From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- toolkit/components/passwordmgr/.eslintrc.js | 17 + toolkit/components/passwordmgr/CSV.sys.mjs | 122 + .../components/passwordmgr/FirefoxRelay.sys.mjs | 662 ++++ .../passwordmgr/FirefoxRelayTelemetry.mjs | 74 + .../passwordmgr/InsecurePasswordUtils.sys.mjs | 214 ++ .../passwordmgr/LoginAutoComplete.sys.mjs | 763 +++++ .../components/passwordmgr/LoginCSVImport.sys.mjs | 221 ++ toolkit/components/passwordmgr/LoginExport.sys.mjs | 76 + .../passwordmgr/LoginFormFactory.sys.mjs | 150 + toolkit/components/passwordmgr/LoginHelper.sys.mjs | 1891 ++++++++++++ toolkit/components/passwordmgr/LoginInfo.sys.mjs | 139 + .../components/passwordmgr/LoginManager.shared.mjs | 48 + .../components/passwordmgr/LoginManager.sys.mjs | 707 +++++ .../passwordmgr/LoginManagerAuthPrompter.sys.mjs | 1115 +++++++ .../passwordmgr/LoginManagerChild.sys.mjs | 3183 ++++++++++++++++++++ .../passwordmgr/LoginManagerContextMenu.sys.mjs | 240 ++ .../passwordmgr/LoginManagerParent.sys.mjs | 1524 ++++++++++ .../passwordmgr/LoginManagerPrompter.sys.mjs | 1116 +++++++ .../components/passwordmgr/LoginRecipes.sys.mjs | 383 +++ .../passwordmgr/LoginRelatedRealms.sys.mjs | 110 + toolkit/components/passwordmgr/LoginStore.sys.mjs | 182 ++ .../passwordmgr/NewPasswordModel.sys.mjs | 681 +++++ .../components/passwordmgr/OSCrypto_win.sys.mjs | 284 ++ .../passwordmgr/PasswordGenerator.sys.mjs | 229 ++ .../passwordmgr/PasswordRulesManager.sys.mjs | 130 + .../passwordmgr/PasswordRulesParser.sys.mjs | 695 +++++ .../passwordmgr/SignUpFormRuleset.sys.mjs | 579 ++++ toolkit/components/passwordmgr/components.conf | 86 + toolkit/components/passwordmgr/crypto-SDR.sys.mjs | 309 ++ toolkit/components/passwordmgr/jar.mn | 6 + toolkit/components/passwordmgr/moz.build | 86 + .../passwordmgr/nsILoginAutoCompleteSearch.idl | 31 + toolkit/components/passwordmgr/nsILoginInfo.idl | 149 + toolkit/components/passwordmgr/nsILoginManager.idl | 334 ++ .../passwordmgr/nsILoginManagerAuthPrompter.idl | 44 + .../passwordmgr/nsILoginManagerCrypto.idl | 89 + .../passwordmgr/nsILoginManagerPrompter.idl | 103 + .../passwordmgr/nsILoginManagerStorage.idl | 258 ++ .../components/passwordmgr/nsILoginMetaInfo.idl | 54 + .../components/passwordmgr/nsIPromptInstance.idl | 17 + .../passwordmgr/storage-geckoview.sys.mjs | 256 ++ .../components/passwordmgr/storage-json.sys.mjs | 879 ++++++ .../passwordmgr/test/LoginTestUtils.sys.mjs | 654 ++++ .../components/passwordmgr/test/authenticate.sjs | 229 ++ toolkit/components/passwordmgr/test/blank.html | 8 + .../passwordmgr/test/browser/.eslintrc.js | 7 + .../passwordmgr/test/browser/authenticate.sjs | 84 + .../passwordmgr/test/browser/browser.ini | 162 + .../test/browser/browser_DOMFormHasPassword.js | 152 + .../browser/browser_DOMFormHasPossibleUsername.js | 254 ++ .../test/browser/browser_DOMInputPasswordAdded.js | 101 + .../browser_autocomplete_autofocus_with_frame.js | 48 + ...autocomplete_disabled_readonly_passwordField.js | 138 + .../test/browser/browser_autocomplete_footer.js | 125 + ...tocomplete_generated_password_private_window.js | 111 + .../test/browser/browser_autocomplete_import.js | 259 ++ .../browser_autocomplete_insecure_warning.js | 44 + .../browser_autocomplete_primary_password.js | 121 + .../browser/browser_autofill_hidden_document.js | 205 ++ .../test/browser/browser_autofill_http.js | 135 + .../browser_autofill_track_filled_logins.js | 111 + .../test/browser/browser_basicAuth_multiTab.js | 158 + .../test/browser/browser_basicAuth_rateLimit.js | 146 + .../test/browser/browser_basicAuth_switchTab.js | 34 + .../test/browser/browser_context_menu.js | 678 +++++ ...rowser_context_menu_autocomplete_interaction.js | 120 + .../browser_context_menu_generated_password.js | 482 +++ .../test/browser/browser_context_menu_iframe.js | 223 ++ ...owser_crossOriginSubmissionUsesCorrectOrigin.js | 53 + .../test/browser/browser_deleteLoginsBackup.js | 282 ++ .../browser_doorhanger_autocomplete_values.js | 274 ++ ...owser_doorhanger_autofill_then_save_password.js | 181 ++ .../test/browser/browser_doorhanger_crossframe.js | 236 ++ .../browser_doorhanger_dismissed_for_ccnumber.js | 202 ++ .../browser/browser_doorhanger_empty_password.js | 42 + .../browser_doorhanger_form_password_edit.js | 562 ++++ .../browser_doorhanger_generated_password.js | 1845 ++++++++++++ .../browser/browser_doorhanger_httpsUpgrade.js | 303 ++ .../browser/browser_doorhanger_multipage_form.js | 182 ++ .../browser/browser_doorhanger_password_edits.js | 220 ++ .../browser_doorhanger_promptToChangePassword.js | 685 +++++ .../test/browser/browser_doorhanger_remembering.js | 1275 ++++++++ ...replace_dismissed_with_visible_while_opening.js | 65 + .../browser/browser_doorhanger_save_password.js | 159 + .../browser/browser_doorhanger_submit_telemetry.js | 387 +++ .../browser/browser_doorhanger_target_blank.js | 94 + .../test/browser/browser_doorhanger_toggles.js | 478 +++ .../browser/browser_doorhanger_username_edits.js | 192 ++ .../test/browser/browser_doorhanger_window_open.js | 201 ++ .../test/browser/browser_entry_point_telemetry.js | 103 + .../test/browser/browser_exceptions_dialog.js | 141 + .../test/browser/browser_fileURIOrigin.js | 51 + .../browser_focus_before_first_DOMContentLoaded.js | 103 + .../test/browser/browser_form_history_fallback.js | 65 + .../test/browser/browser_formless_submit_chrome.js | 161 + .../browser_insecurePasswordConsoleWarning.js | 131 + .../test/browser/browser_isProbablyASignUpForm.js | 42 + .../test/browser/browser_localip_frame.js | 86 + .../test/browser/browser_message_onFormSubmit.js | 82 + .../test/browser/browser_openPasswordManager.js | 161 + .../test/browser/browser_preselect_login.js | 183 ++ .../test/browser/browser_private_window.js | 954 ++++++ .../test/browser/browser_proxyAuth_prompt.js | 182 ++ .../test/browser/browser_relay_telemetry.js | 514 ++++ .../browser/browser_telemetry_SignUpFormRuleset.js | 57 + .../browser_test_changeContentInputValue.js | 129 + .../browser_username_only_form_telemetry.js | 198 ++ .../test/browser/browser_username_select_dialog.js | 177 ++ .../components/passwordmgr/test/browser/empty.html | 8 + .../browser/file_focus_before_DOMContentLoaded.sjs | 35 + .../test/browser/form_autofocus_frame.html | 10 + .../test/browser/form_autofocus_js.html | 10 + .../passwordmgr/test/browser/form_basic.html | 12 + .../test/browser/form_basic_iframe.html | 23 + .../passwordmgr/test/browser/form_basic_login.html | 12 + .../test/browser/form_basic_no_username.html | 11 + .../test/browser/form_basic_signup.html | 10 + .../browser/form_basic_with_confirm_field.html | 13 + .../browser/form_cross_origin_insecure_action.html | 12 + .../browser/form_cross_origin_secure_action.html | 12 + .../passwordmgr/test/browser/form_crossframe.html | 13 + .../test/browser/form_crossframe_inner.html | 13 + .../form_disabled_readonly_passwordField.html | 12 + .../passwordmgr/test/browser/form_expanded.html | 16 + .../passwordmgr/test/browser/form_multipage.html | 32 + .../test/browser/form_password_change.html | 17 + .../test/browser/form_same_origin_action.html | 12 + .../test/browser/form_signup_detection.html | 31 + .../passwordmgr/test/browser/formless_basic.html | 18 + .../components/passwordmgr/test/browser/head.js | 965 ++++++ .../passwordmgr/test/browser/insecure_test.html | 9 + .../test/browser/insecure_test_subframe.html | 16 + .../passwordmgr/test/browser/multiple_forms.html | 145 + .../test/browser/subtst_notifications_1.html | 29 + .../test/browser/subtst_notifications_10.html | 27 + .../test/browser/subtst_notifications_11.html | 26 + .../browser/subtst_notifications_11_popup.html | 32 + .../subtst_notifications_12_target_blank.html | 31 + .../test/browser/subtst_notifications_2.html | 30 + .../test/browser/subtst_notifications_2pw_0un.html | 27 + .../subtst_notifications_2pw_1un_1text.html | 31 + .../test/browser/subtst_notifications_3.html | 30 + .../test/browser/subtst_notifications_4.html | 30 + .../test/browser/subtst_notifications_5.html | 26 + .../test/browser/subtst_notifications_6.html | 27 + .../test/browser/subtst_notifications_8.html | 29 + .../test/browser/subtst_notifications_9.html | 29 + .../browser/subtst_notifications_change_p.html | 32 + .../test/browser/subtst_privbrowsing_1.html | 16 + toolkit/components/passwordmgr/test/formsubmit.sjs | 37 + .../passwordmgr/test/mochitest/.eslintrc.js | 17 + .../test/mochitest/auth2/authenticate.sjs | 216 ++ .../passwordmgr/test/mochitest/chrome_timeout.js | 14 + .../test/mochitest/file_history_back.html | 14 + .../test/mochitest/form_basic_bfcache.html | 61 + ..._DOM_both_fields_together_in_a_shadow_root.html | 31 + ...adow_DOM_each_field_in_its_own_shadow_root.html | 31 + ..._form_and_fields_together_in_a_shadow_root.html | 33 + ..._DOM_both_fields_together_in_a_shadow_root.html | 34 + ...adow_DOM_each_field_in_its_own_shadow_root.html | 38 + ..._form_and_fields_together_in_a_shadow_root.html | 37 + ..._DOM_both_fields_together_in_a_shadow_root.html | 28 + ...adow_DOM_each_field_in_its_own_shadow_root.html | 28 + ..._form_and_fields_together_in_a_shadow_root.html | 30 + .../passwordmgr/test/mochitest/mochitest.ini | 267 ++ ...ltiple_forms_shadow_DOM_all_known_variants.html | 111 + .../passwordmgr/test/mochitest/pwmgr_common.js | 1063 +++++++ .../test/mochitest/pwmgr_common_parent.js | 249 ++ .../passwordmgr/test/mochitest/slow_image.html | 9 + .../passwordmgr/test/mochitest/slow_image.sjs | 25 + .../test/mochitest/subtst_prefilled_form.html | 18 + .../test/mochitest/subtst_primary_pass.html | 8 + .../test/mochitest/subtst_prompt_async.html | 12 + ...d_between_DOMContentLoaded_and_load_events.html | 73 + ...inManagerContent_passwordEditedOrGenerated.html | 160 + ...ocomplete_autofill_related_realms_no_dupes.html | 92 + .../mochitest/test_autocomplete_basic_form.html | 894 ++++++ ...t_autocomplete_basic_form_formActionOrigin.html | 75 + .../test_autocomplete_basic_form_insecure.html | 849 ++++++ ...est_autocomplete_basic_form_related_realms.html | 106 + .../test_autocomplete_basic_form_subdomain.html | 117 + .../test_autocomplete_hasBeenTypePassword.html | 101 + .../mochitest/test_autocomplete_highlight.html | 87 + .../test_autocomplete_highlight_non_login.html | 91 + ..._autocomplete_highlight_username_only_form.html | 56 + .../test_autocomplete_https_downgrade.html | 105 + .../mochitest/test_autocomplete_https_upgrade.html | 191 ++ .../test_autocomplete_password_generation.html | 574 ++++ ...t_autocomplete_password_generation_confirm.html | 518 ++++ .../mochitest/test_autocomplete_password_open.html | 90 + .../mochitest/test_autocomplete_sandboxed.html | 70 + .../test_autocomplete_tab_between_fields.html | 167 + .../test_autofill_autocomplete_types.html | 112 + .../test_autofill_different_formActionOrigin.html | 91 + .../test_autofill_different_subdomain.html | 150 + .../test/mochitest/test_autofill_from_bfcache.html | 58 + .../test_autofill_hasBeenTypePassword.html | 64 + .../test/mochitest/test_autofill_highlight.html | 58 + .../test_autofill_highlight_empty_username.html | 60 + ...test_autofill_highlight_username_only_form.html | 50 + .../mochitest/test_autofill_https_downgrade.html | 118 + .../mochitest/test_autofill_https_upgrade.html | 148 + .../mochitest/test_autofill_password-only.html | 135 + .../test/mochitest/test_autofill_sandboxed.html | 100 + .../test_autofill_tab_between_fields.html | 154 + .../mochitest/test_autofill_username-only.html | 107 + .../test_autofill_username-only_threshold.html | 83 + .../test/mochitest/test_autofocus_js.html | 114 + .../test/mochitest/test_basic_form.html | 48 + .../test/mochitest/test_basic_form_0pw.html | 70 + .../test/mochitest/test_basic_form_1pw.html | 171 ++ .../test/mochitest/test_basic_form_1pw_2.html | 115 + .../test/mochitest/test_basic_form_2pw_1.html | 190 ++ .../test/mochitest/test_basic_form_2pw_2.html | 111 + .../test/mochitest/test_basic_form_3pw_1.html | 259 ++ .../test_basic_form_honor_autocomplete_off.html | 153 + .../test/mochitest/test_basic_form_html5.html | 165 + .../test/mochitest/test_basic_form_pwevent.html | 50 + .../test/mochitest/test_basic_form_pwonly.html | 212 ++ .../test/mochitest/test_bug_627616.html | 161 + .../test/mochitest/test_bug_776171.html | 57 + .../test/mochitest/test_case_differences.html | 100 + .../test_dismissed_doorhanger_in_shadow_DOM.html | 112 + .../test_formLike_rootElement_with_Shadow_DOM.html | 151 + .../test/mochitest/test_form_action_1.html | 140 + .../test/mochitest/test_form_action_2.html | 173 ++ .../mochitest/test_form_action_javascript.html | 47 + .../test/mochitest/test_formless_autofill.html | 144 + .../test/mochitest/test_formless_submit.html | 243 ++ .../test_formless_submit_form_removal.html | 292 ++ ...test_formless_submit_form_removal_negative.html | 205 ++ .../mochitest/test_formless_submit_navigation.html | 272 ++ .../test_formless_submit_navigation_negative.html | 149 + .../test/mochitest/test_input_events.html | 56 + .../test_input_events_for_identical_values.html | 52 + .../test_insecure_form_field_no_saved_login.html | 91 + .../passwordmgr/test/mochitest/test_maxlength.html | 144 + .../test/mochitest/test_munged_values.html | 364 +++ .../mochitest/test_one_doorhanger_per_un_pw.html | 59 + .../test/mochitest/test_onsubmit_value_change.html | 70 + .../test_password_field_autocomplete.html | 198 ++ .../test/mochitest/test_password_length.html | 150 + .../mochitest/test_passwords_in_type_password.html | 114 + .../test/mochitest/test_primary_password.html | 298 ++ .../passwordmgr/test/mochitest/test_prompt.html | 669 ++++ .../test/mochitest/test_prompt_async.html | 621 ++++ .../test/mochitest/test_prompt_http.html | 319 ++ .../test/mochitest/test_prompt_noWindow.html | 72 + .../test/mochitest/test_prompt_promptAuth.html | 370 +++ .../mochitest/test_prompt_promptAuth_proxy.html | 269 ++ .../test/mochitest/test_recipe_login_fields.html | 212 ++ .../test_submit_without_field_modifications.html | 313 ++ .../test/mochitest/test_username_focus.html | 166 + .../passwordmgr/test/mochitest/test_xhr.html | 164 + .../passwordmgr/test/mochitest/test_xhr_2.html | 56 + .../passwordmgr/test/unit/data/corruptDB.sqlite | Bin 0 -> 32772 bytes .../components/passwordmgr/test/unit/data/key4.db | Bin 0 -> 294912 bytes .../passwordmgr/test/unit/data/signons-v1.sqlite | Bin 0 -> 8192 bytes .../passwordmgr/test/unit/data/signons-v1v2.sqlite | Bin 0 -> 10240 bytes .../passwordmgr/test/unit/data/signons-v2.sqlite | Bin 0 -> 11264 bytes .../passwordmgr/test/unit/data/signons-v2v3.sqlite | Bin 0 -> 12288 bytes .../passwordmgr/test/unit/data/signons-v3.sqlite | Bin 0 -> 11264 bytes .../passwordmgr/test/unit/data/signons-v3v4.sqlite | Bin 0 -> 11264 bytes .../passwordmgr/test/unit/data/signons-v4.sqlite | Bin 0 -> 294912 bytes .../passwordmgr/test/unit/data/signons-v4v5.sqlite | Bin 0 -> 327680 bytes .../passwordmgr/test/unit/data/signons-v5v6.sqlite | Bin 0 -> 327680 bytes .../test/unit/data/signons-v999-2.sqlite | Bin 0 -> 8192 bytes .../passwordmgr/test/unit/data/signons-v999.sqlite | Bin 0 -> 11264 bytes toolkit/components/passwordmgr/test/unit/head.js | 134 + .../passwordmgr/test/unit/test_CSVParser.js | 254 ++ ...test_LoginManagerParent_doAutocompleteSearch.js | 148 + ...test_LoginManagerParent_getGeneratedPassword.js | 176 ++ ...ginManagerParent_onPasswordEditedOrGenerated.js | 1141 +++++++ ...est_LoginManagerParent_searchAndDedupeLogins.js | 207 ++ ..._LoginManagerPrompter_getUsernameSuggestions.js | 188 ++ .../passwordmgr/test/unit/test_OSCrypto_win.js | 138 + .../test/unit/test_PasswordGenerator.js | 122 + .../test_PasswordRulesManager_generatePassword.js | 523 ++++ .../passwordmgr/test/unit/test_context_menu.js | 345 +++ .../passwordmgr/test/unit/test_dedupeLogins.js | 411 +++ .../passwordmgr/test/unit/test_disabled_hosts.js | 223 ++ .../passwordmgr/test/unit/test_displayOrigin.js | 43 + .../passwordmgr/test/unit/test_doLoginsMatch.js | 57 + .../test/unit/test_findRelatedRealms.js | 156 + .../passwordmgr/test/unit/test_getFormFields.js | 572 ++++ .../test/unit/test_getPasswordFields.js | 308 ++ .../test/unit/test_getPasswordOrigin.js | 35 + .../test/unit/test_getUserNameAndPasswordFields.js | 177 ++ .../test_getUsernameFieldFromUsernameOnlyForm.js | 179 ++ .../test/unit/test_isInferredLoginForm.js | 98 + .../test/unit/test_isInferredUsernameField.js | 222 ++ .../passwordmgr/test/unit/test_isOriginMatching.js | 177 ++ .../test/unit/test_isProbablyANewPasswordField.js | 192 ++ .../test/unit/test_isUsernameFieldType.js | 160 + .../unit/test_legacy_empty_formActionOrigin.js | 126 + .../test/unit/test_legacy_validation.js | 94 + .../test/unit/test_login_autocomplete_result.js | 735 +++++ .../passwordmgr/test/unit/test_loginsBackup.js | 218 ++ .../passwordmgr/test/unit/test_logins_change.js | 628 ++++ .../test/unit/test_logins_decrypt_failure.js | 172 ++ .../passwordmgr/test/unit/test_logins_metainfo.js | 295 ++ .../passwordmgr/test/unit/test_logins_search.js | 232 ++ .../passwordmgr/test/unit/test_maybeImportLogin.js | 368 +++ .../test/unit/test_module_LoginCSVImport.js | 870 ++++++ .../test/unit/test_module_LoginExport.js | 219 ++ .../test/unit/test_module_LoginManager.js | 37 + .../test/unit/test_module_LoginStore.js | 337 +++ .../passwordmgr/test/unit/test_notifications.js | 193 ++ .../passwordmgr/test/unit/test_recipes_add.js | 288 ++ .../passwordmgr/test/unit/test_recipes_content.js | 53 + .../passwordmgr/test/unit/test_remote_recipes.js | 162 + .../test/unit/test_search_schemeUpgrades.js | 239 ++ .../passwordmgr/test/unit/test_shadowHTTPLogins.js | 82 + .../passwordmgr/test/unit/test_storage.js | 93 + .../passwordmgr/test/unit/test_telemetry.js | 201 ++ .../test/unit/test_vulnerable_passwords.js | 47 + .../components/passwordmgr/test/unit/xpcshell.ini | 74 + 317 files changed, 65577 insertions(+) create mode 100644 toolkit/components/passwordmgr/.eslintrc.js create mode 100644 toolkit/components/passwordmgr/CSV.sys.mjs create mode 100644 toolkit/components/passwordmgr/FirefoxRelay.sys.mjs create mode 100644 toolkit/components/passwordmgr/FirefoxRelayTelemetry.mjs create mode 100644 toolkit/components/passwordmgr/InsecurePasswordUtils.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginCSVImport.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginExport.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginFormFactory.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginHelper.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginInfo.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginManager.shared.mjs create mode 100644 toolkit/components/passwordmgr/LoginManager.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginManagerChild.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginManagerContextMenu.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginManagerParent.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginRecipes.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginRelatedRealms.sys.mjs create mode 100644 toolkit/components/passwordmgr/LoginStore.sys.mjs create mode 100644 toolkit/components/passwordmgr/NewPasswordModel.sys.mjs create mode 100644 toolkit/components/passwordmgr/OSCrypto_win.sys.mjs create mode 100644 toolkit/components/passwordmgr/PasswordGenerator.sys.mjs create mode 100644 toolkit/components/passwordmgr/PasswordRulesManager.sys.mjs create mode 100644 toolkit/components/passwordmgr/PasswordRulesParser.sys.mjs create mode 100644 toolkit/components/passwordmgr/SignUpFormRuleset.sys.mjs create mode 100644 toolkit/components/passwordmgr/components.conf create mode 100644 toolkit/components/passwordmgr/crypto-SDR.sys.mjs create mode 100644 toolkit/components/passwordmgr/jar.mn create mode 100644 toolkit/components/passwordmgr/moz.build create mode 100644 toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl create mode 100644 toolkit/components/passwordmgr/nsILoginInfo.idl create mode 100644 toolkit/components/passwordmgr/nsILoginManager.idl create mode 100644 toolkit/components/passwordmgr/nsILoginManagerAuthPrompter.idl create mode 100644 toolkit/components/passwordmgr/nsILoginManagerCrypto.idl create mode 100644 toolkit/components/passwordmgr/nsILoginManagerPrompter.idl create mode 100644 toolkit/components/passwordmgr/nsILoginManagerStorage.idl create mode 100644 toolkit/components/passwordmgr/nsILoginMetaInfo.idl create mode 100644 toolkit/components/passwordmgr/nsIPromptInstance.idl create mode 100644 toolkit/components/passwordmgr/storage-geckoview.sys.mjs create mode 100644 toolkit/components/passwordmgr/storage-json.sys.mjs create mode 100644 toolkit/components/passwordmgr/test/LoginTestUtils.sys.mjs create mode 100644 toolkit/components/passwordmgr/test/authenticate.sjs create mode 100644 toolkit/components/passwordmgr/test/blank.html create mode 100644 toolkit/components/passwordmgr/test/browser/.eslintrc.js create mode 100644 toolkit/components/passwordmgr/test/browser/authenticate.sjs create mode 100644 toolkit/components/passwordmgr/test/browser/browser.ini create mode 100644 toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPossibleUsername.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autocomplete_autofocus_with_frame.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autocomplete_disabled_readonly_passwordField.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autocomplete_footer.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autocomplete_generated_password_private_window.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autocomplete_import.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autocomplete_primary_password.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autofill_hidden_document.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autofill_http.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_autofill_track_filled_logins.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_basicAuth_multiTab.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_basicAuth_rateLimit.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_basicAuth_switchTab.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_context_menu.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_crossOriginSubmissionUsesCorrectOrigin.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_deleteLoginsBackup.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_autocomplete_values.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_autofill_then_save_password.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_crossframe.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_dismissed_for_ccnumber.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_empty_password.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_form_password_edit.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_generated_password.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_multipage_form.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_password_edits.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_promptToChangePassword.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_replace_dismissed_with_visible_while_opening.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_save_password.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_submit_telemetry.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_target_blank.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_toggles.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_username_edits.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_doorhanger_window_open.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_entry_point_telemetry.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_fileURIOrigin.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_focus_before_first_DOMContentLoaded.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_localip_frame.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_message_onFormSubmit.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_openPasswordManager.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_preselect_login.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_private_window.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_proxyAuth_prompt.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_test_changeContentInputValue.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_username_only_form_telemetry.js create mode 100644 toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js create mode 100644 toolkit/components/passwordmgr/test/browser/empty.html create mode 100644 toolkit/components/passwordmgr/test/browser/file_focus_before_DOMContentLoaded.sjs create mode 100644 toolkit/components/passwordmgr/test/browser/form_autofocus_frame.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_autofocus_js.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_basic.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_basic_iframe.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_basic_login.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_basic_no_username.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_basic_signup.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_basic_with_confirm_field.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_cross_origin_insecure_action.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_cross_origin_secure_action.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_crossframe.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_crossframe_inner.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_disabled_readonly_passwordField.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_expanded.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_multipage.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_password_change.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_same_origin_action.html create mode 100644 toolkit/components/passwordmgr/test/browser/form_signup_detection.html create mode 100644 toolkit/components/passwordmgr/test/browser/formless_basic.html create mode 100644 toolkit/components/passwordmgr/test/browser/head.js create mode 100644 toolkit/components/passwordmgr/test/browser/insecure_test.html create mode 100644 toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html create mode 100644 toolkit/components/passwordmgr/test/browser/multiple_forms.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_1.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_10.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_11.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_11_popup.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_12_target_blank.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_2.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_0un.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_1un_1text.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_3.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_4.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_5.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_6.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_8.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_9.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_notifications_change_p.html create mode 100644 toolkit/components/passwordmgr/test/browser/subtst_privbrowsing_1.html create mode 100644 toolkit/components/passwordmgr/test/formsubmit.sjs create mode 100644 toolkit/components/passwordmgr/test/mochitest/.eslintrc.js create mode 100644 toolkit/components/passwordmgr/test/mochitest/auth2/authenticate.sjs create mode 100644 toolkit/components/passwordmgr/test/mochitest/chrome_timeout.js create mode 100644 toolkit/components/passwordmgr/test/mochitest/file_history_back.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/form_basic_bfcache.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_each_field_in_its_own_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_both_fields_together_in_a_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_each_field_in_its_own_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_form_and_fields_together_in_a_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_each_field_in_its_own_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/mochitest.ini create mode 100644 toolkit/components/passwordmgr/test/mochitest/multiple_forms_shadow_DOM_all_known_variants.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js create mode 100644 toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js create mode 100644 toolkit/components/passwordmgr/test/mochitest/slow_image.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/slow_image.sjs create mode 100644 toolkit/components/passwordmgr/test/mochitest/subtst_prefilled_form.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/subtst_primary_pass.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/subtst_prompt_async.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_LoginManagerContent_passwordEditedOrGenerated.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_formActionOrigin.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_insecure.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_subdomain.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_hasBeenTypePassword.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_non_login.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_username_only_form.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_downgrade.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_open.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_sandboxed.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autocomplete_tab_between_fields.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_autocomplete_types.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_different_formActionOrigin.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_different_subdomain.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_from_bfcache.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_hasBeenTypePassword.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_empty_username.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_username_only_form.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_https_downgrade.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_sandboxed.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_tab_between_fields.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only_threshold.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_0pw.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw_2.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_1.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_3pw_1.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_honor_autocomplete_off.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_html5.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwevent.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_bug_776171.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_case_differences.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_dismissed_doorhanger_in_shadow_DOM.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_formLike_rootElement_with_Shadow_DOM.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_form_action_1.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_form_action_2.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal_negative.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_input_events.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_input_events_for_identical_values.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_no_saved_login.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_maxlength.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_munged_values.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_one_doorhanger_per_un_pw.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_onsubmit_value_change.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_password_length.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_passwords_in_type_password.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_primary_password.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_prompt.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_prompt_async.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_recipe_login_fields.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_submit_without_field_modifications.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_username_focus.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_xhr.html create mode 100644 toolkit/components/passwordmgr/test/mochitest/test_xhr_2.html create mode 100644 toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/key4.db create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite create mode 100644 toolkit/components/passwordmgr/test/unit/head.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_CSVParser.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_context_menu.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_displayOrigin.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_findRelatedRealms.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_getFormFields.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_getUsernameFieldFromUsernameOnlyForm.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_legacy_validation.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_loginsBackup.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_logins_change.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_logins_search.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_module_LoginManager.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_notifications.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_recipes_add.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_recipes_content.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_remote_recipes.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_storage.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_telemetry.js create mode 100644 toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js create mode 100644 toolkit/components/passwordmgr/test/unit/xpcshell.ini (limited to 'toolkit/components/passwordmgr') diff --git a/toolkit/components/passwordmgr/.eslintrc.js b/toolkit/components/passwordmgr/.eslintrc.js new file mode 100644 index 0000000000..ddb6e4fe25 --- /dev/null +++ b/toolkit/components/passwordmgr/.eslintrc.js @@ -0,0 +1,17 @@ +/* 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.exports = { + rules: { + "block-scoped-var": "error", + + // XXX Bug 1358949 - This should be reduced down - probably to 20 or to + // be removed & synced with the mozilla/recommended value. + complexity: ["error", 59], + + "no-var": "error", + }, +}; diff --git a/toolkit/components/passwordmgr/CSV.sys.mjs b/toolkit/components/passwordmgr/CSV.sys.mjs new file mode 100644 index 0000000000..ef5e78c232 --- /dev/null +++ b/toolkit/components/passwordmgr/CSV.sys.mjs @@ -0,0 +1,122 @@ +/* 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/. */ + +/** + * A Class to parse CSV files + */ + +const QUOTATION_MARK = '"'; +const LINE_BREAKS = ["\r", "\n"]; +const EOL = {}; + +class ParsingFailedException extends Error { + constructor(message) { + super(message ? message : `Stopped parsing because of wrong csv format`); + } +} + +export class CSV { + /** + * Parses a csv formated string into rows split into [headerLine, parsedLines]. + * The csv string format has to follow RFC 4180, otherwise the parsing process is stopped and a ParsingFailedException is thrown, e.g.: + * (wrong format => right format): + * 'abc"def' => 'abc""def' + * abc,def => "abc,def" + * + * @param {string} text + * @param {string} delimiter a comma for CSV files and a tab for TSV files + * @returns {Array[]} headerLine: column names (first line of text), parsedLines: Array of Login Objects with column name as properties and login data as values. + */ + static parse(text, delimiter) { + let headerline = []; + let parsedLines = []; + + for (let row of this.mapValuesToRows(this.readCSV(text, delimiter))) { + if (!headerline.length) { + headerline = row; + } else { + let login = {}; + row.forEach((attr, i) => (login[headerline[i]] = attr)); + parsedLines.push(login); + } + } + return [headerline, parsedLines]; + } + static *readCSV(text, delimiter) { + function maySkipMultipleLineBreaks() { + while (LINE_BREAKS.includes(text[current])) { + current++; + } + } + function readUntilSingleQuote() { + const start = ++current; + while (current < text.length) { + if (text[current] === QUOTATION_MARK) { + if (text[current + 1] !== QUOTATION_MARK) { + const result = text.slice(start, current).replaceAll('""', '"'); + current++; + return result; + } + current++; + } + current++; + } + throw new ParsingFailedException(); + } + function readUntilDelimiterOrNewLine() { + const start = current; + while (current < text.length) { + if (text[current] === delimiter) { + const result = text.slice(start, current); + current++; + return result; + } else if (LINE_BREAKS.includes(text[current])) { + const result = text.slice(start, current); + return result; + } + current++; + } + return text.slice(start); + } + let current = 0; + maySkipMultipleLineBreaks(); + + while (current < text.length) { + if (LINE_BREAKS.includes(text[current])) { + maySkipMultipleLineBreaks(); + yield EOL; + } + + let quotedValue = ""; + let value = ""; + + if (text[current] === QUOTATION_MARK) { + quotedValue = readUntilSingleQuote(); + } + + value = readUntilDelimiterOrNewLine(); + + if (quotedValue && value) { + throw new ParsingFailedException(); + } + + yield quotedValue ? quotedValue : value; + } + } + + static *mapValuesToRows(values) { + let row = []; + for (const value of values) { + if (value === EOL) { + yield row; + row = []; + } else { + row.push(value); + } + } + if (!(row.length === 1 && row[0] === "") && row.length) { + yield row; + } + } +} diff --git a/toolkit/components/passwordmgr/FirefoxRelay.sys.mjs b/toolkit/components/passwordmgr/FirefoxRelay.sys.mjs new file mode 100644 index 0000000000..efff478eeb --- /dev/null +++ b/toolkit/components/passwordmgr/FirefoxRelay.sys.mjs @@ -0,0 +1,662 @@ +/* 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/. */ + +import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs"; +import { + LoginHelper, + OptInFeature, + ParentAutocompleteOption, +} from "resource://gre/modules/LoginHelper.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { TelemetryUtils } from "resource://gre/modules/TelemetryUtils.sys.mjs"; + +const lazy = {}; + +// Static configuration +const gConfig = (function () { + const baseUrl = Services.prefs.getStringPref( + "signon.firefoxRelay.base_url", + undefined + ); + return { + scope: ["https://identity.mozilla.com/apps/relay"], + addressesUrl: baseUrl + `relayaddresses/`, + profilesUrl: baseUrl + `profiles/`, + learnMoreURL: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.learn_more_url" + ), + manageURL: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.manage_url" + ), + relayFeaturePref: "signon.firefoxRelay.feature", + termsOfServiceUrl: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.terms_of_service_url" + ), + privacyPolicyUrl: Services.urlFormatter.formatURLPref( + "signon.firefoxRelay.privacy_policy_url" + ), + }; +})(); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => + LoginHelper.createLogger("FirefoxRelay") +); +XPCOMUtils.defineLazyGetter(lazy, "fxAccounts", () => + ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton() +); +XPCOMUtils.defineLazyGetter(lazy, "strings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/firefoxRelay.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ]); +}); + +if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { + throw new Error("FirefoxRelay.sys.mjs should only run in the parent process"); +} + +async function getRelayTokenAsync() { + try { + return await lazy.fxAccounts.getOAuthToken({ scope: gConfig.scope }); + } catch (e) { + console.error(`There was an error getting the user's token: ${e.message}`); + return undefined; + } +} + +async function hasFirefoxAccountAsync() { + if (!lazy.fxAccounts.constructor.config.isProductionConfig()) { + return false; + } + + return lazy.fxAccounts.hasLocalSession(); +} + +async function fetchWithReauth( + browser, + createRequest, + canGetFreshOAuthToken = true +) { + const relayToken = await getRelayTokenAsync(); + if (!relayToken) { + if (browser) { + await showErrorAsync(browser, "firefox-relay-must-login-to-fxa"); + } + return undefined; + } + + const headers = new Headers({ + Authorization: `Bearer ${relayToken}`, + Accept: "application/json", + "Accept-Language": Services.locale.requestedLocales, + "Content-Type": "application/json", + }); + + const request = createRequest(headers); + const response = await fetch(request); + + if (canGetFreshOAuthToken && response.status == 401) { + await lazy.fxAccounts.removeCachedOAuthToken({ token: relayToken }); + return fetchWithReauth(browser, createRequest, false); + } + return response; +} + +async function isRelayUserAsync() { + if (!(await hasFirefoxAccountAsync())) { + return false; + } + + const response = await fetchWithReauth( + null, + headers => new Request(gConfig.profilesUrl, { headers }) + ); + if (!response) { + return false; + } + + if (!response.ok) { + lazy.log.error( + `failed to check if user is a Relay user: ${response.status}:${ + response.statusText + }:${await response.text()}` + ); + } + + return response.ok; +} + +async function getReusableMasksAsync(browser, _origin) { + const response = await fetchWithReauth( + browser, + headers => + new Request(gConfig.addressesUrl, { + method: "GET", + headers, + }) + ); + + if (!response) { + // fetchWithReauth only returns undefined if login / obtaining a token failed. + // Otherwise, it will return a response object. + return [undefined, RelayFeature.AUTH_TOKEN_ERROR_CODE]; + } + + if (response.ok) { + return [await response.json(), response.status]; + } + + lazy.log.error( + `failed to find reusable Relay masks: ${response.status}:${response.statusText}` + ); + await showErrorAsync(browser, "firefox-relay-get-reusable-masks-failed", { + status: response.status, + }); + + return [undefined, response.status]; +} + +/** + * Show confirmation tooltip + * @param browser + * @param messageId message ID from browser/browser.properties + */ +function showConfirmation(browser, messageId) { + const anchor = browser.ownerDocument.getElementById("identity-icon"); + anchor.ownerGlobal.ConfirmationHint.show(anchor, messageId, {}); +} + +/** + * Show localized notification. + * @param browser + * @param messageId messageId from browser/firefoxRelay.ftl + * @param messageArgs + */ +async function showErrorAsync(browser, messageId, messageArgs) { + const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; + const [message] = await lazy.strings.formatValues([ + { id: messageId, args: messageArgs }, + ]); + PopupNotifications.show( + browser, + "relay-integration-error", + message, + "password-notification-icon", + null, + null, + { + autofocus: true, + removeOnDismissal: true, + popupIconURL: "page-icon:https://relay.firefox.com", + learnMoreURL: gConfig.learnMoreURL, + } + ); +} + +function customizeNotificationHeader(notification) { + const document = notification.owner.panel.ownerDocument; + const description = document.querySelector( + `description[popupid=${notification.id}]` + ); + const headerTemplate = document.getElementById("firefox-relay-header"); + description.replaceChildren(headerTemplate.firstChild.cloneNode(true)); +} + +async function formatMessages(...ids) { + for (let i in ids) { + if (typeof ids[i] == "string") { + ids[i] = { id: ids[i] }; + } + } + + const messages = await lazy.strings.formatMessages(ids); + return messages.map(message => { + if (message.attributes) { + return message.attributes.reduce( + (result, { name, value }) => ({ ...result, [name]: value }), + {} + ); + } + return message.value; + }); +} + +async function showReusableMasksAsync(browser, origin, error) { + const [reusableMasks, status] = await getReusableMasksAsync(browser, origin); + if (!reusableMasks) { + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "shown", + FirefoxRelay.flowId, + status + ); + return null; + } + + let fillUsername; + const fillUsernamePromise = new Promise(resolve => (fillUsername = resolve)); + const [getUnlimitedMasksStrings] = await formatMessages( + "firefox-relay-get-unlimited-masks" + ); + const getUnlimitedMasks = { + label: getUnlimitedMasksStrings.label, + accessKey: getUnlimitedMasksStrings.accesskey, + dismiss: true, + async callback() { + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "get_unlimited_masks", + FirefoxRelay.flowId + ); + browser.ownerGlobal.openWebLinkIn(gConfig.manageURL, "tab"); + }, + }; + + let notification; + + function getReusableMasksList() { + return notification.owner.panel.getElementsByClassName( + "reusable-relay-masks" + )[0]; + } + + function notificationShown() { + customizeNotificationHeader(notification); + + notification.owner.panel.getElementsByClassName( + "error-message" + )[0].textContent = error.detail || ""; + + // rebuild "reuse mask" buttons list + const list = getReusableMasksList(); + list.innerHTML = ""; + + const document = list.ownerDocument; + const fragment = document.createDocumentFragment(); + reusableMasks + .filter(mask => mask.enabled) + .forEach(mask => { + const button = document.createElement("button"); + + const maskFullAddress = document.createElement("span"); + maskFullAddress.textContent = mask.full_address; + button.appendChild(maskFullAddress); + + const maskDescription = document.createElement("span"); + maskDescription.textContent = + mask.description || mask.generated_for || mask.used_on; + button.appendChild(maskDescription); + + button.addEventListener("click", () => { + notification.remove(); + lazy.log.info("Reusing Relay mask"); + fillUsername(mask.full_address); + showConfirmation( + browser, + "confirmation-hint-firefox-relay-mask-reused" + ); + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "reuse_mask", + FirefoxRelay.flowId + ); + }); + fragment.appendChild(button); + }); + list.appendChild(fragment); + } + + function notificationRemoved() { + const list = getReusableMasksList(); + list.innerHTML = ""; + } + + function onNotificationEvent(event) { + switch (event) { + case "removed": + notificationRemoved(); + break; + case "shown": + notificationShown(); + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "shown", + FirefoxRelay.flowId + ); + break; + } + } + + const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; + notification = PopupNotifications.show( + browser, + "relay-integration-reuse-masks", + "", // content is provided after popup shown + "password-notification-icon", + getUnlimitedMasks, + [], + { + autofocus: true, + removeOnDismissal: true, + eventCallback: onNotificationEvent, + } + ); + + return fillUsernamePromise; +} + +async function generateUsernameAsync(browser, origin) { + const body = JSON.stringify({ + enabled: true, + description: origin.substr(0, 64), + generated_for: origin.substr(0, 255), + used_on: origin, + }); + + const response = await fetchWithReauth( + browser, + headers => + new Request(gConfig.addressesUrl, { + method: "POST", + headers, + body, + }) + ); + + if (!response) { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "shown", + FirefoxRelay.flowId, + RelayFeature.AUTH_TOKEN_ERROR_CODE + ); + return undefined; + } + + if (response.ok) { + lazy.log.info(`generated Relay mask`); + const result = await response.json(); + showConfirmation(browser, "confirmation-hint-firefox-relay-mask-created"); + return result.full_address; + } + + if (response.status == 403) { + const error = await response.json(); + if (error?.error_code == "free_tier_limit") { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "shown", + FirefoxRelay.flowId, + error?.error_code + ); + return showReusableMasksAsync(browser, origin, error); + } + } + + lazy.log.error( + `failed to generate Relay mask: ${response.status}:${response.statusText}` + ); + + await showErrorAsync(browser, "firefox-relay-mask-generation-failed", { + status: response.status, + }); + + FirefoxRelayTelemetry.recordRelayReusePanelEvent( + "shown", + FirefoxRelay.flowId, + response.status + ); + + return undefined; +} + +function isSignup(scenarioName) { + return scenarioName == "SignUpFormScenario"; +} + +class RelayOffered { + #isRelayUser; + + async *autocompleteItemsAsync(_origin, scenarioName, hasInput) { + if ( + !hasInput && + isSignup(scenarioName) && + !Services.prefs.prefIsLocked("signon.firefoxRelay.feature") + ) { + if (this.#isRelayUser === undefined) { + this.#isRelayUser = await isRelayUserAsync(); + } + + if (this.#isRelayUser) { + const [title, subtitle] = await formatMessages( + "firefox-relay-opt-in-title-1", + "firefox-relay-opt-in-subtitle-1" + ); + yield new ParentAutocompleteOption( + "page-icon:https://relay.firefox.com", + title, + subtitle, + "PasswordManager:offerRelayIntegration", + { + telemetry: { + flowId: FirefoxRelay.flowId, + isRelayUser: this.#isRelayUser, + scenarioName, + }, + } + ); + FirefoxRelayTelemetry.recordRelayOfferedEvent( + "shown", + FirefoxRelay.flowId, + scenarioName, + this.#isRelayUser + ); + } + } + } + + async offerRelayIntegration(feature, browser, origin) { + const fxaUser = await lazy.fxAccounts.getSignedInUser(); + + if (!fxaUser) { + return null; + } + const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; + let fillUsername; + const fillUsernamePromise = new Promise( + resolve => (fillUsername = resolve) + ); + const [enableStrings, disableStrings, postponeStrings] = + await formatMessages( + "firefox-relay-opt-in-confirmation-enable-button", + "firefox-relay-opt-in-confirmation-disable", + "firefox-relay-opt-in-confirmation-postpone" + ); + const enableIntegration = { + label: enableStrings.label, + accessKey: enableStrings.accesskey, + dismiss: true, + async callback() { + lazy.log.info("user opted in to Firefox Relay integration"); + feature.markAsEnabled(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent( + "enabled", + FirefoxRelay.flowId + ); + fillUsername(await generateUsernameAsync(browser, origin)); + }, + }; + const postpone = { + label: postponeStrings.label, + accessKey: postponeStrings.accesskey, + dismiss: true, + callback() { + lazy.log.info( + "user decided not to decide about Firefox Relay integration" + ); + feature.markAsOffered(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent( + "postponed", + FirefoxRelay.flowId + ); + }, + }; + const disableIntegration = { + label: disableStrings.label, + accessKey: disableStrings.accesskey, + dismiss: true, + callback() { + lazy.log.info("user opted out from Firefox Relay integration"); + feature.markAsDisabled(); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent( + "disabled", + FirefoxRelay.flowId + ); + }, + }; + let notification; + feature.markAsOffered(); + notification = PopupNotifications.show( + browser, + "relay-integration-offer", + "", // content is provided after popup shown + "password-notification-icon", + enableIntegration, + [postpone, disableIntegration], + { + autofocus: true, + removeOnDismissal: true, + learnMoreURL: gConfig.learnMoreURL, + eventCallback: event => { + switch (event) { + case "shown": + customizeNotificationHeader(notification); + const document = notification.owner.panel.ownerDocument; + const tosLink = document.getElementById( + "firefox-relay-offer-tos-url" + ); + tosLink.href = gConfig.termsOfServiceUrl; + const privacyLink = document.getElementById( + "firefox-relay-offer-privacy-url" + ); + privacyLink.href = gConfig.privacyPolicyUrl; + const content = document.querySelector( + `popupnotification[id=${notification.id}-notification] popupnotificationcontent` + ); + const line3 = content.querySelector( + "[id=firefox-relay-offer-what-relay-provides]" + ); + document.l10n.setAttributes( + line3, + "firefox-relay-offer-what-relay-provides", + { + useremail: fxaUser.email, + } + ); + FirefoxRelayTelemetry.recordRelayOptInPanelEvent( + "shown", + FirefoxRelay.flowId + ); + break; + } + }, + } + ); + + return fillUsernamePromise; + } +} + +class RelayEnabled { + async *autocompleteItemsAsync(origin, scenarioName, hasInput) { + if ( + !hasInput && + isSignup(scenarioName) && + (await hasFirefoxAccountAsync()) + ) { + const [title] = await formatMessages("firefox-relay-use-mask-title"); + yield new ParentAutocompleteOption( + "page-icon:https://relay.firefox.com", + title, + "", // when the user has opted-in, there is no subtitle content + "PasswordManager:generateRelayUsername", + { + telemetry: { + flowId: FirefoxRelay.flowId, + }, + } + ); + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "shown", + FirefoxRelay.flowId + ); + } + } + + async generateUsername(browser, origin) { + return generateUsernameAsync(browser, origin); + } +} + +class RelayDisabled {} + +class RelayFeature extends OptInFeature { + // Using 418 to avoid conflict with other standard http error code + static AUTH_TOKEN_ERROR_CODE = 418; + + constructor() { + super(RelayOffered, RelayEnabled, RelayDisabled, gConfig.relayFeaturePref); + Services.telemetry.setEventRecordingEnabled("relay_integration", true); + // Update the config when the signon.firefoxRelay.base_url pref is changed. + // This is added mainly for tests. + Services.prefs.addObserver( + "signon.firefoxRelay.base_url", + this.updateConfig + ); + } + + get learnMoreUrl() { + return gConfig.learnMoreURL; + } + + updateConfig() { + const newBaseUrl = Services.prefs.getStringPref( + "signon.firefoxRelay.base_url" + ); + gConfig.addressesUrl = newBaseUrl + `relayaddresses/`; + gConfig.profilesUrl = newBaseUrl + `profiles/`; + } + + async autocompleteItemsAsync({ origin, scenarioName, hasInput }) { + const result = []; + + // Generate a flowID to unique identify a series of user action. FlowId + // allows us to link users' interaction on different UI component (Ex. autocomplete, notification) + // We can use flowID to build the Funnel Diagram + // This value need to always be regenerated in the entry point of an user + // action so we overwrite the previous one. + this.flowId = TelemetryUtils.generateUUID(); + + if (this.implementation.autocompleteItemsAsync) { + for await (const item of this.implementation.autocompleteItemsAsync( + origin, + scenarioName, + hasInput + )) { + result.push(item); + } + } + + return result; + } + + async generateUsername(browser, origin) { + return this.implementation.generateUsername?.(browser, origin); + } + + async offerRelayIntegration(browser, origin) { + return this.implementation.offerRelayIntegration?.(this, browser, origin); + } +} + +export const FirefoxRelay = new RelayFeature(); diff --git a/toolkit/components/passwordmgr/FirefoxRelayTelemetry.mjs b/toolkit/components/passwordmgr/FirefoxRelayTelemetry.mjs new file mode 100644 index 0000000000..96ad487b0d --- /dev/null +++ b/toolkit/components/passwordmgr/FirefoxRelayTelemetry.mjs @@ -0,0 +1,74 @@ +/* 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/. */ + +export const FirefoxRelayTelemetry = { + recordRelayIntegrationTelemetryEvent( + eventObject, + eventMethod, + eventFlowId, + eventExtras + ) { + Services.telemetry.recordEvent( + "relay_integration", + eventMethod, + eventObject, + eventFlowId ?? "", + eventExtras ?? {} + ); + }, + + recordRelayPrefEvent(eventMethod, eventFlowId, eventExtras) { + this.recordRelayIntegrationTelemetryEvent( + "pref_change", + eventMethod, + eventFlowId, + eventExtras + ); + }, + + recordRelayOfferedEvent(eventMethod, eventFlowId, scenarioName, isRelayUser) { + return this.recordRelayIntegrationTelemetryEvent( + "offer_relay", + eventMethod, + eventFlowId, + { + scenario: scenarioName, + is_relay_user: (isRelayUser ?? "") + "", + } + ); + }, + + recordRelayUsernameFilledEvent(eventMethod, eventFlowId, errorCode = 0) { + return this.recordRelayIntegrationTelemetryEvent( + "fill_username", + eventMethod, + eventFlowId, + { + error_code: errorCode + "", + } + ); + }, + + recordRelayReusePanelEvent(eventMethod, eventFlowId, errorCode = 0) { + return this.recordRelayIntegrationTelemetryEvent( + "reuse_panel", + eventMethod, + eventFlowId, + { + error_code: errorCode + "", + } + ); + }, + + recordRelayOptInPanelEvent(eventMethod, eventFlowId, eventExtras) { + return this.recordRelayIntegrationTelemetryEvent( + "opt_in_panel", + eventMethod, + eventFlowId, + eventExtras + ); + }, +}; + +export default FirefoxRelayTelemetry; diff --git a/toolkit/components/passwordmgr/InsecurePasswordUtils.sys.mjs b/toolkit/components/passwordmgr/InsecurePasswordUtils.sys.mjs new file mode 100644 index 0000000000..05c7eda457 --- /dev/null +++ b/toolkit/components/passwordmgr/InsecurePasswordUtils.sys.mjs @@ -0,0 +1,214 @@ +/* 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/. */ + +/* ownerGlobal doesn't exist in content privileged windows. */ +/* eslint-disable mozilla/use-ownerGlobal */ + +const STRINGS_URI = "chrome://global/locale/security/security.properties"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LoginHelper.createLogger("InsecurePasswordUtils"); +}); + +/* + * A module that provides utility functions for form security. + * + */ +export const InsecurePasswordUtils = { + _formRootsWarned: new WeakMap(), + + /** + * Gets the ID of the inner window of this DOM window. + * + * @param nsIDOMWindow window + * @return integer + * Inner ID for the given window. + */ + _getInnerWindowId(window) { + return window.windowGlobalChild.innerWindowId; + }, + + _sendWebConsoleMessage(messageTag, domDoc) { + let windowId = this._getInnerWindowId(domDoc.defaultView); + let category = "Insecure Password Field"; + // All web console messages are warnings for now. + let flag = Ci.nsIScriptError.warningFlag; + let bundle = Services.strings.createBundle(STRINGS_URI); + let message = bundle.GetStringFromName(messageTag); + let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + consoleMsg.initWithWindowID( + message, + domDoc.location.href, + 0, + 0, + 0, + flag, + category, + windowId + ); + + Services.console.logMessage(consoleMsg); + }, + + /** + * Gets the security state of the passed form. + * + * @param {FormLike} aForm A form-like object. @See {FormLikeFactory} + * + * @returns {Object} An object with the following boolean values: + * isFormSubmitHTTP: if the submit action is an http:// URL + * isFormSubmitSecure: if the submit action URL is secure, + * either because it is HTTPS or because its origin is considered trustworthy + */ + _checkFormSecurity(aForm) { + let isFormSubmitHTTP = false, + isFormSubmitSecure = false; + if (HTMLFormElement.isInstance(aForm.rootElement)) { + let uri = Services.io.newURI( + aForm.rootElement.action || aForm.rootElement.baseURI + ); + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + + if (uri.schemeIs("http")) { + isFormSubmitHTTP = true; + if ( + principal.isOriginPotentiallyTrustworthy || + // Ignore sites with local IP addresses pointing to local forms. + (this._isPrincipalForLocalIPAddress( + aForm.rootElement.nodePrincipal + ) && + this._isPrincipalForLocalIPAddress(principal)) + ) { + isFormSubmitSecure = true; + } + } else { + isFormSubmitSecure = true; + } + } + + return { isFormSubmitHTTP, isFormSubmitSecure }; + }, + + _isPrincipalForLocalIPAddress(aPrincipal) { + let res = aPrincipal.isLocalIpAddress; + if (res) { + lazy.log.debug( + "hasInsecureLoginForms: detected local IP address:", + aPrincipal.asciispec + ); + } + return res; + }, + + /**s + * Checks if there are insecure password fields present on the form's document + * i.e. passwords inside forms with http action, inside iframes with http src, + * or on insecure web pages. + * + * @param {FormLike} aForm A form-like object. @See {LoginFormFactory} + * @return {boolean} whether the form is secure + */ + isFormSecure(aForm) { + let isSafePage = aForm.ownerDocument.defaultView.isSecureContext; + + // Ignore insecure documents with URLs that are local IP addresses. + // This is done because the vast majority of routers and other devices + // on the network do not use HTTPS, making this warning show up almost + // constantly on local connections, which annoys users and hurts our cause. + if (!isSafePage && this._ignoreLocalIPAddress) { + let isLocalIP = this._isPrincipalForLocalIPAddress( + aForm.rootElement.nodePrincipal + ); + + let topIsLocalIP = + aForm.ownerDocument.defaultView.windowGlobalChild.windowContext + .topWindowContext.isLocalIP; + + // Only consider the page safe if the top window has a local IP address + // and, if this is an iframe, the iframe also has a local IP address. + if (isLocalIP && topIsLocalIP) { + isSafePage = true; + } + } + + let { isFormSubmitSecure, isFormSubmitHTTP } = + this._checkFormSecurity(aForm); + + return isSafePage && (isFormSubmitSecure || !isFormSubmitHTTP); + }, + + /** + * Report insecure password fields in a form to the web console to warn developers. + * + * @param {FormLike} aForm A form-like object. @See {FormLikeFactory} + */ + reportInsecurePasswords(aForm) { + if ( + this._formRootsWarned.has(aForm.rootElement) || + this._formRootsWarned.get(aForm.rootElement) + ) { + return; + } + + let domDoc = aForm.ownerDocument; + let isSafePage = domDoc.defaultView.isSecureContext; + + let { isFormSubmitHTTP, isFormSubmitSecure } = + this._checkFormSecurity(aForm); + + if (!isSafePage) { + if (domDoc.defaultView == domDoc.defaultView.parent) { + this._sendWebConsoleMessage("InsecurePasswordsPresentOnPage", domDoc); + } else { + this._sendWebConsoleMessage("InsecurePasswordsPresentOnIframe", domDoc); + } + this._formRootsWarned.set(aForm.rootElement, true); + } else if (isFormSubmitHTTP && !isFormSubmitSecure) { + this._sendWebConsoleMessage("InsecureFormActionPasswordsPresent", domDoc); + this._formRootsWarned.set(aForm.rootElement, true); + } + + // The safety of a password field determined by the form action and the page protocol + let passwordSafety; + if (isSafePage) { + if (isFormSubmitSecure) { + passwordSafety = 0; + } else if (isFormSubmitHTTP) { + passwordSafety = 1; + } else { + passwordSafety = 2; + } + } else if (isFormSubmitSecure) { + passwordSafety = 3; + } else if (isFormSubmitHTTP) { + passwordSafety = 4; + } else { + passwordSafety = 5; + } + + Services.telemetry + .getHistogramById("PWMGR_LOGIN_PAGE_SAFETY") + .add(passwordSafety); + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + InsecurePasswordUtils, + "_ignoreLocalIPAddress", + "security.insecure_field_warning.ignore_local_ip_address", + true +); diff --git a/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs b/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs new file mode 100644 index 0000000000..c487e22075 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs @@ -0,0 +1,763 @@ +/* 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/. */ + +/** + * nsIAutoCompleteResult and nsILoginAutoCompleteSearch implementations for saved logins. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { GenericAutocompleteItem } from "resource://gre/modules/FillHelpers.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs", + LoginFormFactory: "resource://gre/modules/LoginFormFactory.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", + NewPasswordModel: "resource://gre/modules/NewPasswordModel.sys.mjs", +}); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "formFillController", + "@mozilla.org/satchel/form-fill-controller;1", + Ci.nsIFormFillController +); +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LoginHelper.createLogger("LoginAutoComplete"); +}); +XPCOMUtils.defineLazyGetter(lazy, "passwordMgrBundle", () => { + return Services.strings.createBundle( + "chrome://passwordmgr/locale/passwordmgr.properties" + ); +}); +XPCOMUtils.defineLazyGetter(lazy, "dateAndTimeFormatter", () => { + return new Services.intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }); +}); + +function loginSort(formHostPort, a, b) { + let maybeHostPortA = lazy.LoginHelper.maybeGetHostPortForURL(a.origin); + let maybeHostPortB = lazy.LoginHelper.maybeGetHostPortForURL(b.origin); + if (formHostPort == maybeHostPortA && formHostPort != maybeHostPortB) { + return -1; + } + if (formHostPort != maybeHostPortA && formHostPort == maybeHostPortB) { + return 1; + } + + if (a.httpRealm !== b.httpRealm) { + // Sort HTTP auth. logins after form logins for the same origin. + if (b.httpRealm === null) { + return 1; + } + if (a.httpRealm === null) { + return -1; + } + } + + let userA = a.username.toLowerCase(); + let userB = b.username.toLowerCase(); + + if (userA < userB) { + return -1; + } + + if (userA > userB) { + return 1; + } + + return 0; +} + +function findDuplicates(loginList) { + let seen = new Set(); + let duplicates = new Set(); + for (let login of loginList) { + if (seen.has(login.username)) { + duplicates.add(login.username); + } + seen.add(login.username); + } + return duplicates; +} + +function getLocalizedString(key, ...formatArgs) { + if (formatArgs.length) { + return lazy.passwordMgrBundle.formatStringFromName(key, formatArgs); + } + return lazy.passwordMgrBundle.GetStringFromName(key); +} + +class AutocompleteItem { + constructor(style) { + this.comment = ""; + this.style = style; + this.value = ""; + } + + removeFromStorage() { + /* Do nothing by default */ + } +} + +class InsecureLoginFormAutocompleteItem extends AutocompleteItem { + constructor() { + super("insecureWarning"); + + this.label = getLocalizedString( + "insecureFieldWarningDescription2", + getLocalizedString("insecureFieldWarningLearnMore") + ); + } +} + +class LoginAutocompleteItem extends AutocompleteItem { + login; + #actor; + + constructor( + login, + hasBeenTypePassword, + duplicateUsernames, + actor, + isOriginMatched + ) { + super("loginWithOrigin"); + this.login = login.QueryInterface(Ci.nsILoginMetaInfo); + this.#actor = actor; + + const isDuplicateUsername = + login.username && duplicateUsernames.has(login.username); + + let username = login.username + ? login.username + : getLocalizedString("noUsername"); + + // If login is empty or duplicated we want to append a modification date to it. + if (!login.username || isDuplicateUsername) { + const time = lazy.dateAndTimeFormatter.format( + new Date(login.timePasswordChanged) + ); + username = getLocalizedString("loginHostAge", username, time); + } + + this.label = username; + this.value = hasBeenTypePassword ? login.password : login.username; + this.comment = JSON.stringify({ + guid: login.guid, + login, + isDuplicateUsername, + isOriginMatched, + comment: + isOriginMatched && login.httpRealm === null + ? getLocalizedString("displaySameOrigin") + : login.displayOrigin, + }); + } + + removeFromStorage() { + if (this.#actor) { + let vanilla = lazy.LoginHelper.loginToVanillaObject(this.login); + this.#actor.sendAsyncMessage("PasswordManager:removeLogin", { + login: vanilla, + }); + } else { + Services.logins.removeLogin(this.login); + } + } +} + +class GeneratedPasswordAutocompleteItem extends AutocompleteItem { + constructor(generatedPassword, willAutoSaveGeneratedPassword) { + super("generatedPassword"); + + this.label = getLocalizedString("useASecurelyGeneratedPassword"); + + this.value = generatedPassword; + + this.comment = JSON.stringify({ + generatedPassword, + willAutoSaveGeneratedPassword, + }); + } +} + +class ImportableLearnMoreAutocompleteItem extends AutocompleteItem { + constructor() { + super("importableLearnMore"); + this.comment = JSON.stringify({ + fillMessageName: "PasswordManager:OpenImportableLearnMore", + }); + } +} + +class ImportableLoginsAutocompleteItem extends AutocompleteItem { + #actor; + + constructor(browserId, hostname, actor) { + super("importableLogins"); + this.label = browserId; + this.comment = JSON.stringify({ + hostname, + fillMessageName: "PasswordManager:HandleImportable", + fillMessageData: { + browserId, + }, + }); + this.#actor = actor; + + // This is sent for every item (re)shown, but the parent will debounce to + // reduce the count by 1 total. + this.#actor.sendAsyncMessage( + "PasswordManager:decreaseSuggestImportCount", + 1 + ); + } + + removeFromStorage() { + this.#actor.sendAsyncMessage( + "PasswordManager:decreaseSuggestImportCount", + 100 + ); + } +} + +class LoginsFooterAutocompleteItem extends AutocompleteItem { + constructor(formHostname, telemetryEventData) { + super("loginsFooter"); + + this.label = getLocalizedString("viewSavedLogins.label"); + + // The comment field of `loginsFooter` results have many additional pieces of + // information for telemetry purposes. After bug 1555209, this information + // can be passed to the parent process outside of nsIAutoCompleteResult APIs + // so we won't need this hack. + this.comment = JSON.stringify({ + telemetryEventData, + formHostname, + fillMessageName: "PasswordManager:OpenPreferences", + fillMessageData: { + entryPoint: "autocomplete", + }, + }); + } +} + +// nsIAutoCompleteResult implementation +export class LoginAutoCompleteResult { + #rows = []; + + constructor( + aSearchString, + matchingLogins, + autocompleteItems, + formOrigin, + { + generatedPassword, + willAutoSaveGeneratedPassword, + importable, + isSecure, + actor, + hasBeenTypePassword, + hostname, + telemetryEventData, + } + ) { + let hidingFooterOnPWFieldAutoOpened = false; + const importableBrowsers = + importable?.state === "import" && importable?.browsers; + + function isFooterEnabled() { + // We need to check LoginHelper.enabled here since the insecure warning should + // appear even if pwmgr is disabled but the footer should never appear in that case. + if ( + !lazy.LoginHelper.showAutoCompleteFooter || + !lazy.LoginHelper.enabled + ) { + return false; + } + + // Don't show the footer on non-empty password fields as it's not providing + // value and only adding noise since a password was already filled. + if (hasBeenTypePassword && aSearchString && !generatedPassword) { + lazy.log.debug("Hiding footer: non-empty password field"); + return false; + } + + if ( + !autocompleteItems?.length && + !importableBrowsers && + !matchingLogins.length && + !generatedPassword && + hasBeenTypePassword && + lazy.formFillController.passwordPopupAutomaticallyOpened + ) { + hidingFooterOnPWFieldAutoOpened = true; + lazy.log.debug( + "Hiding footer: no logins and the popup was opened upon focus of the pw. field" + ); + return false; + } + + return true; + } + + this.searchString = aSearchString; + + // Insecure field warning comes first. + if (!isSecure) { + this.#rows.push(new InsecureLoginFormAutocompleteItem()); + } + + // Saved login items + let formHostPort = lazy.LoginHelper.maybeGetHostPortForURL(formOrigin); + let logins = matchingLogins.sort(loginSort.bind(null, formHostPort)); + let duplicateUsernames = findDuplicates(matchingLogins); + + for (let login of logins) { + let item = new LoginAutocompleteItem( + login, + hasBeenTypePassword, + duplicateUsernames, + actor, + lazy.LoginHelper.isOriginMatching(login.origin, formOrigin, { + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + }) + ); + this.#rows.push(item); + } + + // The footer comes last if it's enabled + if (isFooterEnabled()) { + if (autocompleteItems) { + this.#rows.push( + ...autocompleteItems.map( + item => + new GenericAutocompleteItem( + item.icon, + item.title, + item.subtitle, + item.fillMessageName, + item.fillMessageData + ) + ) + ); + } + + if (generatedPassword) { + this.#rows.push( + new GeneratedPasswordAutocompleteItem( + generatedPassword, + willAutoSaveGeneratedPassword + ) + ); + } + + // Suggest importing logins if there are none found. + if (!logins.length && importableBrowsers) { + this.#rows.push( + ...importableBrowsers.map( + browserId => + new ImportableLoginsAutocompleteItem(browserId, hostname, actor) + ) + ); + this.#rows.push(new ImportableLearnMoreAutocompleteItem()); + } + + // If we have anything in autocomplete, then add "View Saved Logins" + this.#rows.push( + new LoginsFooterAutocompleteItem(hostname, telemetryEventData) + ); + } + + // Determine the result code and default index. + if (this.matchCount > 0) { + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS; + this.defaultIndex = 0; + } else if (hidingFooterOnPWFieldAutoOpened) { + // We use a failure result so that the empty results aren't re-used for when + // the user tries to manually open the popup (we want the footer in that case). + this.searchResult = Ci.nsIAutoCompleteResult.RESULT_FAILURE; + this.defaultIndex = -1; + } + } + + QueryInterface = ChromeUtils.generateQI([ + "nsIAutoCompleteResult", + "nsISupportsWeakReference", + ]); + + /** + * Accessed via .wrappedJSObject + * @private + */ + get logins() { + return this.#rows + .filter(item => item instanceof LoginAutocompleteItem) + .map(item => item.login); + } + + // Allow autoCompleteSearch to get at the JS object so it can + // modify some readonly properties for internal use. + get wrappedJSObject() { + return this; + } + + // Interfaces from idl... + searchString = null; + searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH; + defaultIndex = -1; + errorDescription = ""; + + get matchCount() { + return this.#rows.length; + } + + #throwOnBadIndex(index) { + if (index < 0 || index >= this.matchCount) { + throw new Error("Index out of range."); + } + } + + getValueAt(index) { + this.#throwOnBadIndex(index); + return this.#rows[index].value; + } + + getLabelAt(index) { + this.#throwOnBadIndex(index); + return this.#rows[index].label; + } + + getCommentAt(index) { + this.#throwOnBadIndex(index); + return this.#rows[index].comment; + } + + getStyleAt(index) { + this.#throwOnBadIndex(index); + return this.#rows[index].style; + } + + getImageAt(index) { + this.#throwOnBadIndex(index); + return ""; + } + + getFinalCompleteValueAt(index) { + return this.getValueAt(index); + } + + isRemovableAt(index) { + this.#throwOnBadIndex(index); + return true; + } + + removeValueAt(index) { + this.#throwOnBadIndex(index); + + let [removedItem] = this.#rows.splice(index, 1); + + if (this.defaultIndex > this.#rows.length) { + this.defaultIndex--; + } + + removedItem.removeFromStorage(); + } +} + +export class LoginAutoComplete { + // HTMLInputElement to number, the element's new-password heuristic confidence score + #cachedNewPasswordScore = new WeakMap(); + #autoCompleteLookupPromise = null; + classID = Components.ID("{2bdac17c-53f1-4896-a521-682ccdeef3a8}"); + QueryInterface = ChromeUtils.generateQI(["nsILoginAutoCompleteSearch"]); + + /** + * Yuck. This is called directly by satchel: + * nsFormFillController::StartSearch() + * [toolkit/components/satchel/nsFormFillController.cpp] + * + * We really ought to have a simple way for code to register an + * auto-complete provider, and not have satchel calling pwmgr directly. + * + * @param {string} aSearchString The value typed in the field. + * @param {nsIAutoCompleteResult} aPreviousResult + * @param {HTMLInputElement} aElement + * @param {nsIFormAutoCompleteObserver} aCallback + */ + startSearch(aSearchString, aPreviousResult, aElement, aCallback) { + let { isNullPrincipal } = aElement.nodePrincipal; + if ( + aElement.nodePrincipal.schemeIs("about") || + aElement.nodePrincipal.isSystemPrincipal + ) { + // Don't show autocomplete results for about: pages. + // XXX: Don't we need to call the callback here? + return; + } + + let searchStartTimeMS = Services.telemetry.msSystemNow(); + + // Show the insecure login warning in the passwords field on null principal documents. + // Avoid loading InsecurePasswordUtils.jsm in a sandboxed document (e.g. an ad. frame) if we + // already know it has a null principal and will therefore get the insecure autocomplete + // treatment. + // InsecurePasswordUtils doesn't handle the null principal case as not secure because we don't + // want the same treatment: + // * The web console warnings will be confusing (as they're primarily about http:) and not very + // useful if the developer intentionally sandboxed the document. + // * The site identity insecure field warning would require LoginManagerChild being loaded and + // listening to some of the DOM events we're ignoring in null principal documents. For memory + // reasons it's better to not load LMC at all for these sandboxed frames. Also, if the top- + // document is sandboxing a document, it probably doesn't want that sandboxed document to be + // able to affect the identity icon in the address bar by adding a password field. + let form = lazy.LoginFormFactory.createFromField(aElement); + let isSecure = + !isNullPrincipal && lazy.InsecurePasswordUtils.isFormSecure(form); + let { hasBeenTypePassword } = aElement; + let hostname = aElement.ownerDocument.documentURIObject.host; + let formOrigin = lazy.LoginHelper.getLoginOrigin( + aElement.ownerDocument.documentURI + ); + let loginManagerActor = lazy.LoginManagerChild.forWindow( + aElement.ownerGlobal + ); + let completeSearch = async autoCompleteLookupPromise => { + // Assign to the member synchronously before awaiting the Promise. + this.#autoCompleteLookupPromise = autoCompleteLookupPromise; + + let { + generatedPassword, + importable, + logins, + autocompleteItems, + willAutoSaveGeneratedPassword, + } = await autoCompleteLookupPromise; + + // If the search was canceled before we got our + // results, don't bother reporting them. + // N.B. This check must occur after the `await` above for it to be + // effective. + if (this.#autoCompleteLookupPromise !== autoCompleteLookupPromise) { + lazy.log.debug("Ignoring result from previous search."); + return; + } + + let telemetryEventData = { + acFieldName: aElement.getAutocompleteInfo().fieldName, + hadPrevious: !!aPreviousResult, + typeWasPassword: aElement.hasBeenTypePassword, + fieldType: aElement.type, + searchStartTimeMS, + stringLength: aSearchString.length, + }; + + this.#autoCompleteLookupPromise = null; + let results = new LoginAutoCompleteResult( + aSearchString, + logins, + autocompleteItems, + formOrigin, + { + generatedPassword, + willAutoSaveGeneratedPassword, + importable, + actor: loginManagerActor, + isSecure, + hasBeenTypePassword, + hostname, + telemetryEventData, + } + ); + aCallback.onSearchCompletion(results); + }; + + if (isNullPrincipal) { + // Don't search login storage when the field has a null principal as we don't want to fill + // logins for the `location` in this case. + completeSearch(Promise.resolve({ logins: [] })); + return; + } + + if ( + hasBeenTypePassword && + aSearchString && + !loginManagerActor.isPasswordGenerationForcedOn(aElement) + ) { + // Return empty result on password fields with password already filled, + // unless password generation was forced. + completeSearch(Promise.resolve({ logins: [] })); + return; + } + + if (!lazy.LoginHelper.enabled) { + completeSearch(Promise.resolve({ logins: [] })); + return; + } + + let previousResult; + if (aPreviousResult) { + previousResult = { + searchString: aPreviousResult.searchString, + logins: lazy.LoginHelper.loginsToVanillaObjects( + aPreviousResult.wrappedJSObject.logins + ), + }; + } else { + previousResult = null; + } + + let acLookupPromise = this.#requestAutoCompleteResultsFromParent({ + searchString: aSearchString, + previousResult, + inputElement: aElement, + form, + hasBeenTypePassword, + }); + completeSearch(acLookupPromise).catch(lazy.log.error.bind(lazy.log)); + } + + stopSearch() { + this.#autoCompleteLookupPromise = null; + } + + async #requestAutoCompleteResultsFromParent({ + searchString, + previousResult, + inputElement, + form, + hasBeenTypePassword, + }) { + let actionOrigin = lazy.LoginHelper.getFormActionOrigin(form); + let autocompleteInfo = inputElement.getAutocompleteInfo(); + + let loginManagerActor = lazy.LoginManagerChild.forWindow( + inputElement.ownerGlobal + ); + let forcePasswordGeneration = false; + let isProbablyANewPasswordField = false; + if (hasBeenTypePassword) { + forcePasswordGeneration = + loginManagerActor.isPasswordGenerationForcedOn(inputElement); + // Run the Fathom model only if the password field does not have the + // autocomplete="new-password" attribute. + isProbablyANewPasswordField = + autocompleteInfo.fieldName == "new-password" || + this.isProbablyANewPasswordField(inputElement); + } + const scenario = loginManagerActor.getScenario(inputElement); + + if (lazy.LoginHelper.showAutoCompleteFooter) { + gAutoCompleteListener.init(); + } + + lazy.log.debug("LoginAutoComplete search:", { + forcePasswordGeneration, + hasBeenTypePassword, + isProbablyANewPasswordField, + searchStringLength: searchString.length, + }); + + const result = await loginManagerActor.sendQuery( + "PasswordManager:autoCompleteLogins", + { + actionOrigin, + searchString, + previousResult, + forcePasswordGeneration, + hasBeenTypePassword, + isProbablyANewPasswordField, + scenarioName: scenario?.constructor.name, + inputMaxLength: inputElement.maxLength, + } + ); + + return { + generatedPassword: result.generatedPassword, + importable: result.importable, + autocompleteItems: result.autocompleteItems, + logins: lazy.LoginHelper.vanillaObjectsToLogins(result.logins), + willAutoSaveGeneratedPassword: result.willAutoSaveGeneratedPassword, + }; + } + + isProbablyANewPasswordField(inputElement) { + const threshold = lazy.LoginHelper.generationConfidenceThreshold; + if (threshold == -1) { + // Fathom is disabled + return false; + } + + let score = this.#cachedNewPasswordScore.get(inputElement); + if (score) { + return score >= threshold; + } + + const { rules, type } = lazy.NewPasswordModel; + const results = rules.against(inputElement); + score = results.get(inputElement).scoreFor(type); + this.#cachedNewPasswordScore.set(inputElement, score); + return score >= threshold; + } +} + +let gAutoCompleteListener = { + added: false, + fillRequestId: 0, + + init() { + if (!this.added) { + Services.obs.addObserver(this, "autocomplete-will-enter-text"); + this.added = true; + } + }, + + async observe(subject, topic, data) { + switch (topic) { + case "autocomplete-will-enter-text": { + await this.sendFillRequestToLoginManagerParent(subject, data); + break; + } + } + }, + + async sendFillRequestToLoginManagerParent(input, comment) { + if (!comment) { + return; + } + + if (input != lazy.formFillController.controller.input) { + return; + } + + const { fillMessageName, fillMessageData } = JSON.parse(comment ?? "{}"); + if (!fillMessageName) { + return; + } + + this.fillRequestId++; + const fillRequestId = this.fillRequestId; + const child = lazy.LoginManagerChild.forWindow( + input.focusedInput.ownerGlobal + ); + const value = await child.sendQuery(fillMessageName, fillMessageData ?? {}); + + // skip fill if another fill operation started during await + if (fillRequestId != this.fillRequestId) { + return; + } + + if (typeof value !== "string") { + return; + } + + // If LoginManagerParent returned a string to fill, we must do it here because + // nsAutoCompleteController.cpp already finished it's work before we finished await. + input.textValue = value; + input.selectTextRange(value.length, value.length); + }, +}; diff --git a/toolkit/components/passwordmgr/LoginCSVImport.sys.mjs b/toolkit/components/passwordmgr/LoginCSVImport.sys.mjs new file mode 100644 index 0000000000..247ed80a3a --- /dev/null +++ b/toolkit/components/passwordmgr/LoginCSVImport.sys.mjs @@ -0,0 +1,221 @@ +/* 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/. */ + +/** + * Provides a class to import login-related data CSV files. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CSV: "resource://gre/modules/CSV.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + ResponsivenessMonitor: "resource://gre/modules/ResponsivenessMonitor.sys.mjs", +}); + +/** + * All the CSV column names will be converted to lower case before lookup + * so they must be specified here in lower case. + */ +const FIELD_TO_CSV_COLUMNS = { + origin: ["url", "login_uri"], + username: ["username", "login_username"], + password: ["password", "login_password"], + httpRealm: ["httprealm"], + formActionOrigin: ["formactionorigin"], + guid: ["guid"], + timeCreated: ["timecreated"], + timeLastUsed: ["timelastused"], + timePasswordChanged: ["timepasswordchanged"], +}; + +export const ImportFailedErrorType = Object.freeze({ + CONFLICTING_VALUES_ERROR: "CONFLICTING_VALUES_ERROR", + FILE_FORMAT_ERROR: "FILE_FORMAT_ERROR", + FILE_PERMISSIONS_ERROR: "FILE_PERMISSIONS_ERROR", + UNABLE_TO_READ_ERROR: "UNABLE_TO_READ_ERROR", +}); + +export class ImportFailedException extends Error { + constructor(errorType, message) { + super(message != null ? message : errorType); + this.errorType = errorType; + } +} + +/** + * Provides an object that has a method to import login-related data CSV files + */ +export class LoginCSVImport { + /** + * Returns a map that has the csv column name as key and the value the field name. + * + * @returns {Map} A map that has the csv column name as key and the value the field name. + */ + static _getCSVColumnToFieldMap() { + let csvColumnToField = new Map(); + for (let [field, columns] of Object.entries(FIELD_TO_CSV_COLUMNS)) { + for (let column of columns) { + csvColumnToField.set(column.toLowerCase(), field); + } + } + return csvColumnToField; + } + + /** + * Builds a vanilla JS object containing all the login fields from a row of CSV cells. + * + * @param {object} csvObject + * An object created from a csv row. The keys are the csv column names, the values are the cells. + * @param {Map} csvColumnToFieldMap + * A map where the keys are the csv properties and the values are the object keys. + * @returns {object} Representing login object with only properties, not functions. + */ + static _getVanillaLoginFromCSVObject(csvObject, csvColumnToFieldMap) { + let vanillaLogin = Object.create(null); + for (let columnName of Object.keys(csvObject)) { + let fieldName = csvColumnToFieldMap.get(columnName.toLowerCase()); + if (!fieldName) { + continue; + } + + if ( + typeof vanillaLogin[fieldName] != "undefined" && + vanillaLogin[fieldName] !== csvObject[columnName] + ) { + // Differing column values map to one property. + // e.g. if two headings map to `origin` we won't know which to use. + return {}; + } + + vanillaLogin[fieldName] = csvObject[columnName]; + } + + // Since `null` can't be represented in a CSV file and the httpRealm header + // cannot be an empty string, assume that an empty httpRealm means this is + // a form login and therefore null-out httpRealm. + if (vanillaLogin.httpRealm === "") { + vanillaLogin.httpRealm = null; + } + + return vanillaLogin; + } + static _recordHistogramTelemetry(histogram, report) { + for (let reportRow of report) { + let { result } = reportRow; + if (result.includes("error")) { + histogram.add("error"); + } else { + histogram.add(result); + } + } + } + /** + * Imports logins from a CSV file (comma-separated values file). + * Existing logins may be updated in the process. + * + * @param {string} filePath + * @returns {Object[]} An array of rows where each is mapped to a row in the CSV and it's import information. + */ + static async importFromCSV(filePath) { + TelemetryStopwatch.start("PWMGR_IMPORT_LOGINS_FROM_FILE_MS"); + let responsivenessMonitor; + try { + responsivenessMonitor = new lazy.ResponsivenessMonitor(); + let csvColumnToFieldMap = LoginCSVImport._getCSVColumnToFieldMap(); + let csvFieldToColumnMap = new Map(); + + let csvString; + try { + csvString = await IOUtils.readUTF8(filePath, { encoding: "utf-8" }); + } catch (ex) { + console.error(ex); + throw new ImportFailedException( + ImportFailedErrorType.FILE_PERMISSIONS_ERROR + ); + } + let headerLine; + let parsedLines; + try { + let delimiter = filePath.toUpperCase().endsWith(".CSV") ? "," : "\t"; + [headerLine, parsedLines] = lazy.CSV.parse(csvString, delimiter); + } catch { + throw new ImportFailedException( + ImportFailedErrorType.FILE_FORMAT_ERROR + ); + } + if (parsedLines && headerLine) { + for (const columnName of headerLine) { + const fieldName = csvColumnToFieldMap.get( + columnName.toLocaleLowerCase() + ); + if (fieldName) { + if (!csvFieldToColumnMap.has(fieldName)) { + csvFieldToColumnMap.set(fieldName, columnName); + } else { + throw new ImportFailedException( + ImportFailedErrorType.CONFLICTING_VALUES_ERROR + ); + } + } + } + } + if (csvFieldToColumnMap.size === 0) { + throw new ImportFailedException( + ImportFailedErrorType.FILE_FORMAT_ERROR + ); + } + if ( + parsedLines[0] && + (!csvFieldToColumnMap.has("origin") || + !csvFieldToColumnMap.has("username") || + !csvFieldToColumnMap.has("password")) + ) { + // The username *value* can be empty but we require a username column to + // ensure that we don't import logins without their usernames due to the + // username column not being recognized. + throw new ImportFailedException( + ImportFailedErrorType.FILE_FORMAT_ERROR + ); + } + + let loginsToImport = parsedLines.map(csvObject => { + return LoginCSVImport._getVanillaLoginFromCSVObject( + csvObject, + csvColumnToFieldMap + ); + }); + + let report = await lazy.LoginHelper.maybeImportLogins(loginsToImport); + + for (const reportRow of report) { + if (reportRow.result === "error_missing_field") { + reportRow.field_name = csvFieldToColumnMap.get(reportRow.field_name); + } + } + + // Record quantity, jank, and duration telemetry. + try { + let histogram = Services.telemetry.getHistogramById( + "PWMGR_IMPORT_LOGINS_FROM_FILE_CATEGORICAL" + ); + this._recordHistogramTelemetry(histogram, report); + let accumulatedDelay = responsivenessMonitor.finish(); + Services.telemetry + .getHistogramById("PWMGR_IMPORT_LOGINS_FROM_FILE_JANK_MS") + .add(accumulatedDelay); + TelemetryStopwatch.finish("PWMGR_IMPORT_LOGINS_FROM_FILE_MS"); + } catch (ex) { + console.error(ex); + } + LoginCSVImport.lastImportReport = report; + return report; + } finally { + if (TelemetryStopwatch.running("PWMGR_IMPORT_LOGINS_FROM_FILE_MS")) { + TelemetryStopwatch.cancel("PWMGR_IMPORT_LOGINS_FROM_FILE_MS"); + } + responsivenessMonitor.abort(); + } + } +} diff --git a/toolkit/components/passwordmgr/LoginExport.sys.mjs b/toolkit/components/passwordmgr/LoginExport.sys.mjs new file mode 100644 index 0000000000..181842c3db --- /dev/null +++ b/toolkit/components/passwordmgr/LoginExport.sys.mjs @@ -0,0 +1,76 @@ +/* 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/. */ + +/** + * Module to support exporting logins to a .csv file. + */ + +export 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. + * @param {nsILoginInfo[]} [logins = null] + * An optional list of logins. + */ + static async exportAsCSV(path, logins = null) { + if (!logins) { + logins = await Services.logins.getAllLoginsAsync(); + } + 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); + for (let login of logins) { + rows.push(LoginExport._buildCSVRow(login, columns)); + } + // https://tools.ietf.org/html/rfc7111 suggests always using CRLF. + const csvAsString = rows.map(e => e.join(",")).join("\r\n"); + await IOUtils.writeUTF8(path, csvAsString, { + tmpPath: path + ".tmp", + }); + } +} diff --git a/toolkit/components/passwordmgr/LoginFormFactory.sys.mjs b/toolkit/components/passwordmgr/LoginFormFactory.sys.mjs new file mode 100644 index 0000000000..bea8f9305d --- /dev/null +++ b/toolkit/components/passwordmgr/LoginFormFactory.sys.mjs @@ -0,0 +1,150 @@ +/* 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/. */ + +/** + * A factory to generate LoginForm objects that represent a set of login fields + * which aren't necessarily marked up with a
element. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LoginHelper.createLogger("LoginFormFactory"); +}); + +export const LoginFormFactory = { + /** + * WeakMap of the root element of a LoginForm to the LoginForm representing its fields. + * + * This is used to be able to lookup an existing LoginForm for a given root element since multiple + * calls to LoginFormFactory.createFrom* won't give the exact same object. When batching fills we don't always + * want to use the most recent list of elements for a LoginForm since we may end up doing multiple + * fills for the same set of elements when a field gets added between arming and running the + * DeferredTask. + * + * @type {WeakMap} + */ + _loginFormsByRootElement: new WeakMap(), + + /** + * Maps all DOM content documents in this content process, including those in + * frames, to a WeakSet of LoginForm.rootElement for the document. + */ + _loginFormRootElementsByDocument: new WeakMap(), + + /** + * Create a LoginForm object from a . + * + * @param {HTMLFormElement} aForm + * @return {LoginForm} + * @throws Error if aForm isn't an HTMLFormElement + */ + createFromForm(aForm) { + let formLike = lazy.FormLikeFactory.createFromForm(aForm); + formLike.action = lazy.LoginHelper.getFormActionOrigin(aForm); + + let rootElementsSet = this.getRootElementsWeakSetForDocument( + formLike.ownerDocument + ); + rootElementsSet.add(formLike.rootElement); + lazy.log.debug( + "adding", + formLike.rootElement, + "to root elements for", + formLike.ownerDocument + ); + + this._loginFormsByRootElement.set(formLike.rootElement, formLike); + return formLike; + }, + + /** + * Create a LoginForm object from a password or username field. + * + * If the field is in a , construct the LoginForm from the form. + * Otherwise, create a LoginForm with a rootElement (wrapper) according to + * heuristics. Currently all not in a are one LoginForm but this + * shouldn't be relied upon as the heuristics may change to detect multiple + * "forms" (e.g. registration and login) on one page with a . + * + * Note that two LoginForms created from the same field won't return the same LoginForm object. + * Use the `rootElement` property on the LoginForm as a key instead. + * + * @param {HTMLInputElement} aField - a password or username field in a document + * @return {LoginForm} + * @throws Error if aField isn't a password or username field in a document + */ + createFromField(aField) { + if ( + !HTMLInputElement.isInstance(aField) || + (!aField.hasBeenTypePassword && + !lazy.LoginHelper.isUsernameFieldType(aField)) || + !aField.ownerDocument + ) { + throw new Error( + "createFromField requires a password or username field in a document" + ); + } + + let form = + aField.form || + lazy.FormLikeFactory.closestFormIgnoringShadowRoots(aField); + if (form) { + return this.createFromForm(form); + } else if (aField.hasAttribute("form")) { + lazy.log.debug( + "createFromField: field has form attribute but no form: ", + aField.getAttribute("form") + ); + } + + let formLike = lazy.FormLikeFactory.createFromField(aField); + formLike.action = lazy.LoginHelper.getLoginOrigin( + aField.ownerDocument.baseURI + ); + lazy.log.debug( + "Created non-form LoginForm for rootElement:", + aField.ownerDocument.documentElement + ); + + let rootElementsSet = this.getRootElementsWeakSetForDocument( + formLike.ownerDocument + ); + rootElementsSet.add(formLike.rootElement); + lazy.log.debug( + "adding", + formLike.rootElement, + "to root elements for", + formLike.ownerDocument + ); + + this._loginFormsByRootElement.set(formLike.rootElement, formLike); + + return formLike; + }, + + getRootElementsWeakSetForDocument(aDocument) { + let rootElementsSet = this._loginFormRootElementsByDocument.get(aDocument); + if (!rootElementsSet) { + rootElementsSet = new WeakSet(); + this._loginFormRootElementsByDocument.set(aDocument, rootElementsSet); + } + return rootElementsSet; + }, + + getForRootElement(aRootElement) { + return this._loginFormsByRootElement.get(aRootElement); + }, + + setForRootElement(aRootElement, aLoginForm) { + return this._loginFormsByRootElement.set(aRootElement, aLoginForm); + }, +}; diff --git a/toolkit/components/passwordmgr/LoginHelper.sys.mjs b/toolkit/components/passwordmgr/LoginHelper.sys.mjs new file mode 100644 index 0000000000..e22f0552ce --- /dev/null +++ b/toolkit/components/passwordmgr/LoginHelper.sys.mjs @@ -0,0 +1,1891 @@ +/* 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/. */ + +/** + * Contains functions shared by different Login Manager components. + * + * This JavaScript module exists in order to share code between the different + * XPCOM components that constitute the Login Manager, including implementations + * of nsILoginManager and nsILoginManagerStorage. + */ + +import { Logic } from "resource://gre/modules/LoginManager.shared.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +export class ParentAutocompleteOption { + icon; + title; + subtitle; + fillMessageName; + fillMessageData; + + constructor(icon, title, subtitle, fillMessageName, fillMessageData) { + this.icon = icon; + this.title = title; + this.subtitle = subtitle; + this.fillMessageName = fillMessageName; + this.fillMessageData = fillMessageData; + } +} + +/** + * A helper class to deal with CSV import rows. + */ +class ImportRowProcessor { + uniqueLoginIdentifiers = new Set(); + originToRows = new Map(); + summary = []; + mandatoryFields = ["origin", "password"]; + + /** + * Validates if the login data contains a GUID that was already found in a previous row in the current import. + * If this is the case, the summary will be updated with an error. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {boolean} True if there is an error, false otherwise. + */ + checkNonUniqueGuidError(loginData) { + if (loginData.guid) { + if (this.uniqueLoginIdentifiers.has(loginData.guid)) { + this.addLoginToSummary({ ...loginData }, "error"); + return true; + } + this.uniqueLoginIdentifiers.add(loginData.guid); + } + return false; + } + + /** + * Validates if the login data contains invalid fields that are mandatory like origin and password. + * If this is the case, the summary will be updated with an error. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {boolean} True if there is an error, false otherwise. + */ + checkMissingMandatoryFieldsError(loginData) { + loginData.origin = LoginHelper.getLoginOrigin(loginData.origin); + for (let mandatoryField of this.mandatoryFields) { + if (!loginData[mandatoryField]) { + const missingFieldRow = this.addLoginToSummary( + { ...loginData }, + "error_missing_field" + ); + missingFieldRow.field_name = mandatoryField; + return true; + } + } + return false; + } + + /** + * Validates if there is already an existing entry with similar values. + * If there are similar values but not identical, a new "modified" entry will be added to the summary. + * If there are identical values, a new "no_change" entry will be added to the summary + * If either of these is the case, it will return true. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise. + */ + async checkExistingEntry(loginData) { + if (loginData.guid) { + // First check for `guid` matches if it's set. + // `guid` matches will allow every kind of update, including reverting + // to older passwords which can be useful if the user wants to recover + // an old password. + let existingLogins = await Services.logins.searchLoginsAsync({ + guid: loginData.guid, + origin: loginData.origin, // Ignored outside of GV. + }); + + if (existingLogins.length) { + lazy.log.debug("maybeImportLogins: Found existing login with GUID."); + // There should only be one `guid` match. + let existingLogin = existingLogins[0].QueryInterface( + Ci.nsILoginMetaInfo + ); + + if ( + loginData.username !== existingLogin.username || + loginData.password !== existingLogin.password || + loginData.httpRealm !== existingLogin.httpRealm || + loginData.formActionOrigin !== existingLogin.formActionOrigin || + `${loginData.timeCreated}` !== `${existingLogin.timeCreated}` || + `${loginData.timePasswordChanged}` !== + `${existingLogin.timePasswordChanged}` + ) { + // Use a property bag rather than an nsILoginInfo so we don't clobber + // properties that the import source doesn't provide. + let propBag = LoginHelper.newPropertyBag(loginData); + this.addLoginToSummary({ ...existingLogin }, "modified", propBag); + return true; + } + this.addLoginToSummary({ ...existingLogin }, "no_change"); + return true; + } + } + return false; + } + + /** + * Validates if there is a conflict with previous rows based on the origin. + * We need to check the logins that we've already decided to add, to see if this is a duplicate. + * If this is the case, we mark this one as "no_change" in the summary and return true. + * @param {object} login + * A login object. + * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise. + */ + checkConflictingOriginWithPreviousRows(login) { + let rowsPerOrigin = this.originToRows.get(login.origin); + if (rowsPerOrigin) { + if ( + rowsPerOrigin.some(r => + login.matches(r.login, false /* ignorePassword */) + ) + ) { + this.addLoginToSummary(login, "no_change"); + return true; + } + for (let row of rowsPerOrigin) { + let newLogin = row.login; + if (login.username == newLogin.username) { + this.addLoginToSummary(login, "no_change"); + return true; + } + } + } + return false; + } + + /** + * Validates if there is a conflict with existing logins based on the origin. + * If this is the case and there are some changes, we mark it as "modified" in the summary. + * If it matches an existing login without any extra modifications, we mark it as "no_change". + * For both cases we return true. + * @param {object} login + * A login object. + * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise. + */ + checkConflictingWithExistingLogins(login) { + // While here we're passing formActionOrigin and httpRealm, they could be empty/null and get + // ignored in that case, leading to multiple logins for the same username. + let existingLogins = Services.logins.findLogins( + login.origin, + login.formActionOrigin, + login.httpRealm + ); + // Check for an existing login that matches *including* the password. + // If such a login exists, we do not need to add a new login. + if ( + existingLogins.some(l => login.matches(l, false /* ignorePassword */)) + ) { + this.addLoginToSummary(login, "no_change"); + return true; + } + // Now check for a login with the same username, where it may be that we have an + // updated password. + let foundMatchingLogin = false; + for (let existingLogin of existingLogins) { + if (login.username == existingLogin.username) { + foundMatchingLogin = true; + existingLogin.QueryInterface(Ci.nsILoginMetaInfo); + if ( + (login.password != existingLogin.password) & + (login.timePasswordChanged > existingLogin.timePasswordChanged) + ) { + // if a login with the same username and different password already exists and it's older + // than the current one, update its password and timestamp. + let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + propBag.setProperty("password", login.password); + propBag.setProperty("timePasswordChanged", login.timePasswordChanged); + this.addLoginToSummary({ ...existingLogin }, "modified", propBag); + return true; + } + } + } + // if the new login is an update or is older than an exiting login, don't add it. + if (foundMatchingLogin) { + this.addLoginToSummary(login, "no_change"); + return true; + } + return false; + } + + /** + * Validates if there are any invalid values using LoginHelper.checkLoginValues. + * If this is the case we mark it as "error" and return true. + * @param {object} login + * A login object. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {boolean} True if there is a validation error we return true, false otherwise. + */ + checkLoginValuesError(login, loginData) { + try { + // Ensure we only send checked logins through, since the validation is optimized + // out from the bulk APIs below us. + LoginHelper.checkLoginValues(login); + } catch (e) { + this.addLoginToSummary({ ...loginData }, "error"); + console.error(e); + return true; + } + return false; + } + + /** + * Creates a new login from loginData. + * @param {object} loginData + * An vanilla object for the login without any methods. + * @returns {object} A login object. + */ + createNewLogin(loginData) { + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + login.init( + loginData.origin, + loginData.formActionOrigin, + loginData.httpRealm, + loginData.username, + loginData.password, + loginData.usernameElement || "", + loginData.passwordElement || "" + ); + + login.QueryInterface(Ci.nsILoginMetaInfo); + login.timeCreated = loginData.timeCreated; + login.timeLastUsed = loginData.timeLastUsed || loginData.timeCreated; + login.timePasswordChanged = + loginData.timePasswordChanged || loginData.timeCreated; + login.timesUsed = loginData.timesUsed || 1; + login.guid = loginData.guid || null; + return login; + } + + /** + * Cleans the action and realm field of the loginData. + * @param {object} loginData + * An vanilla object for the login without any methods. + */ + cleanupActionAndRealmFields(loginData) { + const cleanOrigin = loginData.formActionOrigin + ? LoginHelper.getLoginOrigin(loginData.formActionOrigin, true) + : ""; + loginData.formActionOrigin = + cleanOrigin || (typeof loginData.httpRealm == "string" ? null : ""); + + loginData.httpRealm = + typeof loginData.httpRealm == "string" ? loginData.httpRealm : null; + } + + /** + * Adds a login to the summary. + * @param {object} login + * A login object. + * @param {string} result + * The result type. One of "added", "modified", "error", "error_invalid_origin", "error_invalid_password" or "no_change". + * @param {object} propBag + * An optional parameter with the properties bag. + * @returns {object} The row that was added. + */ + addLoginToSummary(login, result, propBag) { + let rows = this.originToRows.get(login.origin) || []; + if (rows.length === 0) { + this.originToRows.set(login.origin, rows); + } + const newSummaryRow = { result, login, propBag }; + rows.push(newSummaryRow); + this.summary.push(newSummaryRow); + return newSummaryRow; + } + + /** + * Iterates over all then rows where more than two match the same origin. It mutates the internal state of the processor. + * It makes sure that if the `timePasswordChanged` field is present it will be used to decide if it's a "no_change" or "added". + * The entry with the oldest `timePasswordChanged` will be "added", the rest will be "no_change". + */ + markLastTimePasswordChangedAsModified() { + const originUserToRowMap = new Map(); + for (let currentRow of this.summary) { + if ( + currentRow.result === "added" || + currentRow.result === "modified" || + currentRow.result === "no_change" + ) { + const originAndUser = + currentRow.login.origin + currentRow.login.username; + let lastTimeChangedRow = originUserToRowMap.get(originAndUser); + if (lastTimeChangedRow) { + if ( + (currentRow.login.password != lastTimeChangedRow.login.password) & + (currentRow.login.timePasswordChanged > + lastTimeChangedRow.login.timePasswordChanged) + ) { + lastTimeChangedRow.result = "no_change"; + currentRow.result = "added"; + originUserToRowMap.set(originAndUser, currentRow); + } + } else { + originUserToRowMap.set(originAndUser, currentRow); + } + } + } + } + + /** + * Iterates over all then rows where more than two match the same origin. It mutates the internal state of the processor. + * It makes sure that if the `timePasswordChanged` field is present it will be used to decide if it's a "no_change" or "added". + * The entry with the oldest `timePasswordChanged` will be "added", the rest will be "no_change". + * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data. + */ + async processLoginsAndBuildSummary() { + this.markLastTimePasswordChangedAsModified(); + for (let summaryRow of this.summary) { + try { + if (summaryRow.result === "added") { + summaryRow.login = await Services.logins.addLoginAsync( + summaryRow.login + ); + } else if (summaryRow.result === "modified") { + Services.logins.modifyLogin(summaryRow.login, summaryRow.propBag); + } + } catch (e) { + console.error(e); + summaryRow.result = "error"; + } + } + return this.summary; + } +} + +/** + * Contains functions shared by different Login Manager components. + */ +export const LoginHelper = { + debug: null, + enabled: null, + storageEnabled: null, + formlessCaptureEnabled: null, + formRemovalCaptureEnabled: null, + generationAvailable: null, + generationConfidenceThreshold: null, + generationEnabled: null, + improvedPasswordRulesEnabled: null, + improvedPasswordRulesCollection: "password-rules", + includeOtherSubdomainsInLookup: null, + insecureAutofill: null, + privateBrowsingCaptureEnabled: null, + remoteRecipesEnabled: null, + remoteRecipesCollection: "password-recipes", + relatedRealmsEnabled: null, + relatedRealmsCollection: "websites-with-shared-credential-backends", + schemeUpgrades: null, + showAutoCompleteFooter: null, + showAutoCompleteImport: null, + signupDectectionConfidenceThreshold: null, + testOnlyUserHasInteractedWithDocument: null, + userInputRequiredToCapture: null, + captureInputChanges: null, + + init() { + // Watch for pref changes to update cached pref values. + Services.prefs.addObserver("signon.", () => this.updateSignonPrefs()); + this.updateSignonPrefs(); + Services.telemetry.setEventRecordingEnabled("pwmgr", true); + Services.telemetry.setEventRecordingEnabled("form_autocomplete", true); + }, + + updateSignonPrefs() { + this.autofillForms = Services.prefs.getBoolPref("signon.autofillForms"); + this.autofillAutocompleteOff = Services.prefs.getBoolPref( + "signon.autofillForms.autocompleteOff" + ); + this.captureInputChanges = Services.prefs.getBoolPref( + "signon.capture.inputChanges.enabled" + ); + this.debug = Services.prefs.getBoolPref("signon.debug"); + this.enabled = Services.prefs.getBoolPref("signon.rememberSignons"); + this.storageEnabled = Services.prefs.getBoolPref( + "signon.storeSignons", + true + ); + this.formlessCaptureEnabled = Services.prefs.getBoolPref( + "signon.formlessCapture.enabled" + ); + this.formRemovalCaptureEnabled = Services.prefs.getBoolPref( + "signon.formRemovalCapture.enabled" + ); + this.generationAvailable = Services.prefs.getBoolPref( + "signon.generation.available" + ); + this.generationConfidenceThreshold = parseFloat( + Services.prefs.getStringPref("signon.generation.confidenceThreshold") + ); + this.generationEnabled = Services.prefs.getBoolPref( + "signon.generation.enabled" + ); + this.improvedPasswordRulesEnabled = Services.prefs.getBoolPref( + "signon.improvedPasswordRules.enabled" + ); + this.insecureAutofill = Services.prefs.getBoolPref( + "signon.autofillForms.http" + ); + this.includeOtherSubdomainsInLookup = Services.prefs.getBoolPref( + "signon.includeOtherSubdomainsInLookup" + ); + this.passwordEditCaptureEnabled = Services.prefs.getBoolPref( + "signon.passwordEditCapture.enabled" + ); + this.privateBrowsingCaptureEnabled = Services.prefs.getBoolPref( + "signon.privateBrowsingCapture.enabled" + ); + this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades"); + this.showAutoCompleteFooter = Services.prefs.getBoolPref( + "signon.showAutoCompleteFooter" + ); + + this.showAutoCompleteImport = Services.prefs.getStringPref( + "signon.showAutoCompleteImport", + "" + ); + this.signupDetectionConfidenceThreshold = parseFloat( + Services.prefs.getStringPref("signon.signupDetection.confidenceThreshold") + ); + this.signupDetectionEnabled = Services.prefs.getBoolPref( + "signon.signupDetection.enabled" + ); + + this.storeWhenAutocompleteOff = Services.prefs.getBoolPref( + "signon.storeWhenAutocompleteOff" + ); + + this.suggestImportCount = Services.prefs.getIntPref( + "signon.suggestImportCount", + 0 + ); + + if ( + Services.prefs.getBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue", + false + ) + ) { + this.testOnlyUserHasInteractedWithDocument = Services.prefs.getBoolPref( + "signon.testOnlyUserHasInteractedWithDocument", + false + ); + lazy.log.debug( + `Using pref value for testOnlyUserHasInteractedWithDocument ${this.testOnlyUserHasInteractedWithDocument}.` + ); + } else { + this.testOnlyUserHasInteractedWithDocument = null; + } + + this.userInputRequiredToCapture = Services.prefs.getBoolPref( + "signon.userInputRequiredToCapture.enabled" + ); + this.usernameOnlyFormEnabled = Services.prefs.getBoolPref( + "signon.usernameOnlyForm.enabled" + ); + this.usernameOnlyFormLookupThreshold = Services.prefs.getIntPref( + "signon.usernameOnlyForm.lookupThreshold" + ); + this.remoteRecipesEnabled = Services.prefs.getBoolPref( + "signon.recipes.remoteRecipes.enabled" + ); + this.relatedRealmsEnabled = Services.prefs.getBoolPref( + "signon.relatedRealms.enabled" + ); + }, + + createLogger(aLogPrefix) { + let getMaxLogLevel = () => { + return this.debug ? "Debug" : "Warn"; + }; + + // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. + let consoleOptions = { + maxLogLevel: getMaxLogLevel(), + prefix: aLogPrefix, + }; + let logger = console.createInstance(consoleOptions); + + // Watch for pref changes and update this.debug and the maxLogLevel for created loggers + Services.prefs.addObserver("signon.debug", () => { + this.debug = Services.prefs.getBoolPref("signon.debug"); + if (logger) { + logger.maxLogLevel = getMaxLogLevel(); + } + }); + + return logger; + }, + + /** + * Due to the way the signons2.txt file is formatted, we need to make + * sure certain field values or characters do not cause the file to + * be parsed incorrectly. Reject origins that we can't store correctly. + * + * @throws String with English message in case validation failed. + */ + checkOriginValue(aOrigin) { + // Nulls are invalid, as they don't round-trip well. Newlines are also + // invalid for any field stored as plaintext, and an origin made of a + // single dot cannot be stored in the legacy format. + if ( + aOrigin == "." || + aOrigin.includes("\r") || + aOrigin.includes("\n") || + aOrigin.includes("\0") + ) { + throw new Error("Invalid origin"); + } + }, + + /** + * Due to the way the signons2.txt file was formatted, we needed to make + * sure certain field values or characters do not cause the file to + * be parsed incorrectly. These characters can cause problems in other + * formats/languages too so reject logins that may not be stored correctly. + * + * @throws String with English message in case validation failed. + */ + checkLoginValues(aLogin) { + function badCharacterPresent(l, c) { + return ( + (l.formActionOrigin && l.formActionOrigin.includes(c)) || + (l.httpRealm && l.httpRealm.includes(c)) || + l.origin.includes(c) || + l.usernameField.includes(c) || + l.passwordField.includes(c) + ); + } + + // Nulls are invalid, as they don't round-trip well. + // Mostly not a formatting problem, although ".\0" can be quirky. + if (badCharacterPresent(aLogin, "\0")) { + throw new Error("login values can't contain nulls"); + } + + if (!aLogin.password || typeof aLogin.password != "string") { + throw new Error("passwords must be non-empty strings"); + } + + // In theory these nulls should just be rolled up into the encrypted + // values, but nsISecretDecoderRing doesn't use nsStrings, so the + // nulls cause truncation. Check for them here just to avoid + // unexpected round-trip surprises. + if (aLogin.username.includes("\0") || aLogin.password.includes("\0")) { + throw new Error("login values can't contain nulls"); + } + + // Newlines are invalid for any field stored as plaintext. + if ( + badCharacterPresent(aLogin, "\r") || + badCharacterPresent(aLogin, "\n") + ) { + throw new Error("login values can't contain newlines"); + } + + // A line with just a "." can have special meaning. + if (aLogin.usernameField == "." || aLogin.formActionOrigin == ".") { + throw new Error("login values can't be periods"); + } + + // An origin with "\ \(" won't roundtrip. + // eg host="foo (", realm="bar" --> "foo ( (bar)" + // vs host="foo", realm=" (bar" --> "foo ( (bar)" + if (aLogin.origin.includes(" (")) { + throw new Error("bad parens in origin"); + } + }, + + /** + * Returns a new XPCOM property bag with the provided properties. + * + * @param {Object} aProperties + * Each property of this object is copied to the property bag. This + * parameter can be omitted to return an empty property bag. + * + * @return A new property bag, that is an instance of nsIWritablePropertyBag, + * nsIWritablePropertyBag2, nsIPropertyBag, and nsIPropertyBag2. + */ + newPropertyBag(aProperties) { + let propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + if (aProperties) { + for (let [name, value] of Object.entries(aProperties)) { + propertyBag.setProperty(name, value); + } + } + return propertyBag + .QueryInterface(Ci.nsIPropertyBag) + .QueryInterface(Ci.nsIPropertyBag2) + .QueryInterface(Ci.nsIWritablePropertyBag2); + }, + + /** + * Helper to avoid the property bags when calling + * Services.logins.searchLogins from JS. + * @deprecated Use Services.logins.searchLoginsAsync instead. + * + * @param {Object} aSearchOptions - A regular JS object to copy to a property bag before searching + * @return {nsILoginInfo[]} - The result of calling searchLogins. + */ + searchLoginsWithObject(aSearchOptions) { + return Services.logins.searchLogins(this.newPropertyBag(aSearchOptions)); + }, + + /** + * @param {string} aURL + * @returns {string} which is the hostPort of aURL if supported by the scheme + * otherwise, returns the original aURL. + */ + maybeGetHostPortForURL(aURL) { + try { + let uri = Services.io.newURI(aURL); + return uri.hostPort; + } catch (ex) { + // No need to warn for javascript:/data:/about:/chrome:/etc. + } + return aURL; + }, + + /** + * Get the parts of the URL we want for identification. + * Strip out things like the userPass portion and handle javascript:. + */ + getLoginOrigin(uriString, allowJS = false) { + let realm = ""; + try { + const mozProxyRegex = /^moz-proxy:\/\//i; + const isMozProxy = !!uriString.match(mozProxyRegex); + if (isMozProxy) { + // Special handling because uri.displayHostPort throws on moz-proxy:// + return ( + "moz-proxy://" + + Services.io.newURI(uriString.replace(mozProxyRegex, "https://")) + .displayHostPort + ); + } + + let uri = Services.io.newURI(uriString); + + if (allowJS && uri.scheme == "javascript") { + return "javascript:"; + } + + // Build this manually instead of using prePath to avoid including the userPass portion. + realm = uri.scheme + "://" + uri.displayHostPort; + } catch (e) { + // bug 159484 - disallow url types that don't support a hostPort. + // (although we handle "javascript:..." as a special case above.) + if (uriString && !uriString.startsWith("data")) { + lazy.log.warn( + `Couldn't parse specified uri ${uriString} with error ${e.name}` + ); + } + realm = null; + } + + return realm; + }, + + getFormActionOrigin(form) { + let uriString = form.action; + + // A blank or missing action submits to where it came from. + if (uriString == "") { + // ala bug 297761 + uriString = form.baseURI; + } + + return this.getLoginOrigin(uriString, true); + }, + + /** + * @param {String} aLoginOrigin - An origin value from a stored login's + * origin or formActionOrigin properties. + * @param {String} aSearchOrigin - The origin that was are looking to match + * with aLoginOrigin. This would normally come + * from a form or page that we are considering. + * @param {nsILoginFindOptions} aOptions - Options to affect whether the origin + * from the login (aLoginOrigin) is a + * match for the origin we're looking + * for (aSearchOrigin). + */ + isOriginMatching( + aLoginOrigin, + aSearchOrigin, + aOptions = { + schemeUpgrades: false, + acceptWildcardMatch: false, + acceptDifferentSubdomains: false, + acceptRelatedRealms: false, + relatedRealms: [], + } + ) { + if (aLoginOrigin == aSearchOrigin) { + return true; + } + + if (!aOptions) { + return false; + } + + if (aOptions.acceptWildcardMatch && aLoginOrigin == "") { + return true; + } + + // We can only match logins now if either of these flags are true, so + // avoid doing the work of constructing URL objects if neither is true. + if (!aOptions.acceptDifferentSubdomains && !aOptions.schemeUpgrades) { + return false; + } + + try { + let loginURI = Services.io.newURI(aLoginOrigin); + let searchURI = Services.io.newURI(aSearchOrigin); + let schemeMatches = + loginURI.scheme == "http" && searchURI.scheme == "https"; + + if (aOptions.acceptDifferentSubdomains) { + let loginBaseDomain = Services.eTLD.getBaseDomain(loginURI); + let searchBaseDomain = Services.eTLD.getBaseDomain(searchURI); + if ( + loginBaseDomain == searchBaseDomain && + (loginURI.scheme == searchURI.scheme || + (aOptions.schemeUpgrades && schemeMatches)) + ) { + return true; + } + if ( + aOptions.acceptRelatedRealms && + aOptions.relatedRealms.length && + (loginURI.scheme == searchURI.scheme || + (aOptions.schemeUpgrades && schemeMatches)) + ) { + for (let relatedOrigin of aOptions.relatedRealms) { + if (Services.eTLD.hasRootDomain(loginURI.host, relatedOrigin)) { + return true; + } + } + } + } + + if ( + aOptions.schemeUpgrades && + loginURI.host == searchURI.host && + schemeMatches && + loginURI.port == searchURI.port + ) { + return true; + } + } catch (ex) { + // newURI will throw for some values e.g. chrome://FirefoxAccounts + // uri.host and uri.port will throw for some values e.g. javascript: + return false; + } + + return false; + }, + + doLoginsMatch( + aLogin1, + aLogin2, + { ignorePassword = false, ignoreSchemes = false } + ) { + if ( + aLogin1.httpRealm != aLogin2.httpRealm || + aLogin1.username != aLogin2.username + ) { + return false; + } + + if (!ignorePassword && aLogin1.password != aLogin2.password) { + return false; + } + + if (ignoreSchemes) { + let login1HostPort = this.maybeGetHostPortForURL(aLogin1.origin); + let login2HostPort = this.maybeGetHostPortForURL(aLogin2.origin); + if (login1HostPort != login2HostPort) { + return false; + } + + if ( + aLogin1.formActionOrigin != "" && + aLogin2.formActionOrigin != "" && + this.maybeGetHostPortForURL(aLogin1.formActionOrigin) != + this.maybeGetHostPortForURL(aLogin2.formActionOrigin) + ) { + return false; + } + } else { + if (aLogin1.origin != aLogin2.origin) { + return false; + } + + // If either formActionOrigin is blank (but not null), then match. + if ( + aLogin1.formActionOrigin != "" && + aLogin2.formActionOrigin != "" && + aLogin1.formActionOrigin != aLogin2.formActionOrigin + ) { + return false; + } + } + + // The .usernameField and .passwordField values are ignored. + + return true; + }, + + /** + * Creates a new login object that results by modifying the given object with + * the provided data. + * + * @param {nsILoginInfo} aOldStoredLogin + * Existing login object to modify. + * @param {nsILoginInfo|nsIProperyBag} aNewLoginData + * The new login values, either as an nsILoginInfo or nsIProperyBag. + * + * @return {nsILoginInfo} The newly created nsILoginInfo object. + * + * @throws {Error} With English message in case validation failed. + */ + buildModifiedLogin(aOldStoredLogin, aNewLoginData) { + function bagHasProperty(aPropName) { + try { + aNewLoginData.getProperty(aPropName); + return true; + } catch (ex) {} + return false; + } + + aOldStoredLogin.QueryInterface(Ci.nsILoginMetaInfo); + + let newLogin; + if (aNewLoginData instanceof Ci.nsILoginInfo) { + // Clone the existing login to get its nsILoginMetaInfo, then init it + // with the replacement nsILoginInfo data from the new login. + newLogin = aOldStoredLogin.clone(); + newLogin.init( + aNewLoginData.origin, + aNewLoginData.formActionOrigin, + aNewLoginData.httpRealm, + aNewLoginData.username, + aNewLoginData.password, + aNewLoginData.usernameField, + aNewLoginData.passwordField + ); + newLogin.unknownFields = aNewLoginData.unknownFields; + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + + // Automatically update metainfo when password is changed. + if (newLogin.password != aOldStoredLogin.password) { + newLogin.timePasswordChanged = Date.now(); + } + } else if (aNewLoginData instanceof Ci.nsIPropertyBag) { + // Clone the existing login, along with all its properties. + newLogin = aOldStoredLogin.clone(); + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + + // Automatically update metainfo when password is changed. + // (Done before the main property updates, lest the caller be + // explicitly updating both .password and .timePasswordChanged) + if (bagHasProperty("password")) { + let newPassword = aNewLoginData.getProperty("password"); + if (newPassword != aOldStoredLogin.password) { + newLogin.timePasswordChanged = Date.now(); + } + } + + for (let prop of aNewLoginData.enumerator) { + switch (prop.name) { + // nsILoginInfo (fall through) + case "origin": + case "httpRealm": + case "formActionOrigin": + case "username": + case "password": + case "usernameField": + case "passwordField": + case "unknownFields": + // nsILoginMetaInfo (fall through) + case "guid": + case "timeCreated": + case "timeLastUsed": + case "timePasswordChanged": + case "timesUsed": + newLogin[prop.name] = prop.value; + break; + + // Fake property, allows easy incrementing. + case "timesUsedIncrement": + newLogin.timesUsed += prop.value; + break; + + // Fail if caller requests setting an unknown property. + default: + throw new Error("Unexpected propertybag item: " + prop.name); + } + } + } else { + throw new Error("newLoginData needs an expected interface!"); + } + + // Sanity check the login + if (newLogin.origin == null || !newLogin.origin.length) { + throw new Error("Can't add a login with a null or empty origin."); + } + + // For logins w/o a username, set to "", not null. + if (newLogin.username == null) { + throw new Error("Can't add a login with a null username."); + } + + if (newLogin.password == null || !newLogin.password.length) { + throw new Error("Can't add a login with a null or empty password."); + } + + if (newLogin.formActionOrigin || newLogin.formActionOrigin == "") { + // We have a form submit URL. Can't have a HTTP realm. + if (newLogin.httpRealm != null) { + throw new Error( + "Can't add a login with both a httpRealm and formActionOrigin." + ); + } + } else if (newLogin.httpRealm) { + // We have a HTTP realm. Can't have a form submit URL. + if (newLogin.formActionOrigin != null) { + throw new Error( + "Can't add a login with both a httpRealm and formActionOrigin." + ); + } + } else { + // Need one or the other! + throw new Error( + "Can't add a login without a httpRealm or formActionOrigin." + ); + } + + // Throws if there are bogus values. + this.checkLoginValues(newLogin); + + return newLogin; + }, + + /** + * Remove http: logins when there is an https: login with the same username and hostPort. + * Sort order is preserved. + * + * @param {nsILoginInfo[]} logins + * A list of logins we want to process for shadowing. + * @returns {nsILoginInfo[]} A subset of of the passed logins. + */ + shadowHTTPLogins(logins) { + /** + * Map a (hostPort, username) to a boolean indicating whether `logins` + * contains an https: login for that combo. + */ + let hasHTTPSByHostPortUsername = new Map(); + for (let login of logins) { + let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]); + let hasHTTPSlogin = hasHTTPSByHostPortUsername.get(key) || false; + let loginURI = Services.io.newURI(login.origin); + hasHTTPSByHostPortUsername.set( + key, + loginURI.scheme == "https" || hasHTTPSlogin + ); + } + + return logins.filter(login => { + let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]); + let loginURI = Services.io.newURI(login.origin); + if (loginURI.scheme == "http" && hasHTTPSByHostPortUsername.get(key)) { + // If this is an http: login and we have an https: login for the + // (hostPort, username) combo then remove it. + return false; + } + return true; + }); + }, + + /** + * Generate a unique key string from a login. + * @param {nsILoginInfo} login + * @param {string[]} uniqueKeys containing nsILoginInfo attribute names or "hostPort" + * @returns {string} to use as a key in a Map + */ + getUniqueKeyForLogin(login, uniqueKeys) { + const KEY_DELIMITER = ":"; + return uniqueKeys.reduce((prev, key) => { + let val = null; + if (key == "hostPort") { + val = Services.io.newURI(login.origin).hostPort; + } else { + val = login[key]; + } + + return prev + KEY_DELIMITER + val; + }, ""); + }, + + /** + * Removes duplicates from a list of logins while preserving the sort order. + * + * @param {nsILoginInfo[]} logins + * A list of logins we want to deduplicate. + * @param {string[]} [uniqueKeys = ["username", "password"]] + * A list of login attributes to use as unique keys for the deduplication. + * @param {string[]} [resolveBy = ["timeLastUsed"]] + * Ordered array of keyword strings used to decide which of the + * duplicates should be used. "scheme" would prefer the login that has + * a scheme matching `preferredOrigin`'s if there are two logins with + * the same `uniqueKeys`. The default preference to distinguish two + * logins is `timeLastUsed`. If there is no preference between two + * logins, the first one found wins. + * @param {string} [preferredOrigin = undefined] + * String representing the origin to use for preferring one login over + * another when they are dupes. This is used with "scheme" for + * `resolveBy` so the scheme from this origin will be preferred. + * @param {string} [preferredFormActionOrigin = undefined] + * String representing the action origin to use for preferring one login over + * another when they are dupes. This is used with "actionOrigin" for + * `resolveBy` so the scheme from this action origin will be preferred. + * + * @returns {nsILoginInfo[]} list of unique logins. + */ + dedupeLogins( + logins, + uniqueKeys = ["username", "password"], + resolveBy = ["timeLastUsed"], + preferredOrigin = undefined, + preferredFormActionOrigin = undefined + ) { + if (!preferredOrigin) { + if (resolveBy.includes("scheme")) { + throw new Error( + "dedupeLogins: `preferredOrigin` is required in order to " + + "prefer schemes which match it." + ); + } + if (resolveBy.includes("subdomain")) { + throw new Error( + "dedupeLogins: `preferredOrigin` is required in order to " + + "prefer subdomains which match it." + ); + } + } + + let preferredOriginScheme; + if (preferredOrigin) { + try { + preferredOriginScheme = Services.io.newURI(preferredOrigin).scheme; + } catch (ex) { + // Handle strings that aren't valid URIs e.g. chrome://FirefoxAccounts + } + } + + if (!preferredOriginScheme && resolveBy.includes("scheme")) { + lazy.log.warn( + "Deduping with a scheme preference but couldn't get the preferred origin scheme." + ); + } + + // We use a Map to easily lookup logins by their unique keys. + let loginsByKeys = new Map(); + + /** + * @return {bool} whether `login` is preferred over its duplicate (considering `uniqueKeys`) + * `existingLogin`. + * + * `resolveBy` is a sorted array so we can return true the first time `login` is preferred + * over the existingLogin. + */ + function isLoginPreferred(existingLogin, login) { + if (!resolveBy || !resolveBy.length) { + // If there is no preference, prefer the existing login. + return false; + } + + for (let preference of resolveBy) { + switch (preference) { + case "actionOrigin": { + if (!preferredFormActionOrigin) { + break; + } + if ( + LoginHelper.isOriginMatching( + existingLogin.formActionOrigin, + preferredFormActionOrigin, + { schemeUpgrades: LoginHelper.schemeUpgrades } + ) && + !LoginHelper.isOriginMatching( + login.formActionOrigin, + preferredFormActionOrigin, + { schemeUpgrades: LoginHelper.schemeUpgrades } + ) + ) { + return false; + } + break; + } + case "scheme": { + if (!preferredOriginScheme) { + break; + } + + try { + // Only `origin` is currently considered + let existingLoginURI = Services.io.newURI(existingLogin.origin); + let loginURI = Services.io.newURI(login.origin); + // If the schemes of the two logins are the same or neither match the + // preferredOriginScheme then we have no preference and look at the next resolveBy. + if ( + loginURI.scheme == existingLoginURI.scheme || + (loginURI.scheme != preferredOriginScheme && + existingLoginURI.scheme != preferredOriginScheme) + ) { + break; + } + + return loginURI.scheme == preferredOriginScheme; + } catch (e) { + // Some URLs aren't valid nsIURI (e.g. chrome://FirefoxAccounts) + lazy.log.debug( + "dedupeLogins/shouldReplaceExisting: Error comparing schemes:", + existingLogin.origin, + login.origin, + "preferredOrigin:", + preferredOrigin, + e.name + ); + } + break; + } + case "subdomain": { + // Replace the existing login only if the new login is an exact match on the host. + let existingLoginURI = Services.io.newURI(existingLogin.origin); + let newLoginURI = Services.io.newURI(login.origin); + let preferredOriginURI = Services.io.newURI(preferredOrigin); + if ( + existingLoginURI.hostPort != preferredOriginURI.hostPort && + newLoginURI.hostPort == preferredOriginURI.hostPort + ) { + return true; + } + if ( + existingLoginURI.host != preferredOriginURI.host && + newLoginURI.host == preferredOriginURI.host + ) { + return true; + } + // if the existing login host *is* a match and the new one isn't + // we explicitly want to keep the existing one + if ( + existingLoginURI.host == preferredOriginURI.host && + newLoginURI.host != preferredOriginURI.host + ) { + return false; + } + break; + } + case "timeLastUsed": + case "timePasswordChanged": { + // If we find a more recent login for the same key, replace the existing one. + let loginDate = login.QueryInterface(Ci.nsILoginMetaInfo)[ + preference + ]; + let storedLoginDate = existingLogin.QueryInterface( + Ci.nsILoginMetaInfo + )[preference]; + if (loginDate == storedLoginDate) { + break; + } + + return loginDate > storedLoginDate; + } + default: { + throw new Error( + "dedupeLogins: Invalid resolveBy preference: " + preference + ); + } + } + } + + return false; + } + + for (let login of logins) { + let key = this.getUniqueKeyForLogin(login, uniqueKeys); + + if (loginsByKeys.has(key)) { + if (!isLoginPreferred(loginsByKeys.get(key), login)) { + // If there is no preference for the new login, use the existing one. + continue; + } + } + loginsByKeys.set(key, login); + } + + // Return the map values in the form of an array. + return [...loginsByKeys.values()]; + }, + + /** + * Open the password manager window. + * + * @param {Window} window + * the window from where we want to open the dialog + * + * @param {object?} args + * params for opening the password manager + * @param {string} [args.filterString=""] + * the domain (not origin) to pass to the login manager dialog + * to pre-filter the results + * @param {string} args.entryPoint + * The name of the entry point, used for telemetry + */ + openPasswordManager( + window, + { filterString = "", entryPoint = "", loginGuid = null } = {} + ) { + // Get currently active tab's origin + const openedFrom = + window.gBrowser?.selectedTab.linkedBrowser.currentURI.spec; + + // If no loginGuid is set, get sanitized origin, this will return null for about:* uris + const preselectedLogin = loginGuid ?? this.getLoginOrigin(openedFrom); + + const params = new URLSearchParams({ + ...(filterString && { filter: filterString }), + ...(entryPoint && { entryPoint }), + }); + + const paramsPart = params.toString() ? `?${params}` : ""; + const fragmentsPart = preselectedLogin + ? `#${window.encodeURIComponent(preselectedLogin)}` + : ""; + const destination = `about:logins${paramsPart}${fragmentsPart}`; + + // We assume that managementURL has a '?' already + window.openTrustedLinkIn(destination, "tab"); + }, + + /** + * Checks if a field type is password compatible. + * + * @param {Element} element + * the field we want to check. + * @param {Object} options + * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected + * of the element. + * + * @returns {Boolean} true if the field can + * be treated as a password input + */ + isPasswordFieldType(element, { ignoreConnect = false } = {}) { + if (!HTMLInputElement.isInstance(element)) { + return false; + } + + if (!element.isConnected && !ignoreConnect) { + // If the element isn't connected then it isn't visible to the user so + // shouldn't be considered. It must have been connected in the past. + return false; + } + + if (!element.hasBeenTypePassword) { + return false; + } + + // Ensure the element is of a type that could have autocomplete. + // These include the types with user-editable values. If not, even if it used to be + // a type=password, we can't treat it as a password input now + let acInfo = element.getAutocompleteInfo(); + if (!acInfo) { + return false; + } + + return true; + }, + + /** + * Checks if a field type is username compatible. + * + * @param {Element} element + * the field we want to check. + * @param {Object} options + * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected + * of the element. + * + * @returns {Boolean} true if the field type is one + * of the username types. + */ + isUsernameFieldType(element, { ignoreConnect = false } = {}) { + if (!HTMLInputElement.isInstance(element)) { + return false; + } + + if (!element.isConnected && !ignoreConnect) { + // If the element isn't connected then it isn't visible to the user so + // shouldn't be considered. It must have been connected in the past. + return false; + } + + if (element.hasBeenTypePassword) { + return false; + } + + if (!Logic.inputTypeIsCompatibleWithUsername(element)) { + return false; + } + + let acFieldName = element.getAutocompleteInfo().fieldName; + if ( + !( + acFieldName == "username" || + // Bug 1540154: Some sites use tel/email on their username fields. + acFieldName == "email" || + acFieldName == "tel" || + acFieldName == "tel-national" || + acFieldName == "off" || + acFieldName == "on" || + acFieldName == "" + ) + ) { + return false; + } + return true; + }, + + /** + * Infer whether a form is a sign-in form by searching keywords + * in its attributes + * + * @param {Element} element + * the form we want to check. + * + * @returns {boolean} True if any of the rules matches + */ + isInferredLoginForm(formElement) { + // This is copied from 'loginFormAttrRegex' in NewPasswordModel.jsm + const loginExpr = + /login|log in|log on|log-on|sign in|sigin|sign\/in|sign-in|sign on|sign-on/i; + + if (Logic.elementAttrsMatchRegex(formElement, loginExpr)) { + return true; + } + + return false; + }, + + /** + * Infer whether an input field is a username field by searching + * 'username' keyword in its attributes + * + * @param {Element} element + * the field we want to check. + * + * @returns {boolean} True if any of the rules matches + */ + isInferredUsernameField(element) { + const expr = /username/i; + + let ac = element.getAutocompleteInfo()?.fieldName; + if (ac && ac == "username") { + return true; + } + + if ( + Logic.elementAttrsMatchRegex(element, expr) || + Logic.hasLabelMatchingRegex(element, expr) + ) { + return true; + } + + return false; + }, + + /** + * Search for keywords that indicates the input field is not likely a + * field of a username login form. + * + * @param {Element} element + * the input field we want to check. + * + * @returns {boolean} True if any of the rules matches + */ + isInferredNonUsernameField(element) { + const expr = /search|code/i; + + if ( + Logic.elementAttrsMatchRegex(element, expr) || + Logic.hasLabelMatchingRegex(element, expr) + ) { + return true; + } + + return false; + }, + + /** + * Infer whether an input field is an email field by searching + * 'email' keyword in its attributes. + * + * @param {Element} element + * the field we want to check. + * + * @returns {boolean} True if any of the rules matches + */ + isInferredEmailField(element) { + const expr = /email|邮箱/i; + + if (element.type == "email") { + return true; + } + + let ac = element.getAutocompleteInfo()?.fieldName; + if (ac && ac == "email") { + return true; + } + + if ( + Logic.elementAttrsMatchRegex(element, expr) || + Logic.hasLabelMatchingRegex(element, expr) + ) { + return true; + } + + return false; + }, + + /** + * For each login, add the login to the password manager if a similar one + * doesn't already exist. Merge it otherwise with the similar existing ones. + * + * @param {Object[]} loginDatas - For each login, the data that needs to be added. + * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data. + */ + async maybeImportLogins(loginDatas) { + this.importing = true; + try { + const processor = new ImportRowProcessor(); + for (let rawLoginData of loginDatas) { + // Do some sanitization on a clone of the loginData. + let loginData = ChromeUtils.shallowClone(rawLoginData); + if (processor.checkNonUniqueGuidError(loginData)) { + continue; + } + if (processor.checkMissingMandatoryFieldsError(loginData)) { + continue; + } + processor.cleanupActionAndRealmFields(loginData); + if (await processor.checkExistingEntry(loginData)) { + continue; + } + let login = processor.createNewLogin(loginData); + if (processor.checkLoginValuesError(login, loginData)) { + continue; + } + if (processor.checkConflictingOriginWithPreviousRows(login)) { + continue; + } + if (processor.checkConflictingWithExistingLogins(login)) { + continue; + } + processor.addLoginToSummary(login, "added"); + } + return await processor.processLoginsAndBuildSummary(); + } finally { + this.importing = false; + + Services.obs.notifyObservers(null, "passwordmgr-reload-all"); + } + }, + + /** + * Convert an array of nsILoginInfo to vanilla JS objects suitable for + * sending over IPC. Avoid using this in other cases. + * + * NB: All members of nsILoginInfo (not nsILoginMetaInfo) are strings. + */ + loginsToVanillaObjects(logins) { + return logins.map(this.loginToVanillaObject); + }, + + /** + * Same as above, but for a single login. + */ + loginToVanillaObject(login) { + let obj = {}; + for (let i in login.QueryInterface(Ci.nsILoginMetaInfo)) { + if (typeof login[i] !== "function") { + obj[i] = login[i]; + } + } + return obj; + }, + + /** + * Convert an object received from IPC into an nsILoginInfo (with guid). + */ + vanillaObjectToLogin(login) { + let formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + formLogin.init( + login.origin, + login.formActionOrigin, + login.httpRealm, + login.username, + login.password, + login.usernameField, + login.passwordField + ); + + formLogin.QueryInterface(Ci.nsILoginMetaInfo); + for (let prop of [ + "guid", + "timeCreated", + "timeLastUsed", + "timePasswordChanged", + "timesUsed", + ]) { + formLogin[prop] = login[prop]; + } + return formLogin; + }, + + /** + * As above, but for an array of objects. + */ + vanillaObjectsToLogins(vanillaObjects) { + const logins = []; + for (const vanillaObject of vanillaObjects) { + logins.push(this.vanillaObjectToLogin(vanillaObject)); + } + return logins; + }, + + /** + * Returns true if the user has a primary password set and false otherwise. + */ + isPrimaryPasswordSet() { + let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService( + Ci.nsIPK11TokenDB + ); + let token = tokenDB.getInternalKeyToken(); + return token.hasPassword; + }, + + /** + * Shows the Primary Password prompt if enabled, or the + * OS auth dialog otherwise. + * @param {Element} browser + * The that the prompt should be shown on + * @param OSReauthEnabled Boolean indicating if OS reauth should be tried + * @param expirationTime Optional timestamp indicating next required re-authentication + * @param messageText Formatted and localized string to be displayed when the OS auth dialog is used. + * @param captionText Formatted and localized string to be displayed when the OS auth dialog is used. + */ + async requestReauth( + browser, + OSReauthEnabled, + expirationTime, + messageText, + captionText + ) { + let isAuthorized = false; + let telemetryEvent; + + // This does no harm if primary password isn't set. + let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance( + Ci.nsIPK11TokenDB + ); + let token = tokendb.getInternalKeyToken(); + + // Do we have a recent authorization? + if (expirationTime && Date.now() < expirationTime) { + isAuthorized = true; + telemetryEvent = { + object: token.hasPassword ? "master_password" : "os_auth", + method: "reauthenticate", + value: "success_no_prompt", + }; + return { + isAuthorized, + telemetryEvent, + }; + } + + // Default to true if there is no primary password and OS reauth is not available + if (!token.hasPassword && !OSReauthEnabled) { + isAuthorized = true; + telemetryEvent = { + object: "os_auth", + method: "reauthenticate", + value: "success_disabled", + }; + return { + isAuthorized, + telemetryEvent, + }; + } + // Use the OS auth dialog if there is no primary password + if (!token.hasPassword && OSReauthEnabled) { + let result = await lazy.OSKeyStore.ensureLoggedIn( + messageText, + captionText, + browser.ownerGlobal, + false + ); + isAuthorized = result.authenticated; + telemetryEvent = { + object: "os_auth", + method: "reauthenticate", + value: result.auth_details, + extra: result.auth_details_extra, + }; + return { + isAuthorized, + telemetryEvent, + }; + } + // We'll attempt to re-auth via Primary Password, force a log-out + token.checkPassword(""); + + // If a primary password prompt is already open, just exit early and return false. + // The user can re-trigger it after responding to the already open dialog. + if (Services.logins.uiBusy) { + isAuthorized = false; + return { + isAuthorized, + telemetryEvent, + }; + } + + // So there's a primary password. But since checkPassword didn't succeed, we're logged out (per nsIPK11Token.idl). + try { + // Relogin and ask for the primary password. + token.login(true); // 'true' means always prompt for token password. User will be prompted until + // clicking 'Cancel' or entering the correct password. + } catch (e) { + // An exception will be thrown if the user cancels the login prompt dialog. + // User is also logged out of Software Security Device. + } + isAuthorized = token.isLoggedIn(); + telemetryEvent = { + object: "master_password", + method: "reauthenticate", + value: isAuthorized ? "success" : "fail", + }; + return { + isAuthorized, + telemetryEvent, + }; + }, + + /** + * Send a notification when stored data is changed. + */ + notifyStorageChanged(changeType, data) { + if (this.importing) { + return; + } + + let dataObject = data; + // Can't pass a raw JS string or array though notifyObservers(). :-( + if (Array.isArray(data)) { + dataObject = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + for (let i = 0; i < data.length; i++) { + dataObject.appendElement(data[i]); + } + } else if (typeof data == "string") { + dataObject = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + dataObject.data = data; + } + Services.obs.notifyObservers( + dataObject, + "passwordmgr-storage-changed", + changeType + ); + }, + + isUserFacingLogin(login) { + return login.origin != "chrome://FirefoxAccounts"; // FXA_PWDMGR_HOST + }, + + async getAllUserFacingLogins() { + try { + let logins = await Services.logins.getAllLoginsAsync(); + return logins.filter(this.isUserFacingLogin); + } catch (e) { + if (e.result == Cr.NS_ERROR_ABORT) { + // If the user cancels the MP prompt then return no logins. + return []; + } + throw e; + } + }, + + createLoginAlreadyExistsError(guid) { + // The GUID is stored in an nsISupportsString here because we cannot pass + // raw JS objects within Components.Exception due to bug 743121. + let guidSupportsString = Cc[ + "@mozilla.org/supports-string;1" + ].createInstance(Ci.nsISupportsString); + guidSupportsString.data = guid; + return Components.Exception("This login already exists.", { + data: guidSupportsString, + }); + }, + + /** + * Determine the that a prompt should be shown on. + * + * Some sites pop up a temporary login window, which disappears + * upon submission of credentials. We want to put the notification + * prompt in the opener window if this seems to be happening. + * + * @param {Element} browser + * The that a prompt was triggered for + * @returns {Element} The that the prompt should be shown on, + * which could be in a different window. + */ + getBrowserForPrompt(browser) { + let chromeWindow = browser.ownerGlobal; + let openerBrowsingContext = browser.browsingContext.opener; + let openerBrowser = openerBrowsingContext + ? openerBrowsingContext.top.embedderElement + : null; + if (openerBrowser) { + let chromeDoc = chromeWindow.document.documentElement; + + // Check to see if the current window was opened with chrome + // disabled, and if so use the opener window. But if the window + // has been used to visit other pages (ie, has a history), + // assume it'll stick around and *don't* use the opener. + if (chromeDoc.getAttribute("chromehidden") && !browser.canGoBack) { + lazy.log.debug("Using opener window for prompt."); + return openerBrowser; + } + } + + return browser; + }, +}; + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let processName = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT + ? "Main" + : "Content"; + return LoginHelper.createLogger(`LoginHelper(${processName})`); +}); + +LoginHelper.init(); + +export class OptInFeature { + implementation; + #offered; + #enabled; + #disabled; + #pref; + + static PREF_AVAILABLE_VALUE = "available"; + static PREF_OFFERED_VALUE = "offered"; + static PREF_ENABLED_VALUE = "enabled"; + static PREF_DISABLED_VALUE = "disabled"; + + constructor(offered, enabled, disabled, pref) { + this.#pref = pref; + this.#offered = offered; + this.#enabled = enabled; + this.#disabled = disabled; + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "implementationPref", + pref, + undefined, + (_preference, _prevValue, _newValue) => this.#updateImplementation() + ); + + this.#updateImplementation(); + } + + get #currentPrefValue() { + // Read pref directly instead of relying on this.implementationPref because + // there is an implementationPref value update lag that affects tests. + return Services.prefs.getStringPref(this.#pref, undefined); + } + + get isAvailable() { + return [ + OptInFeature.PREF_AVAILABLE_VALUE, + OptInFeature.PREF_OFFERED_VALUE, + OptInFeature.PREF_ENABLED_VALUE, + OptInFeature.PREF_DISABLED_VALUE, + ].includes(this.#currentPrefValue); + } + + get isEnabled() { + return this.#currentPrefValue == OptInFeature.PREF_ENABLED_VALUE; + } + + get isDisabled() { + return this.#currentPrefValue == OptInFeature.PREF_DISABLED_VALUE; + } + + markAsAvailable() { + this.#markAs(OptInFeature.PREF_AVAILABLE_VALUE); + } + + markAsOffered() { + this.#markAs(OptInFeature.PREF_OFFERED_VALUE); + } + + markAsEnabled() { + this.#markAs(OptInFeature.PREF_ENABLED_VALUE); + } + + markAsDisabled() { + this.#markAs(OptInFeature.PREF_DISABLED_VALUE); + } + + #markAs(value) { + Services.prefs.setStringPref(this.#pref, value); + } + + #updateImplementation() { + switch (this.implementationPref) { + case OptInFeature.PREF_ENABLED_VALUE: + this.implementation = new this.#enabled(); + break; + case OptInFeature.PREF_AVAILABLE_VALUE: + case OptInFeature.PREF_OFFERED_VALUE: + this.implementation = new this.#offered(); + break; + case OptInFeature.PREF_DISABLED_VALUE: + default: + this.implementation = new this.#disabled(); + break; + } + } +} diff --git a/toolkit/components/passwordmgr/LoginInfo.sys.mjs b/toolkit/components/passwordmgr/LoginInfo.sys.mjs new file mode 100644 index 0000000000..8db67eb6f2 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginInfo.sys.mjs @@ -0,0 +1,139 @@ +/* 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +export function nsLoginInfo() {} + +nsLoginInfo.prototype = { + classID: Components.ID("{0f2f347c-1e4f-40cc-8efd-792dea70a85e}"), + QueryInterface: ChromeUtils.generateQI(["nsILoginInfo", "nsILoginMetaInfo"]), + + // + // nsILoginInfo interfaces... + // + + origin: null, + formActionOrigin: null, + httpRealm: null, + username: null, + password: null, + usernameField: null, + passwordField: null, + unknownFields: null, + + get displayOrigin() { + let displayOrigin = this.origin; + try { + let uri = Services.io.newURI(this.origin); + // Fallback to handle file: URIs + displayOrigin = uri.displayHostPort || this.origin; + } catch (ex) { + // Fallback to this.origin set above in case a URI can't be contructed e.g. + // file:// + } + + if (this.httpRealm === null) { + return displayOrigin; + } + + return `${displayOrigin} (${this.httpRealm})`; + }, + + /** + * @deprecated Use `origin` instead. + */ + get hostname() { + return this.origin; + }, + + /** + * @deprecated Use `formActionOrigin` instead. + */ + get formSubmitURL() { + return this.formActionOrigin; + }, + + init( + aOrigin, + aFormActionOrigin, + aHttpRealm, + aUsername, + aPassword, + aUsernameField = "", + aPasswordField = "" + ) { + this.origin = aOrigin; + this.formActionOrigin = aFormActionOrigin; + this.httpRealm = aHttpRealm; + this.username = aUsername; + this.password = aPassword; + this.usernameField = aUsernameField || ""; + this.passwordField = aPasswordField || ""; + }, + + matches(aLogin, ignorePassword) { + return lazy.LoginHelper.doLoginsMatch(this, aLogin, { + ignorePassword, + }); + }, + + equals(aLogin) { + if ( + this.origin != aLogin.origin || + this.formActionOrigin != aLogin.formActionOrigin || + this.httpRealm != aLogin.httpRealm || + this.username != aLogin.username || + this.password != aLogin.password || + this.usernameField != aLogin.usernameField || + this.passwordField != aLogin.passwordField + ) { + return false; + } + + return true; + }, + + clone() { + let clone = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + clone.init( + this.origin, + this.formActionOrigin, + this.httpRealm, + this.username, + this.password, + this.usernameField, + this.passwordField + ); + + // Copy nsILoginMetaInfo props + clone.QueryInterface(Ci.nsILoginMetaInfo); + clone.guid = this.guid; + clone.timeCreated = this.timeCreated; + clone.timeLastUsed = this.timeLastUsed; + clone.timePasswordChanged = this.timePasswordChanged; + clone.timesUsed = this.timesUsed; + + // Unknown fields from other clients + clone.unknownFields = this.unknownFields; + + return clone; + }, + + // + // nsILoginMetaInfo interfaces... + // + + guid: null, + timeCreated: null, + timeLastUsed: null, + timePasswordChanged: null, + timesUsed: null, +}; // end of nsLoginInfo implementation diff --git a/toolkit/components/passwordmgr/LoginManager.shared.mjs b/toolkit/components/passwordmgr/LoginManager.shared.mjs new file mode 100644 index 0000000000..bacbfc8696 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManager.shared.mjs @@ -0,0 +1,48 @@ +/* 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/. */ + +/** + * Code that we can share across Firefox Desktop, Firefox Android and Firefox iOS. + */ + +class Logic { + static inputTypeIsCompatibleWithUsername(input) { + const fieldType = input.getAttribute("type")?.toLowerCase() || input.type; + return ( + fieldType == "text" || + fieldType == "email" || + fieldType == "url" || + fieldType == "tel" || + fieldType == "number" || + fieldType == "search" + ); + } + + /** + * Test whether the element has the keyword in its attributes. + * The tested attributes include id, name, className, and placeholder. + */ + static elementAttrsMatchRegex(element, regex) { + if ( + regex.test(element.id) || + regex.test(element.name) || + regex.test(element.className) + ) { + return true; + } + + const placeholder = element.getAttribute("placeholder"); + return placeholder && regex.test(placeholder); + } + + /** + * Test whether associated labels of the element have the keyword. + * This is a simplified rule of hasLabelMatchingRegex in NewPasswordModel.jsm + */ + static hasLabelMatchingRegex(element, regex) { + return regex.test(element.labels?.[0]?.textContent); + } +} + +export { Logic }; diff --git a/toolkit/components/passwordmgr/LoginManager.sys.mjs b/toolkit/components/passwordmgr/LoginManager.sys.mjs new file mode 100644 index 0000000000..da46244563 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManager.sys.mjs @@ -0,0 +1,707 @@ +/* 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 PERMISSION_SAVE_LOGINS = "login-saving"; +const MAX_DATE_MS = 8640000000000000; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let logger = lazy.LoginHelper.createLogger("LoginManager"); + return logger; +}); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) { + throw new Error("LoginManager.jsm should only run in the parent process"); +} + +export function LoginManager() { + this.init(); +} + +LoginManager.prototype = { + classID: Components.ID("{cb9e0de8-3598-4ed7-857b-827f011ad5d8}"), + QueryInterface: ChromeUtils.generateQI([ + "nsILoginManager", + "nsISupportsWeakReference", + "nsIInterfaceRequestor", + ]), + getInterface(aIID) { + if (aIID.equals(Ci.mozIStorageConnection) && this._storage) { + let ir = this._storage.QueryInterface(Ci.nsIInterfaceRequestor); + return ir.getInterface(aIID); + } + + if (aIID.equals(Ci.nsIVariant)) { + // Allows unwrapping the JavaScript object for regression tests. + return this; + } + + throw new Components.Exception( + "Interface not available", + Cr.NS_ERROR_NO_INTERFACE + ); + }, + + /* ---------- private members ---------- */ + + _storage: null, // Storage component which contains the saved logins + + /** + * Initialize the Login Manager. Automatically called when service + * is created. + * + * Note: Service created in BrowserGlue#_scheduleStartupIdleTasks() + */ + init() { + // Cache references to current |this| in utility objects + this._observer._pwmgr = this; + + Services.obs.addObserver(this._observer, "xpcom-shutdown"); + Services.obs.addObserver(this._observer, "passwordmgr-storage-replace"); + + // Initialize storage so that asynchronous data loading can start. + this._initStorage(); + + Services.obs.addObserver(this._observer, "gather-telemetry"); + }, + + _initStorage() { + this._storage = Cc[ + "@mozilla.org/login-manager/storage/default;1" + ].createInstance(Ci.nsILoginManagerStorage); + this.initializationPromise = this._storage.initialize(); + this.initializationPromise.then(() => { + lazy.log.debug( + "initializationPromise is resolved, updating isPrimaryPasswordSet in sharedData" + ); + Services.ppmm.sharedData.set( + "isPrimaryPasswordSet", + lazy.LoginHelper.isPrimaryPasswordSet() + ); + }); + }, + + /* ---------- Utility objects ---------- */ + + /** + * Internal utility object, implements the nsIObserver interface. + * Used to receive notification for: form submission, preference changes. + */ + _observer: { + _pwmgr: null, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + // nsIObserver + observe(subject, topic, data) { + if (topic == "xpcom-shutdown") { + delete this._pwmgr._storage; + this._pwmgr = null; + } else if (topic == "passwordmgr-storage-replace") { + (async () => { + await this._pwmgr._storage.terminate(); + this._pwmgr._initStorage(); + await this._pwmgr.initializationPromise; + Services.obs.notifyObservers( + null, + "passwordmgr-storage-replace-complete" + ); + })(); + } else if (topic == "gather-telemetry") { + // When testing, the "data" parameter is a string containing the + // reference time in milliseconds for time-based statistics. + this._pwmgr._gatherTelemetry( + data ? parseInt(data) : new Date().getTime() + ); + } else { + lazy.log.debug(`Unexpected notification: ${topic}.`); + } + }, + }, + + /** + * Collects statistics about the current logins and settings. The telemetry + * histograms used here are not accumulated, but are reset each time this + * function is called, since it can be called multiple times in a session. + * + * This function might also not be called at all in the current session. + * + * @param referenceTimeMs + * Current time used to calculate time-based statistics, expressed as + * the number of milliseconds since January 1, 1970, 00:00:00 UTC. + * This is set to a fake value during unit testing. + */ + async _gatherTelemetry(referenceTimeMs) { + function clearAndGetHistogram(histogramId) { + let histogram = Services.telemetry.getHistogramById(histogramId); + histogram.clear(); + return histogram; + } + + clearAndGetHistogram("PWMGR_BLOCKLIST_NUM_SITES").add( + this.getAllDisabledHosts().length + ); + clearAndGetHistogram("PWMGR_NUM_SAVED_PASSWORDS").add( + this.countLogins("", "", "") + ); + clearAndGetHistogram("PWMGR_NUM_HTTPAUTH_PASSWORDS").add( + this.countLogins("", null, "") + ); + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + "PWMGR_BLOCKLIST_NUM_SITES" + ); + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + "PWMGR_NUM_SAVED_PASSWORDS" + ); + + // This is a boolean histogram, and not a flag, because we don't want to + // record any value if _gatherTelemetry is not called. + clearAndGetHistogram("PWMGR_SAVING_ENABLED").add(lazy.LoginHelper.enabled); + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + "PWMGR_SAVING_ENABLED" + ); + + // Don't try to get logins if MP is enabled, since we don't want to show a MP prompt. + if (!this.isLoggedIn) { + return; + } + + let logins = await this.getAllLoginsAsync(); + + let usernamePresentHistogram = clearAndGetHistogram( + "PWMGR_USERNAME_PRESENT" + ); + let loginLastUsedDaysHistogram = clearAndGetHistogram( + "PWMGR_LOGIN_LAST_USED_DAYS" + ); + + let originCount = new Map(); + for (let login of logins) { + usernamePresentHistogram.add(!!login.username); + + let origin = login.origin; + originCount.set(origin, (originCount.get(origin) || 0) + 1); + + login.QueryInterface(Ci.nsILoginMetaInfo); + let timeLastUsedAgeMs = referenceTimeMs - login.timeLastUsed; + if (timeLastUsedAgeMs > 0) { + loginLastUsedDaysHistogram.add( + Math.floor(timeLastUsedAgeMs / MS_PER_DAY) + ); + } + } + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + "PWMGR_LOGIN_LAST_USED_DAYS" + ); + + let passwordsCountHistogram = clearAndGetHistogram( + "PWMGR_NUM_PASSWORDS_PER_HOSTNAME" + ); + for (let count of originCount.values()) { + passwordsCountHistogram.add(count); + } + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + "PWMGR_NUM_PASSWORDS_PER_HOSTNAME" + ); + + Services.obs.notifyObservers(null, "passwordmgr-gather-telemetry-complete"); + }, + + /** + * Ensures that a login isn't missing any necessary fields. + * + * @param login + * The login to check. + */ + _checkLogin(login) { + // Sanity check the login + if (login.origin == null || !login.origin.length) { + throw new Error("Can't add a login with a null or empty origin."); + } + + // For logins w/o a username, set to "", not null. + if (login.username == null) { + throw new Error("Can't add a login with a null username."); + } + + if (login.password == null || !login.password.length) { + throw new Error("Can't add a login with a null or empty password."); + } + + // Duplicated from toolkit/components/passwordmgr/LoginHelper.jsm + // TODO: move all validations into this function. + // + // In theory these nulls should just be rolled up into the encrypted + // values, but nsISecretDecoderRing doesn't use nsStrings, so the + // nulls cause truncation. Check for them here just to avoid + // unexpected round-trip surprises. + if (login.username.includes("\0") || login.password.includes("\0")) { + throw new Error("login values can't contain nulls"); + } + + if (login.formActionOrigin || login.formActionOrigin == "") { + // We have a form submit URL. Can't have a HTTP realm. + if (login.httpRealm != null) { + throw new Error( + "Can't add a login with both a httpRealm and formActionOrigin." + ); + } + } else if (login.httpRealm) { + // We have a HTTP realm. Can't have a form submit URL. + if (login.formActionOrigin != null) { + throw new Error( + "Can't add a login with both a httpRealm and formActionOrigin." + ); + } + } else { + // Need one or the other! + throw new Error( + "Can't add a login without a httpRealm or formActionOrigin." + ); + } + + login.QueryInterface(Ci.nsILoginMetaInfo); + for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) { + // Invalid dates + if (login[pname] > MAX_DATE_MS) { + throw new Error("Can't add a login with invalid date properties."); + } + } + }, + + /* ---------- Primary Public interfaces ---------- */ + + /** + * @type Promise + * This promise is resolved when initialization is complete, and is rejected + * in case the asynchronous part of initialization failed. + */ + initializationPromise: null, + + /** + * Add a new login to login storage. + * @deprecated: use `addLoginAsync` instead. + */ + addLogin(login) { + this._checkLogin(login); + + // Look for an existing entry. + let logins = this.findLogins( + login.origin, + login.formActionOrigin, + login.httpRealm + ); + + let matchingLogin = logins.find(l => login.matches(l, true)); + if (matchingLogin) { + throw lazy.LoginHelper.createLoginAlreadyExistsError(matchingLogin.guid); + } + lazy.log.debug("addLogin is DEPRECATED, please use addLoginAsync instead."); + return this._storage.addLogin(login); + }, + + /** + * Add a new login to login storage. + */ + async addLoginAsync(login) { + this._checkLogin(login); + + const { origin, formActionOrigin, httpRealm } = login; + const existingLogins = this.findLogins(origin, formActionOrigin, httpRealm); + const matchingLogin = existingLogins.find(l => login.matches(l, true)); + if (matchingLogin) { + throw lazy.LoginHelper.createLoginAlreadyExistsError(matchingLogin.guid); + } + + const crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"].getService( + Ci.nsILoginManagerCrypto + ); + const plaintexts = [login.username, login.password, login.unknownFields]; + const [username, password, unknownFields] = await crypto.encryptMany( + plaintexts + ); + + const { username: plaintextUsername, password: plaintextPassword } = login; + login.username = username; + login.password = password; + login.unknownFields = unknownFields; + + lazy.log.debug("Adding login"); + return this._storage.addLogin( + login, + true, + plaintextUsername, + plaintextPassword + ); + }, + + async addLogins(logins) { + if (logins.length === 0) { + return logins; + } + + const crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"].getService( + Ci.nsILoginManagerCrypto + ); + const plaintexts = logins + .map(({ username }) => username) + .concat(logins.map(({ password }) => password)); + const ciphertexts = await crypto.encryptMany(plaintexts); + const usernames = ciphertexts.slice(0, logins.length); + const passwords = ciphertexts.slice(logins.length); + + const resultLogins = []; + for (const [i, login] of logins.entries()) { + try { + this._checkLogin(login); + } catch (e) { + console.error(e); + continue; + } + + const { origin, formActionOrigin, httpRealm } = login; + const existingLogins = this.findLogins( + origin, + formActionOrigin, + httpRealm + ); + const matchingLogin = existingLogins.find(l => login.matches(l, true)); + if (matchingLogin) { + console.error( + lazy.LoginHelper.createLoginAlreadyExistsError(matchingLogin.guid) + ); + continue; + } + + const { username: plaintextUsername, password: plaintextPassword } = + login; + login.username = usernames[i]; + login.password = passwords[i]; + lazy.log.debug("Adding login"); + const resultLogin = this._storage.addLogin( + login, + true, + plaintextUsername, + plaintextPassword + ); + + resultLogins.push(resultLogin); + } + return resultLogins; + }, + + /** + * Remove the specified login from the stored logins. + */ + removeLogin(login) { + lazy.log.debug( + "Removing login", + login.QueryInterface(Ci.nsILoginMetaInfo).guid + ); + return this._storage.removeLogin(login); + }, + + /** + * Change the specified login to match the new login or new properties. + */ + modifyLogin(oldLogin, newLogin) { + lazy.log.debug( + "Modifying login", + oldLogin.QueryInterface(Ci.nsILoginMetaInfo).guid + ); + return this._storage.modifyLogin(oldLogin, newLogin); + }, + + /** + * Record that the password of a saved login was used (e.g. submitted or copied). + */ + recordPasswordUse( + login, + privateContextWithoutExplicitConsent, + loginType, + filled + ) { + lazy.log.debug( + "Recording password use", + loginType, + login.QueryInterface(Ci.nsILoginMetaInfo).guid + ); + if (!privateContextWithoutExplicitConsent) { + // don't record non-interactive use in private browsing + this._storage.recordPasswordUse(login); + } + + Services.telemetry.recordEvent( + "pwmgr", + "saved_login_used", + loginType, + null, + { + filled: "" + filled, + } + ); + }, + + /** + * Get a dump of all stored logins. Used by the login manager UI. + * + * @return {nsILoginInfo[]} - If there are no logins, the array is empty. + */ + getAllLogins() { + lazy.log.debug("Getting a list of all logins."); + return this._storage.getAllLogins(); + }, + + /** + * Get a dump of all stored logins asynchronously. Used by the login manager UI. + * + * @return {nsILoginInfo[]} - If there are no logins, the array is empty. + */ + async getAllLoginsAsync() { + lazy.log.debug("Getting a list of all logins asynchronously."); + return this._storage.getAllLoginsAsync(); + }, + + /** + * Get a dump of all stored logins asynchronously. Used by the login detection service. + */ + getAllLoginsWithCallbackAsync(aCallback) { + lazy.log.debug("Searching a list of all logins asynchronously."); + this._storage.getAllLoginsAsync().then(logins => { + aCallback.onSearchComplete(logins); + }); + }, + + /** + * Remove all user facing stored logins. + * + * This will not remove the FxA Sync key, which is stored with the rest of a user's logins. + */ + removeAllUserFacingLogins() { + lazy.log.debug("Removing all user facing logins."); + this._storage.removeAllUserFacingLogins(); + }, + + /** + * Remove all logins from data store, including the FxA Sync key. + * + * NOTE: You probably want `removeAllUserFacingLogins()` instead of this function. + * This function will remove the FxA Sync key, which will break syncing of saved user data + * e.g. bookmarks, history, open tabs, logins and passwords, add-ons, and options + */ + removeAllLogins() { + lazy.log.debug("Removing all logins from local store, including FxA key."); + this._storage.removeAllLogins(); + }, + + /** + * Get a list of all origins for which logins are disabled. + * + * @param {Number} count - only needed for XPCOM. + * + * @return {String[]} of disabled origins. If there are no disabled origins, + * the array is empty. + */ + getAllDisabledHosts() { + lazy.log.debug("Getting a list of all disabled origins."); + + let disabledHosts = []; + for (let perm of Services.perms.all) { + if ( + perm.type == PERMISSION_SAVE_LOGINS && + perm.capability == Services.perms.DENY_ACTION + ) { + disabledHosts.push(perm.principal.URI.displayPrePath); + } + } + + lazy.log.debug(`Returning ${disabledHosts.length} disabled hosts.`); + return disabledHosts; + }, + + /** + * Search for the known logins for entries matching the specified criteria. + */ + findLogins(origin, formActionOrigin, httpRealm) { + lazy.log.debug( + "Searching for logins matching origin:", + origin, + "formActionOrigin:", + formActionOrigin, + "httpRealm:", + httpRealm + ); + + return this._storage.findLogins(origin, formActionOrigin, httpRealm); + }, + + async searchLoginsAsync(matchData) { + lazy.log.debug( + `Searching for matching logins for origin: ${matchData.origin}` + ); + + if (!matchData.origin) { + throw new Error("searchLoginsAsync: An `origin` is required"); + } + + return this._storage.searchLoginsAsync(matchData); + }, + + /** + * @return {nsILoginInfo[]} which are decrypted. + */ + searchLogins(matchData) { + lazy.log.debug( + `Searching for matching logins for origin: ${matchData.origin}` + ); + + matchData.QueryInterface(Ci.nsIPropertyBag2); + if (!matchData.hasKey("guid")) { + if (!matchData.hasKey("origin")) { + lazy.log.warn("An `origin` field is recommended."); + } + } + + return this._storage.searchLogins(matchData); + }, + + /** + * Search for the known logins for entries matching the specified criteria, + * returns only the count. + */ + countLogins(origin, formActionOrigin, httpRealm) { + const loginsCount = this._storage.countLogins( + origin, + formActionOrigin, + httpRealm + ); + + lazy.log.debug( + `Found ${loginsCount} matching origin: ${origin}, formActionOrigin: ${formActionOrigin} and realm: ${httpRealm}` + ); + + return loginsCount; + }, + + /* Sync metadata functions - see nsILoginManagerStorage for details */ + async getSyncID() { + return this._storage.getSyncID(); + }, + + async setSyncID(id) { + await this._storage.setSyncID(id); + }, + + async getLastSync() { + return this._storage.getLastSync(); + }, + + async setLastSync(timestamp) { + await this._storage.setLastSync(timestamp); + }, + + async ensureCurrentSyncID(newSyncID) { + let existingSyncID = await this.getSyncID(); + if (existingSyncID == newSyncID) { + return existingSyncID; + } + lazy.log.debug( + `ensureCurrentSyncID: newSyncID: ${newSyncID} existingSyncID: ${existingSyncID}` + ); + + await this.setSyncID(newSyncID); + await this.setLastSync(0); + return newSyncID; + }, + + get uiBusy() { + return this._storage.uiBusy; + }, + + get isLoggedIn() { + return this._storage.isLoggedIn; + }, + + /** + * Check to see if user has disabled saving logins for the origin. + */ + getLoginSavingEnabled(origin) { + lazy.log.debug(`Checking if logins to ${origin} can be saved.`); + if (!lazy.LoginHelper.enabled) { + return false; + } + + try { + let uri = Services.io.newURI(origin); + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + return ( + Services.perms.testPermissionFromPrincipal( + principal, + PERMISSION_SAVE_LOGINS + ) != Services.perms.DENY_ACTION + ); + } catch (ex) { + if (!origin.startsWith("chrome:")) { + console.error(ex); + } + return false; + } + }, + + /** + * Enable or disable storing logins for the specified origin. + */ + setLoginSavingEnabled(origin, enabled) { + // Throws if there are bogus values. + lazy.LoginHelper.checkOriginValue(origin); + + let uri = Services.io.newURI(origin); + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + if (enabled) { + Services.perms.removeFromPrincipal(principal, PERMISSION_SAVE_LOGINS); + } else { + Services.perms.addFromPrincipal( + principal, + PERMISSION_SAVE_LOGINS, + Services.perms.DENY_ACTION + ); + } + + lazy.log.debug( + `Enabling login saving for ${origin} now enabled? ${enabled}.` + ); + lazy.LoginHelper.notifyStorageChanged( + enabled ? "hostSavingEnabled" : "hostSavingDisabled", + origin + ); + }, +}; // end of LoginManager implementation diff --git a/toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs b/toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs new file mode 100644 index 0000000000..6d66b17d63 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs @@ -0,0 +1,1115 @@ +/* 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/. */ + +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; +import { PromptUtils } from "resource://gre/modules/PromptUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gPrompterService", + "@mozilla.org/login-manager/prompter;1", + Ci.nsILoginManagerPrompter +); + +/* eslint-disable block-scoped-var, no-var */ + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" +); + +/** + * A helper module to prevent modal auth prompt abuse. + */ +const PromptAbuseHelper = { + getBaseDomainOrFallback(hostname) { + try { + return Services.eTLD.getBaseDomainFromHost(hostname); + } catch (e) { + return hostname; + } + }, + + incrementPromptAbuseCounter(baseDomain, browser) { + if (!browser) { + return; + } + + if (!browser.authPromptAbuseCounter) { + browser.authPromptAbuseCounter = {}; + } + + if (!browser.authPromptAbuseCounter[baseDomain]) { + browser.authPromptAbuseCounter[baseDomain] = 0; + } + + browser.authPromptAbuseCounter[baseDomain] += 1; + }, + + resetPromptAbuseCounter(baseDomain, browser) { + if (!browser || !browser.authPromptAbuseCounter) { + return; + } + + browser.authPromptAbuseCounter[baseDomain] = 0; + }, + + hasReachedAbuseLimit(baseDomain, browser) { + if (!browser || !browser.authPromptAbuseCounter) { + return false; + } + + let abuseCounter = browser.authPromptAbuseCounter[baseDomain]; + // Allow for setting -1 to turn the feature off. + if (this.abuseLimit < 0) { + return false; + } + return !!abuseCounter && abuseCounter >= this.abuseLimit; + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + PromptAbuseHelper, + "abuseLimit", + "prompts.authentication_dialog_abuse_limit" +); + +/** + * Implements nsIPromptFactory + * + * Invoked by [toolkit/components/prompts/src/Prompter.jsm] + */ +export function LoginManagerAuthPromptFactory() { + Services.obs.addObserver(this, "passwordmgr-crypto-login", true); +} + +LoginManagerAuthPromptFactory.prototype = { + classID: Components.ID("{749e62f4-60ae-4569-a8a2-de78b649660e}"), + QueryInterface: ChromeUtils.generateQI([ + "nsIPromptFactory", + "nsIObserver", + "nsISupportsWeakReference", + ]), + + // Tracks pending auth prompts per top level browser and hash key. + // browser -> hashkey -> prompt + // This enables us to consolidate auth prompts with the same browser and + // hashkey (level, origin, realm). + _pendingPrompts: new WeakMap(), + _pendingSavePrompts: new WeakMap(), + // We use a separate bucket for when we don't have a browser. + // _noBrowser -> hashkey -> prompt + _noBrowser: {}, + // Promise used to defer prompts if the password manager isn't ready when + // they're called. + _uiBusyPromise: null, + _uiBusyResolve: null, + + observe(subject, topic, data) { + this.log(`Observed topic: ${topic}.`); + if (topic == "passwordmgr-crypto-login") { + // Show the deferred prompters. + this._uiBusyResolve?.(); + } + }, + + getPrompt(aWindow, aIID) { + var prompt = new LoginManagerAuthPrompter().QueryInterface(aIID); + prompt.init(aWindow, this); + return prompt; + }, + + getPendingPrompt(browser, hashKey) { + // If there is already a matching auth prompt which has no browser + // associated we can reuse it. This way we avoid showing tab level prompts + // when there is already a pending window prompt. + let pendingNoBrowserPrompt = this._pendingPrompts + .get(this._noBrowser) + ?.get(hashKey); + if (pendingNoBrowserPrompt) { + return pendingNoBrowserPrompt; + } + return this._pendingPrompts.get(browser)?.get(hashKey); + }, + + _dismissPendingSavePrompt(browser) { + this._pendingSavePrompts.get(browser)?.dismiss(); + this._pendingSavePrompts.delete(browser); + }, + + _setPendingSavePrompt(browser, prompt) { + this._pendingSavePrompts.set(browser, prompt); + }, + + _setPendingPrompt(prompt, hashKey) { + let browser = prompt.prompter.browser || this._noBrowser; + let hashToPrompt = this._pendingPrompts.get(browser); + if (!hashToPrompt) { + hashToPrompt = new Map(); + this._pendingPrompts.set(browser, hashToPrompt); + } + hashToPrompt.set(hashKey, prompt); + }, + + _removePendingPrompt(prompt, hashKey) { + let browser = prompt.prompter.browser || this._noBrowser; + let hashToPrompt = this._pendingPrompts.get(browser); + if (!hashToPrompt) { + return; + } + hashToPrompt.delete(hashKey); + if (!hashToPrompt.size) { + this._pendingPrompts.delete(browser); + } + }, + + async _waitForLoginsUI(prompt) { + await this._uiBusyPromise; + + let [origin, httpRealm] = prompt.prompter._getAuthTarget( + prompt.channel, + prompt.authInfo + ); + + // No UI to wait for. + if (!Services.logins.uiBusy) { + return; + } + + let hasLogins = Services.logins.countLogins(origin, null, httpRealm) > 0; + if ( + !hasLogins && + lazy.LoginHelper.schemeUpgrades && + origin.startsWith("https://") + ) { + let httpOrigin = origin.replace(/^https:\/\//, "http://"); + hasLogins = Services.logins.countLogins(httpOrigin, null, httpRealm) > 0; + } + // We don't depend on saved logins. + if (!hasLogins) { + return; + } + + this.log("Waiting for primary password UI."); + + this._uiBusyPromise = new Promise(resolve => { + this._uiBusyResolve = resolve; + }); + await this._uiBusyPromise; + }, + + async _doAsyncPrompt(prompt, hashKey) { + this._setPendingPrompt(prompt, hashKey); + + // UI might be busy due to the primary password dialog. Wait for it to close. + await this._waitForLoginsUI(prompt); + + let ok = false; + let promptAborted = false; + try { + this.log(`Performing the prompt for ${hashKey}.`); + ok = await prompt.prompter.promptAuthInternal( + prompt.channel, + prompt.level, + prompt.authInfo + ); + } catch (e) { + if ( + e instanceof Components.Exception && + e.result == Cr.NS_ERROR_NOT_AVAILABLE + ) { + this.log("Bypassed, UI is not available in this context."); + // Prompts throw NS_ERROR_NOT_AVAILABLE if they're aborted. + promptAborted = true; + } else { + console.error("LoginManagerAuthPrompter: _doAsyncPrompt " + e + "\n"); + } + } + + this._removePendingPrompt(prompt, hashKey); + + // Handle callbacks + for (var consumer of prompt.consumers) { + if (!consumer.callback) { + // Not having a callback means that consumer didn't provide it + // or canceled the notification + continue; + } + + this.log(`Calling back to callback: ${consumer.callback} ok: ${ok}.`); + try { + if (ok) { + consumer.callback.onAuthAvailable(consumer.context, prompt.authInfo); + } else { + consumer.callback.onAuthCancelled(consumer.context, !promptAborted); + } + } catch (e) { + /* Throw away exceptions caused by callback */ + } + } + }, +}; // end of LoginManagerAuthPromptFactory implementation + +XPCOMUtils.defineLazyGetter( + LoginManagerAuthPromptFactory.prototype, + "log", + () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerAuthPromptFactory"); + return logger.log.bind(logger); + } +); + +/* ==================== LoginManagerAuthPrompter ==================== */ + +/** + * Implements interfaces for prompting the user to enter/save/change auth info. + * + * nsIAuthPrompt: Used by SeaMonkey, Thunderbird, but not Firefox. + * + * nsIAuthPrompt2: Is invoked by a channel for protocol-based authentication + * (eg HTTP Authenticate, FTP login). + * + * nsILoginManagerAuthPrompter: Used by consumers to indicate which tab/window a + * prompt should appear on. + */ +export function LoginManagerAuthPrompter() {} + +LoginManagerAuthPrompter.prototype = { + classID: Components.ID("{8aa66d77-1bbb-45a6-991e-b8f47751c291}"), + QueryInterface: ChromeUtils.generateQI([ + "nsIAuthPrompt", + "nsIAuthPrompt2", + "nsILoginManagerAuthPrompter", + ]), + + _factory: null, + _chromeWindow: null, + _browser: null, + + __strBundle: null, // String bundle for L10N + get _strBundle() { + if (!this.__strBundle) { + this.__strBundle = Services.strings.createBundle( + "chrome://passwordmgr/locale/passwordmgr.properties" + ); + if (!this.__strBundle) { + throw new Error("String bundle for Login Manager not present!"); + } + } + + return this.__strBundle; + }, + + __ellipsis: null, + get _ellipsis() { + if (!this.__ellipsis) { + this.__ellipsis = "\u2026"; + try { + this.__ellipsis = Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + } + return this.__ellipsis; + }, + + // Whether we are in private browsing mode + get _inPrivateBrowsing() { + if (this._chromeWindow) { + return PrivateBrowsingUtils.isWindowPrivate(this._chromeWindow); + } + // If we don't that we're in private browsing mode if the caller did + // not provide a window. The callers which really care about this + // will indeed pass down a window to us, and for those who don't, + // we can just assume that we don't want to save the entered login + // information. + this.log("We have no chromeWindow so assume we're in a private context."); + return true; + }, + + get _allowRememberLogin() { + if (!this._inPrivateBrowsing) { + return true; + } + return lazy.LoginHelper.privateBrowsingCaptureEnabled; + }, + + /* ---------- nsIAuthPrompt prompts ---------- */ + + /** + * Wrapper around the prompt service prompt. Saving random fields here + * doesn't really make sense and therefore isn't implemented. + */ + prompt( + aDialogTitle, + aText, + aPasswordRealm, + aSavePassword, + aDefaultText, + aResult + ) { + if (aSavePassword != Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER) { + throw new Components.Exception( + "prompt only supports SAVE_PASSWORD_NEVER", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + if (aDefaultText) { + aResult.value = aDefaultText; + } + + return Services.prompt.prompt( + this._chromeWindow, + aDialogTitle, + aText, + aResult, + null, + {} + ); + }, + + /** + * Looks up a username and password in the database. Will prompt the user + * with a dialog, even if a username and password are found. + */ + async asyncPromptUsernameAndPassword( + aDialogTitle, + aText, + aPasswordRealm, + aSavePassword, + aUsername, + aPassword + ) { + if (aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_FOR_SESSION) { + throw new Components.Exception( + "asyncPromptUsernameAndPassword doesn't support SAVE_PASSWORD_FOR_SESSION", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + let foundLogins = null; + let canRememberLogin = false; + var selectedLogin = null; + var [origin, realm] = this._getRealmInfo(aPasswordRealm); + + // If origin is null, we can't save this login. + if (origin) { + if (this._allowRememberLogin) { + canRememberLogin = + aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY && + Services.logins.getLoginSavingEnabled(origin); + } + + // Look for existing logins. + foundLogins = Services.logins.findLogins(origin, null, realm); + + // XXX Like the original code, we can't deal with multiple + // account selection. (bug 227632) + if (foundLogins.length) { + selectedLogin = foundLogins[0]; + + // If the caller provided a username, try to use it. If they + // provided only a password, this will try to find a password-only + // login (or return null if none exists). + if (aUsername.value) { + selectedLogin = this._repickSelectedLogin( + foundLogins, + aUsername.value + ); + } + + if (selectedLogin) { + aUsername.value = selectedLogin.username; + // If the caller provided a password, prefer it. + if (!aPassword.value) { + aPassword.value = selectedLogin.password; + } + } + } + } + + let autofilled = !!aPassword.value; + var ok = Services.prompt.promptUsernameAndPassword( + this._chromeWindow, + aDialogTitle, + aText, + aUsername, + aPassword + ); + + if (!ok || !canRememberLogin) { + return ok; + } + + if (!aPassword.value) { + this.log("No password entered, so won't offer to save."); + return ok; + } + + // XXX We can't prompt with multiple logins yet (bug 227632), so + // the entered login might correspond to an existing login + // other than the one we originally selected. + selectedLogin = this._repickSelectedLogin(foundLogins, aUsername.value); + + // If we didn't find an existing login, or if the username + // changed, save as a new login. + let newLogin = new LoginInfo( + origin, + null, + realm, + aUsername.value, + aPassword.value + ); + if (!selectedLogin) { + // add as new + this.log(`New login seen for: ${realm}.`); + await Services.logins.addLoginAsync(newLogin); + } else if (aPassword.value != selectedLogin.password) { + // update password + this.log(`Updating password for ${realm}.`); + this._updateLogin(selectedLogin, newLogin); + } else { + this.log("Login unchanged, no further action needed."); + Services.logins.recordPasswordUse( + selectedLogin, + this._inPrivateBrowsing, + "prompt_login", + autofilled + ); + } + + return ok; + }, + + /** + * If a password is found in the database for the password realm, it is + * returned straight away without displaying a dialog. + * + * If a password is not found in the database, the user will be prompted + * with a dialog with a text field and ok/cancel buttons. If the user + * allows it, then the password will be saved in the database. + */ + async asyncPromptPassword( + aDialogTitle, + aText, + aPasswordRealm, + aSavePassword, + aPassword + ) { + if (aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_FOR_SESSION) { + throw new Components.Exception( + "promptPassword doesn't support SAVE_PASSWORD_FOR_SESSION", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + var [origin, realm, username] = this._getRealmInfo(aPasswordRealm); + + username = decodeURIComponent(username); + + let canRememberLogin = false; + // If origin is null, we can't save this login. + if (origin && !this._inPrivateBrowsing) { + canRememberLogin = + aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY && + Services.logins.getLoginSavingEnabled(origin); + if (!aPassword.value) { + // Look for existing logins. + var foundLogins = Services.logins.findLogins(origin, null, realm); + + // XXX Like the original code, we can't deal with multiple + // account selection (bug 227632). We can deal with finding the + // account based on the supplied username - but in this case we'll + // just return the first match. + for (var i = 0; i < foundLogins.length; ++i) { + if (foundLogins[i].username == username) { + aPassword.value = foundLogins[i].password; + // wallet returned straight away, so this mimics that code + return true; + } + } + } + } + + var ok = Services.prompt.promptPassword( + this._chromeWindow, + aDialogTitle, + aText, + aPassword + ); + + if (ok && canRememberLogin && aPassword.value) { + let newLogin = new LoginInfo( + origin, + null, + realm, + username, + aPassword.value + ); + + this.log(`New login seen for ${realm}.`); + + await Services.logins.addLoginAsync(newLogin); + } + + return ok; + }, + + /* ---------- nsIAuthPrompt helpers ---------- */ + + /** + * Given aRealmString, such as "http://user@example.com/foo", returns an + * array of: + * - the formatted origin + * - the realm (origin + path) + * - the username, if present + * + * If aRealmString is in the format produced by NS_GetAuthKey for HTTP[S] + * channels, e.g. "example.com:80 (httprealm)", null is returned for all + * arguments to let callers know the login can't be saved because we don't + * know whether it's http or https. + */ + _getRealmInfo(aRealmString) { + var httpRealm = /^.+ \(.+\)$/; + if (httpRealm.test(aRealmString)) { + return [null, null, null]; + } + + var uri = Services.io.newURI(aRealmString); + var pathname = ""; + + if (uri.pathQueryRef != "/") { + pathname = uri.pathQueryRef; + } + + var formattedOrigin = this._getFormattedOrigin(uri); + + return [formattedOrigin, formattedOrigin + pathname, uri.username]; + }, + + async promptAuthInternal(aChannel, aLevel, aAuthInfo) { + var selectedLogin = null; + var epicfail = false; + var canAutologin = false; + var foundLogins; + let autofilled = false; + + try { + // If the user submits a login but it fails, we need to remove the + // notification prompt that was displayed. Conveniently, the user will + // be prompted for authentication again, which brings us here. + this._factory._dismissPendingSavePrompt(this._browser); + + var [origin, httpRealm] = this._getAuthTarget(aChannel, aAuthInfo); + + // Looks for existing logins to prefill the prompt with. + foundLogins = await Services.logins.searchLoginsAsync({ + origin, + httpRealm, + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + }); + this.log(`Found ${foundLogins.length} matching logins.`); + let resolveBy = ["scheme", "timePasswordChanged"]; + foundLogins = lazy.LoginHelper.dedupeLogins( + foundLogins, + ["username"], + resolveBy, + origin + ); + this.log(`${foundLogins.length} matching logins remain after deduping.`); + + // XXX Can't select from multiple accounts yet. (bug 227632) + if (foundLogins.length) { + selectedLogin = foundLogins[0]; + this._SetAuthInfo( + aAuthInfo, + selectedLogin.username, + selectedLogin.password + ); + autofilled = true; + + // Allow automatic proxy login + if ( + aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY && + !(aAuthInfo.flags & Ci.nsIAuthInformation.PREVIOUS_FAILED) && + Services.prefs.getBoolPref("signon.autologin.proxy") && + !PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + this.log("Autologin enabled, skipping auth prompt."); + canAutologin = true; + } + } + + var canRememberLogin = Services.logins.getLoginSavingEnabled(origin); + if (!this._allowRememberLogin) { + canRememberLogin = false; + } + } catch (e) { + // Ignore any errors and display the prompt anyway. + epicfail = true; + console.error( + "LoginManagerAuthPrompter: Epic fail in promptAuth: " + e + "\n" + ); + } + + var ok = canAutologin; + let browser = this._browser; + let baseDomain; + + // We might not have a browser or browser.currentURI.host could fail + // (e.g. on about:blank). Fall back to the subresource hostname in that case. + try { + let topLevelHost = browser.currentURI.host; + baseDomain = PromptAbuseHelper.getBaseDomainOrFallback(topLevelHost); + } catch (e) { + baseDomain = PromptAbuseHelper.getBaseDomainOrFallback(origin); + } + + if (!ok) { + if (PromptAbuseHelper.hasReachedAbuseLimit(baseDomain, browser)) { + this.log("Blocking auth dialog, due to exceeding dialog bloat limit."); + return false; + } + + // Set up a counter for ensuring that the basic auth prompt can not + // be abused for DOS-style attacks. With this counter, each eTLD+1 + // per browser will get a limited number of times a user can + // cancel the prompt until we stop showing it. + PromptAbuseHelper.incrementPromptAbuseCounter(baseDomain, browser); + + if (this._chromeWindow) { + PromptUtils.fireDialogEvent( + this._chromeWindow, + "DOMWillOpenModalDialog", + this._browser + ); + } + + ok = await Services.prompt.asyncPromptAuth( + this._browser?.browsingContext, + LoginManagerAuthPrompter.promptAuthModalType, + aChannel, + aLevel, + aAuthInfo + ); + } + + let [username, password] = this._GetAuthInfo(aAuthInfo); + + // Reset the counter state if the user replied to a prompt and actually + // tried to login (vs. simply clicking any button to get out). + if (ok && (username || password)) { + PromptAbuseHelper.resetPromptAbuseCounter(baseDomain, browser); + } + + if (!ok || !canRememberLogin || epicfail) { + return ok; + } + + try { + if (!password) { + this.log("No password entered, so won't offer to save."); + return ok; + } + + // XXX We can't prompt with multiple logins yet (bug 227632), so + // the entered login might correspond to an existing login + // other than the one we originally selected. + selectedLogin = this._repickSelectedLogin(foundLogins, username); + + // If we didn't find an existing login, or if the username + // changed, save as a new login. + let newLogin = new LoginInfo(origin, null, httpRealm, username, password); + if (!selectedLogin) { + this.log(`New login seen for origin: ${origin}.`); + + let promptBrowser = lazy.LoginHelper.getBrowserForPrompt(browser); + let savePrompt = lazy.gPrompterService.promptToSavePassword( + promptBrowser, + newLogin + ); + this._factory._setPendingSavePrompt(promptBrowser, savePrompt); + } else if (password != selectedLogin.password) { + this.log(`Updating password for origin: ${origin}.`); + + let promptBrowser = lazy.LoginHelper.getBrowserForPrompt(browser); + let savePrompt = lazy.gPrompterService.promptToChangePassword( + promptBrowser, + selectedLogin, + newLogin + ); + this._factory._setPendingSavePrompt(promptBrowser, savePrompt); + } else { + this.log("Login unchanged, no further action needed."); + Services.logins.recordPasswordUse( + selectedLogin, + this._inPrivateBrowsing, + "auth_login", + autofilled + ); + } + } catch (e) { + console.error("LoginManagerAuthPrompter: Fail2 in promptAuth: " + e); + } + + return ok; + }, + + /* ---------- nsIAuthPrompt2 prompts ---------- */ + + /** + * Implementation of nsIAuthPrompt2. + * + * @param {nsIChannel} aChannel + * @param {int} aLevel + * @param {nsIAuthInformation} aAuthInfo + */ + promptAuth(aChannel, aLevel, aAuthInfo) { + let closed = false; + let result = false; + this.promptAuthInternal(aChannel, aLevel, aAuthInfo) + .then(ok => (result = ok)) + .finally(() => (closed = true)); + Services.tm.spinEventLoopUntilOrQuit( + "LoginManagerAuthPrompter.jsm:promptAuth", + () => closed + ); + return result; + }, + + asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) { + var cancelable = null; + + try { + // If the user submits a login but it fails, we need to remove the + // notification prompt that was displayed. Conveniently, the user will + // be prompted for authentication again, which brings us here. + this._factory._dismissPendingSavePrompt(this._browser); + + cancelable = this._newAsyncPromptConsumer(aCallback, aContext); + + let [origin, httpRealm] = this._getAuthTarget(aChannel, aAuthInfo); + + let hashKey = aLevel + "|" + origin + "|" + httpRealm; + let pendingPrompt = this._factory.getPendingPrompt( + this._browser, + hashKey + ); + if (pendingPrompt) { + this.log( + `Prompt bound to an existing one in the queue, callback: ${aCallback}.` + ); + pendingPrompt.consumers.push(cancelable); + return cancelable; + } + + this.log(`Adding new async prompt, callback: ${aCallback}.`); + let asyncPrompt = { + consumers: [cancelable], + channel: aChannel, + authInfo: aAuthInfo, + level: aLevel, + prompter: this, + }; + + this._factory._doAsyncPrompt(asyncPrompt, hashKey); + } catch (e) { + console.error( + "LoginManagerAuthPrompter: " + + "asyncPromptAuth: " + + e + + "\nFalling back to promptAuth\n" + ); + // Fail the prompt operation to let the consumer fall back + // to synchronous promptAuth method + throw e; + } + + return cancelable; + }, + + /* ---------- nsILoginManagerAuthPrompter prompts ---------- */ + + init(aWindow = null, aFactory = null) { + if (!aWindow) { + // There may be no applicable window e.g. in a Sandbox or JSM. + this._chromeWindow = null; + this._browser = null; + } else if (aWindow.isChromeWindow) { + this._chromeWindow = aWindow; + // needs to be set explicitly using setBrowser + this._browser = null; + } else { + let { win, browser } = this._getChromeWindow(aWindow); + this._chromeWindow = win; + this._browser = browser; + } + this._factory = aFactory || null; + }, + + set browser(aBrowser) { + this._browser = aBrowser; + }, + + get browser() { + return this._browser; + }, + + /* ---------- Internal Methods ---------- */ + + _updateLogin(login, aNewLogin) { + var now = Date.now(); + var propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + propBag.setProperty("formActionOrigin", aNewLogin.formActionOrigin); + propBag.setProperty("origin", aNewLogin.origin); + propBag.setProperty("password", aNewLogin.password); + propBag.setProperty("username", aNewLogin.username); + // Explicitly set the password change time here (even though it would + // be changed automatically), to ensure that it's exactly the same + // value as timeLastUsed. + propBag.setProperty("timePasswordChanged", now); + propBag.setProperty("timeLastUsed", now); + propBag.setProperty("timesUsedIncrement", 1); + // Note that we don't call `recordPasswordUse` so we won't potentially record + // both a use and a save/update. See bug 1640096. + Services.logins.modifyLogin(login, propBag); + }, + + /** + * Given a content DOM window, returns the chrome window and browser it's in. + */ + _getChromeWindow(aWindow) { + let browser = aWindow.docShell.chromeEventHandler; + if (!browser) { + return null; + } + + let chromeWin = browser.ownerGlobal; + if (!chromeWin) { + return null; + } + + return { win: chromeWin, browser }; + }, + + /** + * The user might enter a login that isn't the one we prefilled, but + * is the same as some other existing login. So, pick a login with a + * matching username, or return null. + */ + _repickSelectedLogin(foundLogins, username) { + for (var i = 0; i < foundLogins.length; i++) { + if (foundLogins[i].username == username) { + return foundLogins[i]; + } + } + return null; + }, + + /** + * Can be called as: + * _getLocalizedString("key1"); + * _getLocalizedString("key2", ["arg1"]); + * _getLocalizedString("key3", ["arg1", "arg2"]); + * (etc) + * + * Returns the localized string for the specified key, + * formatted if required. + * + */ + _getLocalizedString(key, formatArgs) { + if (formatArgs) { + return this._strBundle.formatStringFromName(key, formatArgs); + } + return this._strBundle.GetStringFromName(key); + }, + + /** + * Sanitizes the specified username, by stripping quotes and truncating if + * it's too long. This helps prevent an evil site from messing with the + * "save password?" prompt too much. + */ + _sanitizeUsername(username) { + if (username.length > 30) { + username = username.substring(0, 30); + username += this._ellipsis; + } + return username.replace(/['"]/g, ""); + }, + + /** + * The aURI parameter may either be a string uri, or an nsIURI instance. + * + * Returns the origin to use in a nsILoginInfo object (for example, + * "http://example.com"). + */ + _getFormattedOrigin(aURI) { + let uri; + if (aURI instanceof Ci.nsIURI) { + uri = aURI; + } else { + uri = Services.io.newURI(aURI); + } + + return uri.scheme + "://" + uri.displayHostPort; + }, + + /** + * Converts a login's origin field (a URL) to a short string for + * prompting purposes. Eg, "http://foo.com" --> "foo.com", or + * "ftp://www.site.co.uk" --> "site.co.uk". + */ + _getShortDisplayHost(aURIString) { + var displayHost; + + var idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + try { + var uri = Services.io.newURI(aURIString); + var baseDomain = Services.eTLD.getBaseDomain(uri); + displayHost = idnService.convertToDisplayIDN(baseDomain, {}); + } catch (e) { + this.log(`Couldn't process supplied URIString ${aURIString}.`); + } + + if (!displayHost) { + displayHost = aURIString; + } + + return displayHost; + }, + + /** + * Returns the origin and realm for which authentication is being + * requested, in the format expected to be used with nsILoginInfo. + */ + _getAuthTarget(aChannel, aAuthInfo) { + var origin, realm; + + // If our proxy is demanding authentication, don't use the + // channel's actual destination. + if (aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY) { + this.log("getAuthTarget is for proxy auth."); + if (!(aChannel instanceof Ci.nsIProxiedChannel)) { + throw new Error("proxy auth needs nsIProxiedChannel"); + } + + var info = aChannel.proxyInfo; + if (!info) { + throw new Error("proxy auth needs nsIProxyInfo"); + } + + // Proxies don't have a scheme, but we'll use "moz-proxy://" + // so that it's more obvious what the login is for. + var idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + origin = + "moz-proxy://" + + idnService.convertUTF8toACE(info.host) + + ":" + + info.port; + realm = aAuthInfo.realm; + if (!realm) { + realm = origin; + } + + return [origin, realm]; + } + + origin = this._getFormattedOrigin(aChannel.URI); + + // If a HTTP WWW-Authenticate header specified a realm, that value + // will be available here. If it wasn't set or wasn't HTTP, we'll use + // the formatted origin instead. + realm = aAuthInfo.realm; + if (!realm) { + realm = origin; + } + + return [origin, realm]; + }, + + /** + * Returns [username, password] as extracted from aAuthInfo (which + * holds this info after having prompted the user). + * + * If the authentication was for a Windows domain, we'll prepend the + * return username with the domain. (eg, "domain\user") + */ + _GetAuthInfo(aAuthInfo) { + var username, password; + + var flags = aAuthInfo.flags; + if (flags & Ci.nsIAuthInformation.NEED_DOMAIN && aAuthInfo.domain) { + username = aAuthInfo.domain + "\\" + aAuthInfo.username; + } else { + username = aAuthInfo.username; + } + + password = aAuthInfo.password; + + return [username, password]; + }, + + /** + * Given a username (possibly in DOMAIN\user form) and password, parses the + * domain out of the username if necessary and sets domain, username and + * password on the auth information object. + */ + _SetAuthInfo(aAuthInfo, username, password) { + var flags = aAuthInfo.flags; + if (flags & Ci.nsIAuthInformation.NEED_DOMAIN) { + // Domain is separated from username by a backslash + var idx = username.indexOf("\\"); + if (idx == -1) { + aAuthInfo.username = username; + } else { + aAuthInfo.domain = username.substring(0, idx); + aAuthInfo.username = username.substring(idx + 1); + } + } else { + aAuthInfo.username = username; + } + aAuthInfo.password = password; + }, + + _newAsyncPromptConsumer(aCallback, aContext) { + return { + QueryInterface: ChromeUtils.generateQI(["nsICancelable"]), + callback: aCallback, + context: aContext, + cancel() { + this.callback.onAuthCancelled(this.context, false); + this.callback = null; + this.context = null; + }, + }; + }, +}; // end of LoginManagerAuthPrompter implementation + +XPCOMUtils.defineLazyGetter(LoginManagerAuthPrompter.prototype, "log", () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerAuthPrompter"); + return logger.log.bind(logger); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + LoginManagerAuthPrompter, + "promptAuthModalType", + "prompts.modalType.httpAuth", + Services.prompt.MODAL_TYPE_WINDOW +); diff --git a/toolkit/components/passwordmgr/LoginManagerChild.sys.mjs b/toolkit/components/passwordmgr/LoginManagerChild.sys.mjs new file mode 100644 index 0000000000..118226c207 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerChild.sys.mjs @@ -0,0 +1,3183 @@ +/* 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/. */ + +/** + * Module doing most of the content process work for the password manager. + */ + +// Disable use-ownerGlobal since LoginForm doesn't have it. +/* eslint-disable mozilla/use-ownerGlobal */ + +const PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS = 1; +// The amount of time a context menu event supresses showing a +// popup from a focus event in ms. This matches the threshold in +// toolkit/components/satchel/nsFormFillController.cpp +const AUTOCOMPLETE_AFTER_RIGHT_CLICK_THRESHOLD_MS = 400; +const AUTOFILL_STATE = "autofill"; + +const SUBMIT_FORM_SUBMIT = 1; +const SUBMIT_PAGE_NAVIGATION = 2; +const SUBMIT_FORM_IS_REMOVED = 3; + +const LOG_MESSAGE_FORM_SUBMISSION = "form submission"; +const LOG_MESSAGE_FIELD_EDIT = "field edit"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; +import { CreditCard } from "resource://gre/modules/CreditCard.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs", + LoginFormFactory: "resource://gre/modules/LoginFormFactory.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + LoginRecipesContent: "resource://gre/modules/LoginRecipes.sys.mjs", + SignUpFormRuleset: "resource://gre/modules/SignUpFormRuleset.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gFormFillService", + "@mozilla.org/satchel/form-fill-controller;1", + "nsIFormFillController" +); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerChild"); + return logger.log.bind(logger); +}); + +Services.cpmm.addMessageListener("clearRecipeCache", () => { + lazy.LoginRecipesContent._clearRecipeCache(); +}); + +let gLastRightClickTimeStamp = Number.NEGATIVE_INFINITY; + +// Events on pages with Shadow DOM could return the shadow host element +// (aEvent.target) rather than the actual username or password field +// (aEvent.composedTarget). +// Only allow input elements (can be extended later) to avoid false negatives. +class WeakFieldSet extends WeakSet { + add(value) { + if (!HTMLInputElement.isInstance(value)) { + throw new Error("Non-field type added to a WeakFieldSet"); + } + super.add(value); + } +} + +const observer = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + // nsIWebProgressListener + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + // Only handle pushState/replaceState here. + if ( + !(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) || + !(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) + ) { + return; + } + + const window = aWebProgress.DOMWindow; + lazy.log( + "onLocationChange handled:", + aLocation.displaySpec, + window.document + ); + LoginManagerChild.forWindow(window)._onNavigation(window.document); + }, + + onStateChange(aWebProgress, aRequest, aState, aStatus) { + const window = aWebProgress.DOMWindow; + const loginManagerChild = () => LoginManagerChild.forWindow(window); + + if ( + aState & Ci.nsIWebProgressListener.STATE_RESTORING && + aState & Ci.nsIWebProgressListener.STATE_STOP + ) { + // Re-fill a document restored from bfcache since password field values + // aren't persisted there. + loginManagerChild()._onDocumentRestored(window.document); + return; + } + + if (!(aState & Ci.nsIWebProgressListener.STATE_START)) { + return; + } + + // We only care about when a page triggered a load, not the user. For example: + // clicking refresh/back/forward, typing a URL and hitting enter, and loading a bookmark aren't + // likely to be when a user wants to save a login. + let channel = aRequest.QueryInterface(Ci.nsIChannel); + let triggeringPrincipal = channel.loadInfo.triggeringPrincipal; + if ( + triggeringPrincipal.isNullPrincipal || + triggeringPrincipal.equals( + Services.scriptSecurityManager.getSystemPrincipal() + ) + ) { + return; + } + + // Don't handle history navigation, reload, or pushState not triggered via chrome UI. + // e.g. history.go(-1), location.reload(), history.replaceState() + if (!(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_NORMAL)) { + lazy.log(`loadType isn't LOAD_CMD_NORMAL: ${aWebProgress.loadType}.`); + return; + } + + lazy.log(`Handled channel: ${channel}`); + loginManagerChild()._onNavigation(window.document); + }, + + // nsIObserver + observe(subject, topic, data) { + switch (topic) { + case "autocomplete-did-enter-text": { + let input = subject.QueryInterface(Ci.nsIAutoCompleteInput); + let { selectedIndex } = input.popup; + if (selectedIndex < 0) { + break; + } + + let { focusedInput } = lazy.gFormFillService; + if (focusedInput.nodePrincipal.isNullPrincipal) { + // If we have a null principal then prevent any more password manager code from running and + // incorrectly using the document `location`. + return; + } + + let window = focusedInput.ownerGlobal; + let loginManagerChild = LoginManagerChild.forWindow(window); + + let style = input.controller.getStyleAt(selectedIndex); + if (style == "login" || style == "loginWithOrigin") { + let details = JSON.parse( + input.controller.getCommentAt(selectedIndex) + ); + loginManagerChild.onFieldAutoComplete(focusedInput, details.guid); + } else if (style == "generatedPassword") { + loginManagerChild._filledWithGeneratedPassword(focusedInput); + } + break; + } + } + }, + + // nsIDOMEventListener + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + return; + } + + if (!lazy.LoginHelper.enabled) { + return; + } + + const ownerDocument = aEvent.target.ownerDocument; + const window = ownerDocument.defaultView; + const loginManagerChild = LoginManagerChild.forWindow(window); + const docState = loginManagerChild.stateForDocument(ownerDocument); + const field = aEvent.composedTarget; + + switch (aEvent.type) { + // Used to mask fields with filled generated passwords when blurred. + case "blur": { + if (docState.generatedPasswordFields.has(field)) { + docState._togglePasswordFieldMasking(field, false); + } + break; + } + + // Used to watch for changes to username and password fields. + case "change": { + let formLikeRoot = lazy.FormLikeFactory.findRootForField(field); + if (!docState.fieldModificationsByRootElement.get(formLikeRoot)) { + lazy.log( + "Ignoring change event on form that hasn't been user-modified." + ); + if (field.hasBeenTypePassword) { + // Send notification that the password field has not been changed. + // This is used only for testing. + loginManagerChild._ignorePasswordEdit(); + } + break; + } + + docState.storeUserInput(field); + let detail = { + possibleValues: { + usernames: docState.possibleUsernames, + passwords: docState.possiblePasswords, + }, + }; + loginManagerChild.sendAsyncMessage( + "PasswordManager:updateDoorhangerSuggestions", + detail + ); + + if (field.hasBeenTypePassword) { + let triggeredByFillingGenerated = + docState.generatedPasswordFields.has(field); + // Autosave generated password initial fills and subsequent edits + if (triggeredByFillingGenerated) { + loginManagerChild._passwordEditedOrGenerated(field, { + triggeredByFillingGenerated, + }); + } else { + // Send a notification that we are not saving the edit to the password field. + // This is used only for testing. + loginManagerChild._ignorePasswordEdit(); + } + } + break; + } + + case "input": { + let isPasswordType = lazy.LoginHelper.isPasswordFieldType(field); + // React to input into fields filled with generated passwords. + if ( + docState.generatedPasswordFields.has(field) && + // Depending on the edit, we may no longer want to consider + // the field a generated password field to avoid autosaving. + loginManagerChild._doesEventClearPrevFieldValue(aEvent) + ) { + docState._stopTreatingAsGeneratedPasswordField(field); + } + + if (!isPasswordType && !lazy.LoginHelper.isUsernameFieldType(field)) { + break; + } + + // React to input into potential username or password fields + let formLikeRoot = lazy.FormLikeFactory.findRootForField(field); + + if (formLikeRoot !== aEvent.currentTarget) { + break; + } + // flag this form as user-modified for the closest form/root ancestor + let alreadyModified = + docState.fieldModificationsByRootElement.get(formLikeRoot); + let { login: filledLogin, userTriggered: fillWasUserTriggered } = + docState.fillsByRootElement.get(formLikeRoot) || {}; + + // don't flag as user-modified if the form was autofilled and doesn't appear to have changed + let isAutofillInput = filledLogin && !fillWasUserTriggered; + if (!alreadyModified && isAutofillInput) { + if (isPasswordType && filledLogin.password == field.value) { + lazy.log( + "Ignoring password input event that doesn't change autofilled values." + ); + break; + } + if ( + !isPasswordType && + filledLogin.usernameField && + filledLogin.username == field.value + ) { + lazy.log( + "Ignoring username input event that doesn't change autofilled values." + ); + break; + } + } + docState.fieldModificationsByRootElement.set(formLikeRoot, true); + // Keep track of the modified formless password field to trigger form submission + // when it is removed from DOM. + let alreadyModifiedFormLessField = true; + if (!HTMLFormElement.isInstance(formLikeRoot)) { + alreadyModifiedFormLessField = + docState.formlessModifiedPasswordFields.has(field); + if (!alreadyModifiedFormLessField) { + docState.formlessModifiedPasswordFields.add(field); + } + } + + // Infer form submission only when there has been an user interaction on the form + // or the formless password field. + if ( + lazy.LoginHelper.formRemovalCaptureEnabled && + (!alreadyModified || !alreadyModifiedFormLessField) + ) { + ownerDocument.setNotifyFetchSuccess(true); + } + + if ( + // When the password field value is cleared or entirely replaced we don't treat it as + // an autofilled form any more. We don't do the same for username edits to avoid snooping + // on the autofilled password in the resulting doorhanger + isPasswordType && + loginManagerChild._doesEventClearPrevFieldValue(aEvent) && + // Don't clear last recorded autofill if THIS is an autofilled value. This will be true + // when filling from the context menu. + filledLogin && + filledLogin.password !== field.value + ) { + docState.fillsByRootElement.delete(formLikeRoot); + } + + if (!lazy.LoginHelper.passwordEditCaptureEnabled) { + break; + } + if (field.hasBeenTypePassword) { + // When a field is filled with a generated password, we also fill a confirm password field + // if found. To do this, _fillConfirmFieldWithGeneratedPassword calls setUserInput, which fires + // an "input" event on the confirm password field. compareAndUpdatePreviouslySentValues will + // allow that message through due to triggeredByFillingGenerated, so early return here. + let form = lazy.LoginFormFactory.createFromField(field); + if ( + docState.generatedPasswordFields.has(field) && + docState._getFormFields(form).confirmPasswordField === field + ) { + break; + } + // Don't check for triggeredByFillingGenerated, as we do not want to autosave + // a field marked as a generated password field on every "input" event + loginManagerChild._passwordEditedOrGenerated(field); + } else { + let [usernameField, passwordField] = + docState.getUserNameAndPasswordFields(field); + if (field == usernameField && passwordField?.value) { + loginManagerChild._passwordEditedOrGenerated(passwordField, { + triggeredByFillingGenerated: + docState.generatedPasswordFields.has(passwordField), + }); + } + } + break; + } + + case "keydown": { + if ( + field.value && + (aEvent.keyCode == aEvent.DOM_VK_TAB || + aEvent.keyCode == aEvent.DOM_VK_RETURN) + ) { + const autofillForm = + lazy.LoginHelper.autofillForms && + !PrivateBrowsingUtils.isContentWindowPrivate( + ownerDocument.defaultView + ); + + if (autofillForm) { + loginManagerChild.onUsernameAutocompleted(field); + } + } + break; + } + + case "focus": { + //@sg see if we can drop focusedField (aEvent.target) and use field (aEvent.composedTarget) + docState.onFocus(field, aEvent.target); + break; + } + + case "mousedown": { + if (aEvent.button == 2) { + // Date.now() is used instead of event.timeStamp since + // dom.event.highrestimestamp.enabled isn't true on all channels yet. + gLastRightClickTimeStamp = Date.now(); + } + + break; + } + + default: { + throw new Error("Unexpected event"); + } + } + }, +}; + +// Add this observer once for the process. +Services.obs.addObserver(observer, "autocomplete-did-enter-text"); + +/** + * Form scenario defines what can be done with form. + */ +class FormScenario {} + +/** + * Sign up scenario defines typical account registration flow. + */ +class SignUpFormScenario extends FormScenario { + usernameField; + passwordField; +} + +/** + * Logic of Capture and Filling. + * + * This class will be shared with Firefox iOS and should have no references to + * Gecko internals. See Bug 1774208. + */ +export class LoginFormState { + /** + * Keeps track of filled fields and values. + */ + fillsByRootElement = new WeakMap(); + /** + * Keeps track of fields we've filled with generated passwords + */ + generatedPasswordFields = new WeakFieldSet(); + /** + * Keeps track of logins that were last submitted. + */ + lastSubmittedValuesByRootElement = new WeakMap(); + fieldModificationsByRootElement = new WeakMap(); + /** + * Anything entered into an that we think might be a username + */ + possibleUsernames = new Set(); + /** + * Anything entered into an that we think might be a password + */ + possiblePasswords = new Set(); + + /** + * Keeps track of the formLike of nodes (form or formless password field) + * that we are watching when they are removed from DOM. + */ + formLikeByObservedNode = new WeakMap(); + + /** + * Keeps track of all formless password fields that have been + * updated by the user. + */ + formlessModifiedPasswordFields = new WeakFieldSet(); + + /** + * Caches the results of the username heuristics + */ + #cachedIsInferredUsernameField = new WeakMap(); + #cachedIsInferredEmailField = new WeakMap(); + #cachedIsInferredLoginForm = new WeakMap(); + + /** + * Caches the scores when running the SignUpFormRuleset against a form + */ + #cachedSignUpFormScore = new WeakMap(); + + /** + * Records the mock username field when its associated form is submitted. + */ + mockUsernameOnlyField = null; + + /** + * Records the number of possible username event received for this document. + */ + numFormHasPossibleUsernameEvent = 0; + + captureLoginTimeStamp = 0; + + // Scenarios detected on this page + #scenariosByRoot = new WeakMap(); + + getScenario(inputElement) { + const formLikeRoot = lazy.FormLikeFactory.findRootForField(inputElement); + return this.#scenariosByRoot.get(formLikeRoot); + } + + setScenario(formLikeRoot, scenario) { + this.#scenariosByRoot.set(formLikeRoot, scenario); + } + + storeUserInput(field) { + if (field.value && lazy.LoginHelper.captureInputChanges) { + if (lazy.LoginHelper.isPasswordFieldType(field)) { + this.possiblePasswords.add(field.value); + } else if (lazy.LoginHelper.isUsernameFieldType(field)) { + this.possibleUsernames.add(field.value); + } + } + } + + /** + * Returns true if the input field is considered an email field by + * 'LoginHelper.isInferredEmailField'. + * + * @param {Element} element the field to check. + * @returns {boolean} True if the element is likely an email field + */ + isProbablyAnEmailField(inputElement) { + if (!inputElement) { + return false; + } + + let result = this.#cachedIsInferredEmailField.get(inputElement); + if (result === undefined) { + result = lazy.LoginHelper.isInferredEmailField(inputElement); + this.#cachedIsInferredEmailField.set(inputElement, result); + } + + return result; + } + + /** + * Returns true if the input field is considered a username field by + * 'LoginHelper.isInferredUsernameField'. The main purpose of this method + * is to cache the result because _getFormFields has many call sites and we + * want to avoid applying the heuristic every time. + * + * @param {Element} element the field to check. + * @returns {boolean} True if the element is likely a username field + */ + isProbablyAUsernameField(inputElement) { + let result = this.#cachedIsInferredUsernameField.get(inputElement); + if (result === undefined) { + result = lazy.LoginHelper.isInferredUsernameField(inputElement); + this.#cachedIsInferredUsernameField.set(inputElement, result); + } + + return result; + } + + /** + * Returns true if the form is considered a username login form if + * 1. The input element looks like a username field or the form looks + * like a login form + * 2. The input field doesn't match keywords that indicate the username + * is not used for login (ex, search) or the login form is not use + * a username to sign-in (ex, authentication code) + * + * @param {Element} element the form to check. + * @returns {boolean} True if the element is likely a login form + */ + #isProbablyAUsernameLoginForm(formElement, inputElement) { + let result = this.#cachedIsInferredLoginForm.get(formElement); + if (result === undefined) { + // We should revisit these rules after we collect more positive or negative + // cases for username-only forms. Right now, if-else-based rules are good + // enough to cover the sites we know, but if we find out defining "weight" for each + // rule is necessary to improve the heuristic, we should consider switching + // this with Fathom. + + result = false; + // Check whether the input field looks like a username field or the + // form looks like a sign-in or sign-up form. + if ( + this.isProbablyAUsernameField(inputElement) || + lazy.LoginHelper.isInferredLoginForm(formElement) + ) { + // This is where we collect hints that indicate this is not a username + // login form. + if (!lazy.LoginHelper.isInferredNonUsernameField(inputElement)) { + result = true; + } + } + this.#cachedIsInferredLoginForm.set(formElement, result); + } + + return result; + } + + /** + * Determine if the form is a sign-up form. + * This is done by running the rules of the Fathom SignUpFormRuleset against the form and calucating a score between 0 and 1. + * It's considered a sign-up form, if the score is higher than the confidence threshold (default=0.75) + * + * @param {HTMLFormElement} formElement + * @returns {boolean} returns true if the calculcated score is higher than the confidenceThreshold + */ + isProbablyASignUpForm(formElement) { + if (!HTMLFormElement.isInstance(formElement)) { + return false; + } + const threshold = lazy.LoginHelper.signupDetectionConfidenceThreshold; + let score = this.#cachedSignUpFormScore.get(formElement); + if (!score) { + TelemetryStopwatch.start("PWMGR_SIGNUP_FORM_DETECTION_MS"); + try { + const { rules, type } = lazy.SignUpFormRuleset; + const results = rules.against(formElement); + score = results.get(formElement).scoreFor(type); + TelemetryStopwatch.finish("PWMGR_SIGNUP_FORM_DETECTION_MS"); + } finally { + if (TelemetryStopwatch.running("PWMGR_SIGNUP_FORM_DETECTION_MS")) { + TelemetryStopwatch.cancel("PWMGR_SIGNUP_FORM_DETECTION_MS"); + } + } + this.#cachedSignUpFormScore.set(formElement, score); + } + return score > threshold; + } + + /** + * Given a field, determine whether that field was last filled as a username + * field AND whether the username is still filled in with the username AND + * whether the associated password field has the matching password. + * + * @note This could possibly be unified with getFieldContext but they have + * slightly different use cases. getFieldContext looks up recipes whereas this + * method doesn't need to since it's only returning a boolean based upon the + * recipes used for the last fill (in _fillForm). + * + * @param {HTMLInputElement} aUsernameField element contained in a LoginForm + * cached in LoginFormFactory. + * @returns {Boolean} whether the username and password fields still have the + * last-filled values, if previously filled. + */ + #isLoginAlreadyFilled(aUsernameField) { + let formLikeRoot = lazy.FormLikeFactory.findRootForField(aUsernameField); + // Look for the existing LoginForm. + let existingLoginForm = + lazy.LoginFormFactory.getForRootElement(formLikeRoot); + if (!existingLoginForm) { + throw new Error( + "#isLoginAlreadyFilled called with a username field with " + + "no rootElement LoginForm" + ); + } + + let { login: filledLogin } = + this.fillsByRootElement.get(formLikeRoot) || {}; + if (!filledLogin) { + return false; + } + + // Unpack the weak references. + let autoFilledUsernameField = filledLogin.usernameField?.get(); + let autoFilledPasswordField = filledLogin.passwordField?.get(); + + // Check username and password values match what was filled. + if ( + !autoFilledUsernameField || + autoFilledUsernameField != aUsernameField || + autoFilledUsernameField.value != filledLogin.username || + (autoFilledPasswordField && + autoFilledPasswordField.value != filledLogin.password) + ) { + return false; + } + + return true; + } + + _togglePasswordFieldMasking(passwordField, unmask) { + let { editor } = passwordField; + + if (passwordField.type != "password") { + // The type may have been changed by the website. + lazy.log("Field isn't type=password."); + return; + } + + if (!unmask && !editor) { + // It hasn't been created yet but the default is to be masked anyways. + return; + } + + if (unmask) { + editor.unmask(0); + return; + } + + if (editor.autoMaskingEnabled) { + return; + } + editor.mask(); + } + + /** + * Track a form field as has having been filled with a generated password. This adds explicit + * focus & blur handling to unmask & mask the value, and enables special handling of edits to + * generated password values (see the observer's input event handler.) + * + * @param {HTMLInputElement} passwordField + */ + _treatAsGeneratedPasswordField(passwordField) { + this.generatedPasswordFields.add(passwordField); + + // blur/focus: listen for focus changes to we can mask/unmask generated passwords + for (let eventType of ["blur", "focus"]) { + passwordField.addEventListener(eventType, observer, { + capture: true, + mozSystemGroup: true, + }); + } + if (passwordField.ownerDocument.activeElement == passwordField) { + // Unmask the password field + this._togglePasswordFieldMasking(passwordField, true); + } + } + + _formHasModifiedFields(form) { + const doc = form.rootElement.ownerDocument; + let userHasInteracted; + const testOnlyUserHasInteracted = + lazy.LoginHelper.testOnlyUserHasInteractedWithDocument; + if (Cu.isInAutomation && testOnlyUserHasInteracted !== null) { + userHasInteracted = testOnlyUserHasInteracted; + } else { + userHasInteracted = + !lazy.LoginHelper.userInputRequiredToCapture || + this.captureLoginTimeStamp != doc.lastUserGestureTimeStamp; + } + + lazy.log( + `_formHasModifiedFields: userHasInteracted: ${userHasInteracted}.` + ); + + // Skip if user didn't interact with the page since last call or ever + if (!userHasInteracted) { + return false; + } + + // check for user inputs to the form fields + let fieldsModified = this.fieldModificationsByRootElement.get( + form.rootElement + ); + // also consider a form modified if there's a difference between fields' .value and .defaultValue + if (!fieldsModified) { + fieldsModified = Array.from(form.elements).some( + field => + field.defaultValue !== undefined && field.value !== field.defaultValue + ); + } + return fieldsModified; + } + + _stopTreatingAsGeneratedPasswordField(passwordField) { + this.generatedPasswordFields.delete(passwordField); + + // Remove all the event listeners added in _passwordEditedOrGenerated + for (let eventType of ["blur", "focus"]) { + passwordField.removeEventListener(eventType, observer, { + capture: true, + mozSystemGroup: true, + }); + } + + // Mask the password field + this._togglePasswordFieldMasking(passwordField, false); + } + + onFocus(field, focusedField) { + if (field.hasBeenTypePassword && this.generatedPasswordFields.has(field)) { + // Used to unmask fields with filled generated passwords when focused. + this._togglePasswordFieldMasking(field, true); + return; + } + + // Only used for username fields. + this.#onUsernameFocus(focusedField); + } + + /** + * Focus event handler for username fields to decide whether to show autocomplete. + * @param {HTMLInputElement} focusedField + */ + #onUsernameFocus(focusedField) { + if ( + !focusedField.mozIsTextField(true) || + focusedField.hasBeenTypePassword || + focusedField.readOnly + ) { + return; + } + + if (this.#isLoginAlreadyFilled(focusedField)) { + lazy.log("Login already filled."); + return; + } + + /* + * A `mousedown` event is fired before the `focus` event if the user right clicks into an + * unfocused field. In that case we don't want to show both autocomplete and a context menu + * overlapping so we check against the timestamp that was set by the `mousedown` event if the + * button code indicated a right click. + * We use a timestamp instead of a bool to avoid complexity when dealing with multiple input + * forms and the fact that a mousedown into an already focused field does not trigger another focus. + * Date.now() is used instead of event.timeStamp since dom.event.highrestimestamp.enabled isn't + * true on all channels yet. + */ + let timeDiff = Date.now() - gLastRightClickTimeStamp; + if (timeDiff < AUTOCOMPLETE_AFTER_RIGHT_CLICK_THRESHOLD_MS) { + lazy.log( + `Not opening autocomplete after focus since a context menu was opened within ${timeDiff}ms.` + ); + return; + } + + lazy.log("Opening the autocomplete popup."); + lazy.gFormFillService.showPopup(); + } + + /** Remove login field highlight when its value is cleared or overwritten. + */ + static #removeFillFieldHighlight(event) { + let winUtils = event.target.ownerGlobal.windowUtils; + winUtils.removeManuallyManagedState(event.target, AUTOFILL_STATE); + } + + /** + * Highlight login fields on autocomplete or autofill on page load. + * @param {Node} element that needs highlighting. + */ + static _highlightFilledField(element) { + let winUtils = element.ownerGlobal.windowUtils; + + winUtils.addManuallyManagedState(element, AUTOFILL_STATE); + // Remove highlighting when the field is changed. + element.addEventListener( + "input", + LoginFormState.#removeFillFieldHighlight, + { + mozSystemGroup: true, + once: true, + } + ); + } + + /** + * Returns the username field of the passed form if the form is a + * username-only form. + * A form is considered a username-only form only if it meets all the + * following conditions: + * 1. Does not have any password field, + * 2. Only contains one input field whose type is username compatible. + * 3. The username compatible input field looks like a username field + * or the form itself looks like a sign-in or sign-up form. + * + * @param {Element} formElement + * the form to check. + * @param {Object} recipe=null + * A relevant field override recipe to use. + * @returns {Element} The username field or null (if the form is not a + * username-only form). + */ + getUsernameFieldFromUsernameOnlyForm(formElement, recipe = null) { + if (!HTMLFormElement.isInstance(formElement)) { + return null; + } + + let candidate = null; + for (let element of formElement.elements) { + // We are looking for a username-only form, so if there is a password + // field in the form, this is NOT a username-only form. + if (element.hasBeenTypePassword) { + return null; + } + + // Ignore input fields whose type are not username compatiable, ex, hidden. + if (!lazy.LoginHelper.isUsernameFieldType(element)) { + continue; + } + + if ( + recipe?.notUsernameSelector && + element.matches(recipe.notUsernameSelector) + ) { + continue; + } + + // If there are more than two input fields whose type is username + // compatiable, this is NOT a username-only form. + if (candidate) { + return null; + } + candidate = element; + } + + if ( + candidate && + this.#isProbablyAUsernameLoginForm(formElement, candidate) + ) { + return candidate; + } + + return null; + } + + /** + * @param {LoginForm} form - the LoginForm to look for password fields in. + * @param {Object} options + * @param {bool} [options.skipEmptyFields=false] - Whether to ignore password fields with no value. + * Used at capture time since saving empty values isn't + * useful. + * @param {Object} [options.fieldOverrideRecipe=null] - A relevant field override recipe to use. + * @return {Array|null} Array of password field elements for the specified form. + * If no pw fields are found, or if more than 5 are found, then null + * is returned. + */ + static _getPasswordFields( + form, + { + fieldOverrideRecipe = null, + minPasswordLength = 0, + ignoreConnect = false, + } = {} + ) { + // Locate the password fields in the form. + let pwFields = []; + for (let i = 0; i < form.elements.length; i++) { + let element = form.elements[i]; + if ( + !HTMLInputElement.isInstance(element) || + !element.hasBeenTypePassword || + (!element.isConnected && !ignoreConnect) + ) { + continue; + } + + // Exclude ones matching a `notPasswordSelector`, if specified. + if ( + fieldOverrideRecipe?.notPasswordSelector && + element.matches(fieldOverrideRecipe.notPasswordSelector) + ) { + lazy.log( + `Skipping password field with id: ${element.id}, name: ${element.name} due to recipe ${fieldOverrideRecipe}.` + ); + continue; + } + + // XXX: Bug 780449 tracks our handling of emoji and multi-code-point characters in + // password fields. To avoid surprises, we should be consistent with the visual + // representation of the masked password + if ( + minPasswordLength && + element.value.trim().length < minPasswordLength + ) { + lazy.log( + `Skipping password field with id: ${element.id}, name: ${element.name} as value is too short.` + ); + continue; // Ignore empty or too-short passwords fields + } + + pwFields[pwFields.length] = { + index: i, + element, + }; + } + + // If too few or too many fields, bail out. + if (!pwFields.length) { + lazy.log("Form ignored, no password fields."); + return null; + } + + if (pwFields.length > 5) { + lazy.log(`Form ignored, too many password fields: ${pwFields.length}.`); + return null; + } + + return pwFields; + } + + /** + * Stores passed arguments, and returns whether or not they match the args given the last time + * this method was called with the same [formLikeRoot]. This is used to avoid sending duplicate + * messages to the parent. + * + * @param {Element} formLikeRoot + * @param {string} usernameValue + * @param {string} passwordValue + * @param {boolean?} [dismissed=false] + * @param {boolean?} [triggeredByFillingGenerated=false] whether or not this call was triggered by a generated + * password being filled into a form-like element. + * + * @returns {boolean} true if args match the most recently passed values + */ + compareAndUpdatePreviouslySentValues( + formLikeRoot, + usernameValue, + passwordValue, + dismissed = false, + triggeredByFillingGenerated = false + ) { + const lastSentValues = + this.lastSubmittedValuesByRootElement.get(formLikeRoot); + if (lastSentValues) { + if (dismissed && !lastSentValues.dismissed) { + // preserve previous dismissed value if it was false (i.e. shown/open) + dismissed = false; + } + if ( + lastSentValues.username == usernameValue && + lastSentValues.password == passwordValue && + lastSentValues.dismissed == dismissed && + lastSentValues.triggeredByFillingGenerated == + triggeredByFillingGenerated + ) { + lazy.log( + "compareAndUpdatePreviouslySentValues: values are equivalent, returning true." + ); + return true; + } + } + + // Save the last submitted values so we don't prompt twice for the same values using + // different capture methods e.g. a form submit event and upon navigation. + this.lastSubmittedValuesByRootElement.set(formLikeRoot, { + username: usernameValue, + password: passwordValue, + dismissed, + triggeredByFillingGenerated, + }); + lazy.log( + "compareAndUpdatePreviouslySentValues: values not equivalent, returning false." + ); + return false; + } + + fillConfirmFieldWithGeneratedPassword(passwordField) { + // Fill a nearby password input if it looks like a confirm-password field + let form = lazy.LoginFormFactory.createFromField(passwordField); + let confirmPasswordInput = null; + // The confirm-password field shouldn't be more than 3 form elements away from the password field we filled + let MAX_CONFIRM_PASSWORD_DISTANCE = 3; + + let startIndex = form.elements.indexOf(passwordField); + if (startIndex == -1) { + throw new Error( + "Password field is not in the form's elements collection" + ); + } + + // If we've already filled another field with a generated password, + // this might be the confirm-password field, so don't try and find another + let previousGeneratedPasswordField = form.elements.some( + inp => inp !== passwordField && this.generatedPasswordFields.has(inp) + ); + if (previousGeneratedPasswordField) { + lazy.log("Previously-filled generated password input found."); + return; + } + + // Get a list of input fields to search in. + // Pre-filter type=hidden fields; they don't count against the distance threshold + let afterFields = form.elements + .slice(startIndex + 1) + .filter(elem => elem.type !== "hidden"); + + let acFieldName = passwordField.getAutocompleteInfo()?.fieldName; + + // Match same autocomplete values first + if (acFieldName == "new-password") { + let matchIndex = afterFields.findIndex( + elem => + lazy.LoginHelper.isPasswordFieldType(elem) && + elem.getAutocompleteInfo().fieldName == acFieldName && + !elem.disabled && + !elem.readOnly + ); + if (matchIndex >= 0 && matchIndex < MAX_CONFIRM_PASSWORD_DISTANCE) { + confirmPasswordInput = afterFields[matchIndex]; + } + } + if (!confirmPasswordInput) { + for ( + let idx = 0; + idx < Math.min(MAX_CONFIRM_PASSWORD_DISTANCE, afterFields.length); + idx++ + ) { + if ( + lazy.LoginHelper.isPasswordFieldType(afterFields[idx]) && + !afterFields[idx].disabled && + !afterFields[idx].readOnly + ) { + confirmPasswordInput = afterFields[idx]; + break; + } + } + } + if (confirmPasswordInput && !confirmPasswordInput.value) { + this._treatAsGeneratedPasswordField(confirmPasswordInput); + confirmPasswordInput.setUserInput(passwordField.value); + LoginFormState._highlightFilledField(confirmPasswordInput); + } + } + + /** + * Returns the username and password fields found in the form. + * Can handle complex forms by trying to figure out what the + * relevant fields are. + * + * @param {LoginForm} form + * @param {bool} isSubmission + * @param {Set} recipes + * @param {Object} options + * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected + * of the element. + * @return {Object} {usernameField, newPasswordField, oldPasswordField, confirmPasswordField} + * + * usernameField may be null. + * newPasswordField may be null. If null, this is a username-only form. + * oldPasswordField may be null. If null, newPasswordField is just + * "theLoginField". If not null, the form is apparently a + * change-password field, with oldPasswordField containing the password + * that is being changed. + * + * Note that even though we can create a LoginForm from a text field, + * this method will only return a non-null usernameField if the + * LoginForm has a password field. + */ + _getFormFields(form, isSubmission, recipes, { ignoreConnect = false } = {}) { + let usernameField = null; + let newPasswordField = null; + let oldPasswordField = null; + let confirmPasswordField = null; + let emptyResult = { + usernameField: null, + newPasswordField: null, + oldPasswordField: null, + confirmPasswordField: null, + }; + + let pwFields = null; + let fieldOverrideRecipe = lazy.LoginRecipesContent.getFieldOverrides( + recipes, + form + ); + if (fieldOverrideRecipe) { + lazy.log("fieldOverrideRecipe found ", fieldOverrideRecipe); + let pwOverrideField = lazy.LoginRecipesContent.queryLoginField( + form, + fieldOverrideRecipe.passwordSelector + ); + if (pwOverrideField) { + lazy.log("pwOverrideField found ", pwOverrideField); + // The field from the password override may be in a different LoginForm. + let formLike = lazy.LoginFormFactory.createFromField(pwOverrideField); + pwFields = [ + { + index: [...formLike.elements].indexOf(pwOverrideField), + element: pwOverrideField, + }, + ]; + } + + let usernameOverrideField = lazy.LoginRecipesContent.queryLoginField( + form, + fieldOverrideRecipe.usernameSelector + ); + if (usernameOverrideField) { + usernameField = usernameOverrideField; + } + } + + if (!pwFields) { + // Locate the password field(s) in the form. Up to 3 supported. + // If there's no password field, there's nothing for us to do. + const minSubmitPasswordLength = 2; + pwFields = LoginFormState._getPasswordFields(form, { + fieldOverrideRecipe, + minPasswordLength: isSubmission ? minSubmitPasswordLength : 0, + ignoreConnect, + }); + } + + // Check whether this is a username-only form when the form doesn't have + // a password field. Note that recipes are not supported in username-only + // forms currently (Bug 1708455). + if (!pwFields) { + if (!lazy.LoginHelper.usernameOnlyFormEnabled) { + return emptyResult; + } + + usernameField = this.getUsernameFieldFromUsernameOnlyForm( + form.rootElement, + fieldOverrideRecipe + ); + + if (usernameField) { + lazy.log(`Found username field with name: ${usernameField.name}.`); + } + + return { + ...emptyResult, + usernameField, + }; + } + + if (!usernameField) { + // Searching backwards from the first password field until we find a field + // that looks like a "username" field. If no "username" field is found, + // consider an email-like field a username field, if any. + // If neither a username-like or an email-like field exists, assume the + // first text field before the password field is the username. + // We might not find a username field if the user is already logged in to the site. + // + // Note: We only search fields precede the first password field because we + // don't see sites putting a username field after a password field. We can + // extend searching to all fields in the form if this turns out not to be the case. + + for (let i = pwFields[0].index - 1; i >= 0; i--) { + let element = form.elements[i]; + if (!lazy.LoginHelper.isUsernameFieldType(element, { ignoreConnect })) { + continue; + } + + if ( + fieldOverrideRecipe?.notUsernameSelector && + element.matches(fieldOverrideRecipe.notUsernameSelector) + ) { + continue; + } + + // Assume the first text field is the username by default. + // It will be replaced if we find a likely username field afterward. + if (!usernameField) { + usernameField = element; + } + + if (this.isProbablyAUsernameField(element)) { + // An username field is found, we are done. + usernameField = element; + break; + } else if (this.isProbablyAnEmailField(element)) { + // An email field is found, consider it a username field but continue + // to search for an "username" field. + // In current implementation, if another email field is found during + // the process, we will use the new one. + usernameField = element; + } + } + } + + if (!usernameField) { + lazy.log("No username field found."); + } else { + lazy.log(`Found username field with name: ${usernameField.name}.`); + } + + let pwGeneratedFields = pwFields.filter(pwField => + this.generatedPasswordFields.has(pwField.element) + ); + if (pwGeneratedFields.length) { + // we have at least the newPasswordField + [newPasswordField, confirmPasswordField] = pwGeneratedFields.map( + pwField => pwField.element + ); + // if the user filled a field with a generated password, + // a field immediately previous to that is most likely the old password field + let idx = pwFields.findIndex( + pwField => pwField.element === newPasswordField + ); + if (idx > 0) { + oldPasswordField = pwFields[idx - 1].element; + } + return { + ...emptyResult, + usernameField, + newPasswordField, + oldPasswordField: oldPasswordField || null, + confirmPasswordField: confirmPasswordField || null, + }; + } + + // If we're not submitting a form (it's a page load), there are no + // password field values for us to use for identifying fields. So, + // just assume the first password field is the one to be filled in. + if (!isSubmission || pwFields.length == 1) { + let passwordField = pwFields[0].element; + lazy.log(`Found Password field with name: ${passwordField.name}.`); + return { + ...emptyResult, + usernameField, + newPasswordField: passwordField, + oldPasswordField: null, + }; + } + + // We're looking for both new and old password field + // Try to figure out what is in the form based on the password values. + let pw1 = pwFields[0].element.value; + let pw2 = pwFields[1] ? pwFields[1].element.value : null; + let pw3 = pwFields[2] ? pwFields[2].element.value : null; + + if (pwFields.length == 3) { + // Look for two identical passwords, that's the new password + + if (pw1 == pw2 && pw2 == pw3) { + // All 3 passwords the same? Weird! Treat as if 1 pw field. + newPasswordField = pwFields[0].element; + oldPasswordField = null; + } else if (pw1 == pw2) { + newPasswordField = pwFields[0].element; + oldPasswordField = pwFields[2].element; + } else if (pw2 == pw3) { + oldPasswordField = pwFields[0].element; + newPasswordField = pwFields[2].element; + } else if (pw1 == pw3) { + // A bit odd, but could make sense with the right page layout. + newPasswordField = pwFields[0].element; + oldPasswordField = pwFields[1].element; + } else { + // We can't tell which of the 3 passwords should be saved. + lazy.log(`Form ignored -- all 3 pw fields differ.`); + return emptyResult; + } + } else if (pw1 == pw2) { + // pwFields.length == 2 + // Treat as if 1 pw field + newPasswordField = pwFields[0].element; + oldPasswordField = null; + } else { + // Just assume that the 2nd password is the new password + oldPasswordField = pwFields[0].element; + newPasswordField = pwFields[1].element; + } + + lazy.log( + `New Password field id: ${newPasswordField.id}, name: ${newPasswordField.name}.` + ); + + lazy.log( + oldPasswordField + ? `Old Password field id: ${oldPasswordField.id}, name: ${oldPasswordField.name}.` + : "No Old password field." + ); + return { + ...emptyResult, + usernameField, + newPasswordField, + oldPasswordField, + }; + } + + /** + * Returns the username and password fields found in the form by input + * element into form. + * + * @param {HTMLInputElement} aField + * A form field + * @return {Array} [usernameField, newPasswordField, oldPasswordField] + * + * Details of these values are the same as _getFormFields. + */ + getUserNameAndPasswordFields(aField) { + const noResult = [null, null, null]; + if (!HTMLInputElement.isInstance(aField)) { + throw new Error("getUserNameAndPasswordFields: input element required"); + } + + if (aField.nodePrincipal.isNullPrincipal || !aField.isConnected) { + return noResult; + } + + // If the element is not a login form field, return all null. + if ( + !aField.hasBeenTypePassword && + !lazy.LoginHelper.isUsernameFieldType(aField) + ) { + return noResult; + } + + const form = lazy.LoginFormFactory.createFromField(aField); + const doc = aField.ownerDocument; + const formOrigin = lazy.LoginHelper.getLoginOrigin(doc.documentURI); + const recipes = lazy.LoginRecipesContent.getRecipes( + formOrigin, + doc.defaultView + ); + const { usernameField, newPasswordField, oldPasswordField } = + this._getFormFields(form, false, recipes); + + return [usernameField, newPasswordField, oldPasswordField]; + } + + /** + * Verify if a field is a valid login form field and + * returns some information about it's LoginForm. + * + * @param {Element} aField + * A form field we want to verify. + * + * @returns {Object} an object with information about the + * LoginForm username and password field + * or null if the passed field is invalid. + */ + getFieldContext(aField) { + // If the element is not a proper form field, return null. + if ( + !HTMLInputElement.isInstance(aField) || + (!aField.hasBeenTypePassword && + !lazy.LoginHelper.isUsernameFieldType(aField)) || + aField.nodePrincipal.isNullPrincipal || + aField.nodePrincipal.schemeIs("about") || + !aField.ownerDocument + ) { + return null; + } + let { hasBeenTypePassword } = aField; + + // This array provides labels that correspond to the return values from + // `getUserNameAndPasswordFields` so we can know which one aField is. + const LOGIN_FIELD_ORDER = ["username", "new-password", "current-password"]; + let usernameAndPasswordFields = this.getUserNameAndPasswordFields(aField); + let fieldNameHint; + let indexOfFieldInUsernameAndPasswordFields = + usernameAndPasswordFields.indexOf(aField); + if (indexOfFieldInUsernameAndPasswordFields == -1) { + // For fields in the form that are neither username nor password, + // set fieldNameHint to "other". Right now, in contextmenu, we treat both + // "username" and "other" field as username fields. + fieldNameHint = hasBeenTypePassword ? "current-password" : "other"; + } else { + fieldNameHint = + LOGIN_FIELD_ORDER[indexOfFieldInUsernameAndPasswordFields]; + } + let [, newPasswordField] = usernameAndPasswordFields; + + return { + activeField: { + disabled: aField.disabled || aField.readOnly, + fieldNameHint, + }, + // `passwordField` may be the same as `activeField`. + passwordField: { + found: !!newPasswordField, + disabled: + newPasswordField && + (newPasswordField.disabled || newPasswordField.readOnly), + }, + }; + } +} + +/** + * Integration with browser and IPC with LoginManagerParent. + * + * NOTE: there are still bits of code here that needs to be moved to + * LoginFormState. + */ +export class LoginManagerChild extends JSWindowActorChild { + /** + * WeakMap of the root element of a LoginForm to the DeferredTask to fill its fields. + * + * This is used to be able to throttle fills for a LoginForm since onDOMInputPasswordAdded gets + * dispatched for each password field added to a document but we only want to fill once per + * LoginForm when multiple fields are added at once. + * + * @type {WeakMap} + */ + #deferredPasswordAddedTasksByRootElement = new WeakMap(); + + /** + * WeakMap of a document to the array of callbacks to execute when it becomes visible + * + * This is used to defer handling DOMFormHasPassword and onDOMInputPasswordAdded events when the + * containing document is hidden. + * When the document first becomes visible, any queued events will be handled as normal. + * + * @type {WeakMap} + */ + #visibleTasksByDocument = new WeakMap(); + + /** + * Maps all DOM content documents in this content process, including those in + * frames, to the current state used by the Login Manager. + */ + #loginFormStateByDocument = new WeakMap(); + + /** + * Set of fields where the user specifically requested password generation + * (from the context menu) even if we wouldn't offer it on this field by default. + */ + #fieldsWithPasswordGenerationForcedOn = new WeakSet(); + + static forWindow(window) { + return window.windowGlobalChild?.getActor("LoginManager"); + } + + receiveMessage(msg) { + switch (msg.name) { + case "PasswordManager:fillForm": { + this.fillForm({ + loginFormOrigin: msg.data.loginFormOrigin, + loginsFound: lazy.LoginHelper.vanillaObjectsToLogins(msg.data.logins), + recipes: msg.data.recipes, + inputElementIdentifier: msg.data.inputElementIdentifier, + originMatches: msg.data.originMatches, + style: msg.data.style, + }); + break; + } + case "PasswordManager:useGeneratedPassword": { + this.#onUseGeneratedPassword(msg.data.inputElementIdentifier); + break; + } + case "PasswordManager:repopulateAutocompletePopup": { + this.repopulateAutocompletePopup(); + break; + } + case "PasswordManager:formIsPending": { + return this.#visibleTasksByDocument.has(this.document); + } + case "PasswordManager:formProcessed": { + this.notifyObserversOfFormProcessed(msg.data.formid); + break; + } + } + + return undefined; + } + + #onUseGeneratedPassword(inputElementIdentifier) { + let inputElement = lazy.ContentDOMReference.resolve(inputElementIdentifier); + if (!inputElement) { + lazy.log("Could not resolve inputElementIdentifier to a living element."); + return; + } + + if (inputElement != lazy.gFormFillService.focusedInput) { + lazy.log("Could not open popup on input that's no longer focused."); + return; + } + + this.#fieldsWithPasswordGenerationForcedOn.add(inputElement); + this.repopulateAutocompletePopup(); + } + + repopulateAutocompletePopup() { + // Clear the cache of previous autocomplete results to show new options. + lazy.gFormFillService.QueryInterface(Ci.nsIAutoCompleteInput); + lazy.gFormFillService.controller.resetInternalState(); + lazy.gFormFillService.showPopup(); + } + + shouldIgnoreLoginManagerEvent(event) { + let nodePrincipal = event.target.nodePrincipal; + // If we have a system or null principal then prevent any more password manager code from running and + // incorrectly using the document `location`. Also skip password manager for about: pages. + return ( + nodePrincipal.isSystemPrincipal || + nodePrincipal.isNullPrincipal || + nodePrincipal.schemeIs("about") + ); + } + + handleEvent(event) { + if ( + AppConstants.platform == "android" && + Services.prefs.getBoolPref("reftest.remote", false) + ) { + // XXX known incompatibility between reftest harness and form-fill. Is this still needed? + return; + } + + if (this.shouldIgnoreLoginManagerEvent(event)) { + return; + } + + switch (event.type) { + case "DOMDocFetchSuccess": { + this.#onDOMDocFetchSuccess(event); + break; + } + case "DOMFormBeforeSubmit": { + this.#onDOMFormBeforeSubmit(event); + break; + } + case "DOMFormHasPassword": { + this.#onDOMFormHasPassword(event, this.document.defaultView); + let formLike = lazy.LoginFormFactory.createFromForm( + event.originalTarget + ); + lazy.InsecurePasswordUtils.reportInsecurePasswords(formLike); + break; + } + case "DOMFormHasPossibleUsername": { + this.#onDOMFormHasPossibleUsername(event); + break; + } + case "DOMFormRemoved": + case "DOMInputPasswordRemoved": { + this.#onDOMFormRemoved(event); + break; + } + case "DOMInputPasswordAdded": { + this.#onDOMInputPasswordAdded(event, this.document.defaultView); + let formLike = lazy.LoginFormFactory.createFromField( + event.originalTarget + ); + lazy.InsecurePasswordUtils.reportInsecurePasswords(formLike); + break; + } + } + } + + notifyObserversOfFormProcessed(formid) { + Services.obs.notifyObservers(this, "passwordmgr-processed-form", formid); + } + + /** + * Get relevant logins and recipes from the parent + * + * @param {HTMLFormElement} form - form to get login data for + * @param {Object} options + * @param {boolean} options.guid - guid of a login to retrieve + * @param {boolean} options.showPrimaryPassword - whether to show a primary password prompt + */ + _getLoginDataFromParent(form, options) { + let actionOrigin = lazy.LoginHelper.getFormActionOrigin(form); + let messageData = { actionOrigin, options }; + let resultPromise = this.sendQuery( + "PasswordManager:findLogins", + messageData + ); + return resultPromise.then(result => { + return { + form, + importable: result.importable, + loginsFound: lazy.LoginHelper.vanillaObjectsToLogins(result.logins), + recipes: result.recipes, + }; + }); + } + + setupProgressListener(window) { + if (!lazy.LoginHelper.formlessCaptureEnabled) { + return; + } + + // Get the highest accessible docshell and attach the progress listener to that. + let docShell; + for ( + let browsingContext = BrowsingContext.getFromWindow(window); + browsingContext?.docShell; + browsingContext = browsingContext.parent + ) { + docShell = browsingContext.docShell; + } + + try { + let webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener( + observer, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | + Ci.nsIWebProgress.NOTIFY_LOCATION + ); + } catch (ex) { + // Ignore NS_ERROR_FAILURE if the progress listener was already added + } + } + + /** + * This method sets up form removal listener for form and password fields that + * users have interacted with. + */ + #onDOMDocFetchSuccess(event) { + let document = event.target; + let docState = this.stateForDocument(document); + let weakModificationsRootElements = + ChromeUtils.nondeterministicGetWeakMapKeys( + docState.fieldModificationsByRootElement + ); + + lazy.log( + `modificationsByRootElement approx size: ${weakModificationsRootElements.length}.` + ); + // Start to listen to form/password removed event after receiving a fetch/xhr + // complete event. + document.setNotifyFormOrPasswordRemoved(true); + this.docShell.chromeEventHandler.addEventListener( + "DOMFormRemoved", + this, + true + ); + this.docShell.chromeEventHandler.addEventListener( + "DOMInputPasswordRemoved", + this, + true + ); + + for (let rootElement of weakModificationsRootElements) { + if (HTMLFormElement.isInstance(rootElement)) { + // If we create formLike when it is removed, we might not have the + // right elements at that point, so create formLike object now. + let formLike = lazy.LoginFormFactory.createFromForm(rootElement); + docState.formLikeByObservedNode.set(rootElement, formLike); + } + } + + let weakFormlessModifiedPasswordFields = + ChromeUtils.nondeterministicGetWeakSetKeys( + docState.formlessModifiedPasswordFields + ); + + lazy.log( + `formlessModifiedPasswordFields approx size: ${weakFormlessModifiedPasswordFields.length}.` + ); + for (let passwordField of weakFormlessModifiedPasswordFields) { + let formLike = lazy.LoginFormFactory.createFromField(passwordField); + // force elements lazy getter being called. + if (formLike.elements.length) { + docState.formLikeByObservedNode.set(passwordField, formLike); + } + } + + // Observers have been setted up, removed the listener. + document.setNotifyFetchSuccess(false); + } + + /* + * Trigger capture when a form/formless password is removed from DOM. + * This method is used to capture logins for cases where form submit events + * are not used. + * + * The heuristic works as follow: + * 1. Set up 'DOMDocFetchSuccess' event listener when users have interacted + * with a form (by calling setNotifyFetchSuccess) + * 2. After receiving `DOMDocFetchSuccess`, set up form removal event listener + * (see onDOMDocFetchSuccess) + * 3. When a form is removed, onDOMFormRemoved triggers the login capture + * code. + */ + #onDOMFormRemoved(event) { + let document = event.composedTarget.ownerDocument; + let docState = this.stateForDocument(document); + let formLike = docState.formLikeByObservedNode.get(event.target); + if (!formLike) { + return; + } + + lazy.log("Form is removed."); + this._onFormSubmit(formLike, SUBMIT_FORM_IS_REMOVED); + + docState.formLikeByObservedNode.delete(event.target); + let weakObserveredNodes = ChromeUtils.nondeterministicGetWeakMapKeys( + docState.formLikeByObservedNode + ); + + if (!weakObserveredNodes.length) { + document.setNotifyFormOrPasswordRemoved(false); + this.docShell.chromeEventHandler.removeEventListener( + "DOMFormRemoved", + this + ); + this.docShell.chromeEventHandler.removeEventListener( + "DOMInputPasswordRemoved", + this + ); + } + } + + #onDOMFormBeforeSubmit(event) { + if (!event.isTrusted) { + return; + } + + // We're invoked before the content's |submit| event handlers, so we + // can grab form data before it might be modified (see bug 257781). + let formLike = lazy.LoginFormFactory.createFromForm(event.target); + this._onFormSubmit(formLike, SUBMIT_FORM_SUBMIT); + } + + onDocumentVisibilityChange(event) { + if (!event.isTrusted) { + return; + } + let document = event.target; + let onVisibleTasks = this.#visibleTasksByDocument.get(document); + if (!onVisibleTasks) { + return; + } + for (let task of onVisibleTasks) { + lazy.log("onDocumentVisibilityChange: executing queued task."); + task(); + } + this.#visibleTasksByDocument.delete(document); + } + + _deferHandlingEventUntilDocumentVisible(event, document, fn) { + lazy.log( + `Defer handling event, document.visibilityState: ${document.visibilityState}, defer handling ${event.type}.` + ); + let onVisibleTasks = this.#visibleTasksByDocument.get(document); + if (!onVisibleTasks) { + lazy.log( + "Defer handling first queued event and register the visibilitychange handler." + ); + onVisibleTasks = []; + this.#visibleTasksByDocument.set(document, onVisibleTasks); + document.addEventListener( + "visibilitychange", + event => { + this.onDocumentVisibilityChange(event); + }, + { once: true } + ); + } + onVisibleTasks.push(fn); + } + + #getIsPrimaryPasswordSet() { + return Services.cpmm.sharedData.get("isPrimaryPasswordSet"); + } + + #onDOMFormHasPassword(event, window) { + if (!event.isTrusted) { + return; + } + + this.setupProgressListener(window); + + const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet(); + let document = event.target.ownerDocument; + + // don't attempt to defer handling when a primary password is set + // Showing the MP modal as soon as possible minimizes its interference with tab interactions + // See bug 1539091 and bug 1538460. + lazy.log( + `#onDOMFormHasPassword: visibilityState: ${document.visibilityState}, isPrimaryPasswordSet: ${isPrimaryPasswordSet}.` + ); + + if (document.visibilityState == "visible" || isPrimaryPasswordSet) { + this._processDOMFormHasPasswordEvent(event); + } else { + // wait until the document becomes visible before handling this event + this._deferHandlingEventUntilDocumentVisible(event, document, () => { + this._processDOMFormHasPasswordEvent(event); + }); + } + } + + _processDOMFormHasPasswordEvent(event) { + let form = event.target; + let formLike = lazy.LoginFormFactory.createFromForm(form); + this._fetchLoginsFromParentAndFillForm(formLike); + } + + #onDOMFormHasPossibleUsername(event) { + if (!event.isTrusted) { + return; + } + const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet(); + let document = event.target.ownerDocument; + + lazy.log( + `#onDOMFormHasPossibleUsername: visibilityState: ${document.visibilityState}, isPrimaryPasswordSet: ${isPrimaryPasswordSet}.` + ); + + // For simplicity, the result of the telemetry is stacked. This means if a + // document receives two `DOMFormHasPossibleEvent`, we add one counter to both + // bucket 1 & 2. + let docState = this.stateForDocument(document); + Services.telemetry + .getHistogramById("PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC") + .add(++docState.numFormHasPossibleUsernameEvent); + + // Infer whether a form is a username-only form is expensive, so we restrict the + // number of form looked up per document. + if ( + docState.numFormHasPossibleUsernameEvent > + lazy.LoginHelper.usernameOnlyFormLookupThreshold + ) { + return; + } + + if (document.visibilityState == "visible" || isPrimaryPasswordSet) { + this._processDOMFormHasPossibleUsernameEvent(event); + } else { + // wait until the document becomes visible before handling this event + this._deferHandlingEventUntilDocumentVisible(event, document, () => { + this._processDOMFormHasPossibleUsernameEvent(event); + }); + } + } + + _processDOMFormHasPossibleUsernameEvent(event) { + let form = event.target; + let formLike = lazy.LoginFormFactory.createFromForm(form); + + // If the form contains a passoword field, `getUsernameFieldFromUsernameOnlyForm` returns + // null, so we don't trigger autofill for those forms here. In this function, + // we only care about username-only forms. For forms contain a password, they'll be handled + // in onDOMFormHasPassword. + + // We specifically set the recipe to empty here to avoid loading site recipes during page loads. + // This is okay because if we end up finding a username-only form that should be ignore by + // the site recipe, the form will be skipped while autofilling later. + let docState = this.stateForDocument(form.ownerDocument); + let usernameField = docState.getUsernameFieldFromUsernameOnlyForm(form, {}); + if (usernameField) { + // Autofill the username-only form. + lazy.log("A username-only form is found."); + this._fetchLoginsFromParentAndFillForm(formLike); + } + + Services.telemetry + .getHistogramById("PWMGR_IS_USERNAME_ONLY_FORM") + .add(!!usernameField); + } + + #onDOMInputPasswordAdded(event, window) { + if (!event.isTrusted) { + return; + } + + this.setupProgressListener(window); + + let pwField = event.originalTarget; + if (pwField.form) { + // Fill is handled by onDOMFormHasPassword which is already throttled. + return; + } + + let document = pwField.ownerDocument; + const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet(); + lazy.log( + `#onDOMInputPasswordAdded, visibilityState: ${document.visibilityState}, isPrimaryPasswordSet: ${isPrimaryPasswordSet}.` + ); + + // don't attempt to defer handling when a primary password is set + // Showing the MP modal as soon as possible minimizes its interference with tab interactions + // See bug 1539091 and bug 1538460. + if (document.visibilityState == "visible" || isPrimaryPasswordSet) { + this._processDOMInputPasswordAddedEvent(event); + } else { + // wait until the document becomes visible before handling this event + this._deferHandlingEventUntilDocumentVisible(event, document, () => { + this._processDOMInputPasswordAddedEvent(event); + }); + } + } + + _processDOMInputPasswordAddedEvent(event) { + let pwField = event.originalTarget; + let formLike = lazy.LoginFormFactory.createFromField(pwField); + + let deferredTask = this.#deferredPasswordAddedTasksByRootElement.get( + formLike.rootElement + ); + if (!deferredTask) { + lazy.log( + "Creating a DeferredTask to call _fetchLoginsFromParentAndFillForm soon." + ); + lazy.LoginFormFactory.setForRootElement(formLike.rootElement, formLike); + + deferredTask = new lazy.DeferredTask( + () => { + // Get the updated LoginForm instead of the one at the time of creating the DeferredTask via + // a closure since it could be stale since LoginForm.elements isn't live. + let formLike2 = lazy.LoginFormFactory.getForRootElement( + formLike.rootElement + ); + lazy.log("Running deferred processing of onDOMInputPasswordAdded."); + this.#deferredPasswordAddedTasksByRootElement.delete( + formLike2.rootElement + ); + this._fetchLoginsFromParentAndFillForm(formLike2); + }, + PASSWORD_INPUT_ADDED_COALESCING_THRESHOLD_MS, + 0 + ); + + this.#deferredPasswordAddedTasksByRootElement.set( + formLike.rootElement, + deferredTask + ); + } + + let window = pwField.ownerGlobal; + if (deferredTask.isArmed) { + lazy.log("DeferredTask is already armed so just updating the LoginForm."); + // We update the LoginForm so it (most important .elements) is fresh when the task eventually + // runs since changes to the elements could affect our field heuristics. + lazy.LoginFormFactory.setForRootElement(formLike.rootElement, formLike); + } else if ( + ["interactive", "complete"].includes(window.document.readyState) + ) { + lazy.log( + "Arming the DeferredTask we just created since document.readyState == 'interactive' or 'complete'." + ); + deferredTask.arm(); + } else { + window.addEventListener( + "DOMContentLoaded", + function () { + lazy.log( + "Arming the onDOMInputPasswordAdded DeferredTask due to DOMContentLoaded." + ); + deferredTask.arm(); + }, + { once: true } + ); + } + } + + /** + * Fetch logins from the parent for a given form and then attempt to fill it. + * + * @param {LoginForm} form to fetch the logins for then try autofill. + */ + _fetchLoginsFromParentAndFillForm(form) { + if (!lazy.LoginHelper.enabled) { + return; + } + + // set up input event listeners so we know if the user has interacted with these fields + // * input: Listen for the field getting blanked (without blurring) or a paste + // * change: Listen for changes to the field filled with the generated password so we can preserve edits. + form.rootElement.addEventListener("input", observer, { + capture: true, + mozSystemGroup: true, + }); + form.rootElement.addEventListener("change", observer, { + capture: true, + mozSystemGroup: true, + }); + + this._getLoginDataFromParent(form, { showPrimaryPassword: true }) + .then(this.loginsFound.bind(this)) + .catch(console.error); + } + + isPasswordGenerationForcedOn(passwordField) { + return this.#fieldsWithPasswordGenerationForcedOn.has(passwordField); + } + + /** + * Retrieves a reference to the state object associated with the given + * document. This is initialized to an object with default values. + */ + stateForDocument(document) { + let loginFormState = this.#loginFormStateByDocument.get(document); + if (!loginFormState) { + loginFormState = new LoginFormState(); + this.#loginFormStateByDocument.set(document, loginFormState); + } + return loginFormState; + } + + /** + * Perform a password fill upon user request coming from the parent process. + * The fill will be in the form previously identified during page navigation. + * + * @param An object with the following properties: + * { + * loginFormOrigin: + * String with the origin for which the login UI was displayed. + * This must match the origin of the form used for the fill. + * loginsFound: + * Array containing the login to fill. While other messages may + * have more logins, for this use case this is expected to have + * exactly one element. The origin of the login may be different + * from the origin of the form used for the fill. + * recipes: + * Fill recipes transmitted together with the original message. + * inputElementIdentifier: + * An identifier generated for the input element via ContentDOMReference. + * originMatches: + * True if the origin of the form matches the page URI. + * } + */ + fillForm({ + loginFormOrigin, + loginsFound, + recipes, + inputElementIdentifier, + originMatches, + style, + }) { + if (!inputElementIdentifier) { + lazy.log("No input element specified."); + return; + } + + let inputElement = lazy.ContentDOMReference.resolve(inputElementIdentifier); + if (!inputElement) { + lazy.log("Could not resolve inputElementIdentifier to a living element."); + return; + } + + if (!originMatches) { + if ( + lazy.LoginHelper.getLoginOrigin( + inputElement.ownerDocument.documentURI + ) != loginFormOrigin + ) { + lazy.log( + "The requested origin doesn't match the one from the", + "document. This may mean we navigated to a document from a different", + "site before we had a chance to indicate this change in the user", + "interface." + ); + return; + } + } + + let clobberUsername = true; + let form = lazy.LoginFormFactory.createFromField(inputElement); + if (inputElement.hasBeenTypePassword) { + clobberUsername = false; + } + + this._fillForm(form, loginsFound, recipes, { + inputElement, + autofillForm: true, + clobberUsername, + clobberPassword: true, + userTriggered: true, + style, + }); + } + + loginsFound({ form, importable, loginsFound, recipes }) { + let doc = form.ownerDocument; + let autofillForm = + lazy.LoginHelper.autofillForms && + !PrivateBrowsingUtils.isContentWindowPrivate(doc.defaultView); + + let formOrigin = lazy.LoginHelper.getLoginOrigin(doc.documentURI); + lazy.LoginRecipesContent.cacheRecipes(formOrigin, doc.defaultView, recipes); + + this._fillForm(form, loginsFound, recipes, { autofillForm, importable }); + } + + /** + * A username or password was autocompleted into a field. + */ + onFieldAutoComplete(acInputField, loginGUID) { + if (!lazy.LoginHelper.enabled) { + return; + } + + // This is probably a bit over-conservatative. + if (!Document.isInstance(acInputField.ownerDocument)) { + return; + } + + if (!lazy.LoginFormFactory.createFromField(acInputField)) { + return; + } + + if (lazy.LoginHelper.isUsernameFieldType(acInputField)) { + this.onUsernameAutocompleted(acInputField, loginGUID); + } else if (acInputField.hasBeenTypePassword) { + // Ensure the field gets re-masked and edits don't overwrite the generated + // password in case a generated password was filled into it previously. + const docState = this.stateForDocument(acInputField.ownerDocument); + docState._stopTreatingAsGeneratedPasswordField(acInputField); + LoginFormState._highlightFilledField(acInputField); + } + } + + /** + * A username field was filled or tabbed away from so try fill in the + * associated password in the password field. + */ + onUsernameAutocompleted(acInputField, loginGUID = null) { + lazy.log(`Autocompleting input field with name: ${acInputField.name}`); + + let acForm = lazy.LoginFormFactory.createFromField(acInputField); + let doc = acForm.ownerDocument; + let formOrigin = lazy.LoginHelper.getLoginOrigin(doc.documentURI); + let recipes = lazy.LoginRecipesContent.getRecipes( + formOrigin, + doc.defaultView + ); + + // Make sure the username field fillForm will use is the + // same field as the autocomplete was activated on. + const docState = this.stateForDocument(acInputField.ownerDocument); + let { usernameField, newPasswordField: passwordField } = + docState._getFormFields(acForm, false, recipes); + if (usernameField == acInputField) { + // Fill the form when a password field is present. + if (passwordField) { + this._getLoginDataFromParent(acForm, { + guid: loginGUID, + showPrimaryPassword: false, + }) + .then(({ form, loginsFound, recipes }) => { + if (!loginGUID) { + // not an explicit autocomplete menu selection, filter for exact matches only + loginsFound = this._filterForExactFormOriginLogins( + loginsFound, + acForm + ); + // filter the list for exact matches with the username + // NOTE: this could be an empty string which is a valid username + let searchString = usernameField.value.toLowerCase(); + loginsFound = loginsFound.filter( + l => l.username.toLowerCase() == searchString + ); + } + + this._fillForm(form, loginsFound, recipes, { + autofillForm: true, + clobberPassword: true, + userTriggered: true, + }); + }) + .catch(console.error); + // Use `loginGUID !== null` to distinguish whether this is called when the + // field is filled or tabbed away from. For the latter, don't highlight the field. + } else if (loginGUID !== null) { + LoginFormState._highlightFilledField(usernameField); + } + } else { + // Ignore the event, it's for some input we don't care about. + } + } + + /** + * @return true if the page requests autocomplete be disabled for the + * specified element. + */ + _isAutocompleteDisabled(element) { + return element?.autocomplete == "off"; + } + + /** + * Fill a page that was restored from bfcache since we wouldn't receive + * DOMInputPasswordAdded or DOMFormHasPassword events for it. + * @param {Document} aDocument that was restored from bfcache. + */ + _onDocumentRestored(aDocument) { + let rootElsWeakSet = + lazy.LoginFormFactory.getRootElementsWeakSetForDocument(aDocument); + let weakLoginFormRootElements = + ChromeUtils.nondeterministicGetWeakSetKeys(rootElsWeakSet); + + lazy.log( + `loginFormRootElements approx size: ${weakLoginFormRootElements.length}.` + ); + + for (let formRoot of weakLoginFormRootElements) { + if (!formRoot.isConnected) { + continue; + } + + let formLike = lazy.LoginFormFactory.getForRootElement(formRoot); + this._fetchLoginsFromParentAndFillForm(formLike); + } + } + + /** + * Trigger capture on any relevant FormLikes due to a navigation alone (not + * necessarily due to an actual form submission). This method is used to + * capture logins for cases where form submit events are not used. + * + * To avoid multiple notifications for the same LoginForm, this currently + * avoids capturing when dealing with a real which are ideally already + * using a submit event. + * + * @param {Document} document being navigated + */ + _onNavigation(aDocument) { + let rootElsWeakSet = + lazy.LoginFormFactory.getRootElementsWeakSetForDocument(aDocument); + let weakLoginFormRootElements = + ChromeUtils.nondeterministicGetWeakSetKeys(rootElsWeakSet); + + lazy.log(`root elements approx size: ${weakLoginFormRootElements.length}`); + + for (let formRoot of weakLoginFormRootElements) { + if (!formRoot.isConnected) { + continue; + } + + let formLike = lazy.LoginFormFactory.getForRootElement(formRoot); + this._onFormSubmit(formLike, SUBMIT_PAGE_NAVIGATION); + } + } + + /** + * Called by our observer when notified of a form submission. + * [Note that this happens before any DOM onsubmit handlers are invoked.] + * Looks for a password change in the submitted form, so we can update + * our stored password. + * + * @param {LoginForm} form + */ + _onFormSubmit(form, reason) { + lazy.log("Detected form submission."); + + this._maybeSendFormInteractionMessage( + form, + "PasswordManager:ShowDoorhanger", + { + targetField: null, + isSubmission: true, + // When this is trigger by inferring from form removal, the form is not + // connected anymore, skip checking isConnected in this case. + ignoreConnect: reason == SUBMIT_FORM_IS_REMOVED, + } + ); + } + + /** + * Extracts and validates information from a form-like element on the page. If validation is + * successful, sends a message to the parent process requesting that it show a dialog. + * + * The validation works are divided into two parts: + * 1. Whether this is a valid form with a password (validate in this function) + * 2. Whether the password manager decides to send interaction message for this form + * (validate in _maybeSendFormInteractionMessageContinue) + * + * When the function is triggered by a form submission event, and the form is valid (pass #1), + * We still send the message to the parent even the validation of #2 fails. This is because + * there might be someone who is interested in form submission events regardless of whether + * the password manager decides to show the doorhanger or not. + * + * @param {LoginForm} form + * @param {string} messageName used to categorize the type of message sent to the parent process. + * @param {Element?} options.targetField + * @param {boolean} options.isSubmission if true, this function call was prompted by a form submission. + * @param {boolean?} options.triggeredByFillingGenerated whether or not this call was triggered by a + * generated password being filled into a form-like element. + * @param {boolean?} options.ignoreConnect Whether to ignore isConnected attribute of a element. + * + * @returns {Boolean} whether the message is sent to the parent process. + */ + _maybeSendFormInteractionMessage( + form, + messageName, + { targetField, isSubmission, triggeredByFillingGenerated, ignoreConnect } + ) { + let logMessagePrefix = isSubmission + ? LOG_MESSAGE_FORM_SUBMISSION + : LOG_MESSAGE_FIELD_EDIT; + let doc = form.ownerDocument; + let win = doc.defaultView; + let passwordField = null; + if (targetField?.hasBeenTypePassword) { + passwordField = targetField; + } + + let origin = lazy.LoginHelper.getLoginOrigin(doc.documentURI); + if (!origin) { + lazy.log(`${logMessagePrefix} ignored -- invalid origin.`); + return; + } + + // Get the appropriate fields from the form. + let recipes = lazy.LoginRecipesContent.getRecipes(origin, win); + const docState = this.stateForDocument(form.ownerDocument); + let fields = { + targetField, + ...docState._getFormFields(form, true, recipes, { ignoreConnect }), + }; + + if (fields.usernameField) { + lazy.gFormFillService.markAsLoginManagerField(fields.usernameField); + } + + // It's possible the field triggering this message isn't one of those found by _getFormFields' heuristics + if ( + passwordField && + passwordField != fields.newPasswordField && + passwordField != fields.oldPasswordField && + passwordField != fields.confirmPasswordField + ) { + fields.newPasswordField = passwordField; + } + + // Need at least 1 valid password field to do anything. + if (fields.newPasswordField == null) { + if (isSubmission && fields.usernameField) { + lazy.log( + "_onFormSubmit: username-only form. Record the username field but not sending prompt." + ); + docState.mockUsernameOnlyField = { + name: fields.usernameField.name, + value: fields.usernameField.value, + }; + } + return; + } + + this._maybeSendFormInteractionMessageContinue(form, messageName, { + ...fields, + isSubmission, + triggeredByFillingGenerated, + }); + + if (isSubmission) { + // Notify `PasswordManager:onFormSubmit` as long as we detect submission event on a + // valid form with a password field. + this.sendAsyncMessage( + "PasswordManager:onFormSubmit", + {}, + { + fields, + isSubmission, + triggeredByFillingGenerated, + } + ); + } + } + + /** + * Continues the works that are not done in _maybeSendFormInteractionMessage. + * See comments in _maybeSendFormInteractionMessage for more details. + */ + _maybeSendFormInteractionMessageContinue( + form, + messageName, + { + targetField, + usernameField, + newPasswordField, + oldPasswordField, + confirmPasswordField, + isSubmission, + triggeredByFillingGenerated, + } + ) { + let logMessagePrefix = isSubmission + ? LOG_MESSAGE_FORM_SUBMISSION + : LOG_MESSAGE_FIELD_EDIT; + let doc = form.ownerDocument; + let win = doc.defaultView; + let detail = { messageSent: false }; + try { + // when filling a generated password, we do still want to message the parent + if ( + !triggeredByFillingGenerated && + PrivateBrowsingUtils.isContentWindowPrivate(win) && + !lazy.LoginHelper.privateBrowsingCaptureEnabled + ) { + // We won't do anything in private browsing mode anyway, + // so there's no need to perform further checks. + lazy.log(`${logMessagePrefix} ignored in private browsing mode.`); + return; + } + + // If password saving is disabled globally, bail out now. + if (!lazy.LoginHelper.enabled) { + return; + } + + let fullyMungedPattern = /^\*+$|^•+$|^\.+$/; + // Check `isSubmission` to allow munged passwords in dismissed by default doorhangers (since + // they are initiated by the user) in case this matches their actual password. + if (isSubmission && newPasswordField?.value.match(fullyMungedPattern)) { + lazy.log("New password looks munged. Not sending prompt."); + return; + } + + // When the username field is empty, check whether we have found it previously from + // a username-only form, if yes, fill in its value. + // XXX This is not ideal, we only use the previous saved username field when the current + // form doesn't have one. This means if there is a username field found in the current + // form, we don't compare it to the saved one, which might be a better choice in some cases. + // The reason we are not doing it now is because we haven't found a real world example. + let docState = this.stateForDocument(doc); + if (!usernameField) { + if (docState.mockUsernameOnlyField) { + usernameField = docState.mockUsernameOnlyField; + } + } + if (usernameField?.value.match(/\.{3,}|\*{3,}|•{3,}/)) { + lazy.log( + `usernameField with name ${usernameField.name} looks munged, setting to null.` + ); + usernameField = null; + } + + // Check for autocomplete=off attribute. We don't use it to prevent + // autofilling (for existing logins), but won't save logins when it's + // present and the storeWhenAutocompleteOff pref is false. + // XXX spin out a bug that we don't update timeLastUsed in this case? + if ( + (this._isAutocompleteDisabled(form) || + this._isAutocompleteDisabled(usernameField) || + this._isAutocompleteDisabled(newPasswordField) || + this._isAutocompleteDisabled(oldPasswordField)) && + !lazy.LoginHelper.storeWhenAutocompleteOff + ) { + lazy.log(`${logMessagePrefix} ignored -- autocomplete=off found.`); + return; + } + + // Don't try to send DOM nodes over IPC. + let mockUsername = usernameField + ? { name: usernameField.name, value: usernameField.value } + : null; + let mockPassword = { + name: newPasswordField.name, + value: newPasswordField.value, + }; + let mockOldPassword = oldPasswordField + ? { name: oldPasswordField.name, value: oldPasswordField.value } + : null; + + let usernameValue = usernameField?.value; + // Dismiss prompt if the username field is a credit card number AND + // if the password field is a three digit number. Also dismiss prompt if + // the password is a credit card number and the password field has attribute + // autocomplete="cc-number". + let dismissedPrompt = !isSubmission; + let newPasswordFieldValue = newPasswordField.value; + if ( + (!dismissedPrompt && + CreditCard.isValidNumber(usernameValue) && + newPasswordFieldValue.trim().match(/^[0-9]{3}$/)) || + (CreditCard.isValidNumber(newPasswordFieldValue) && + newPasswordField.getAutocompleteInfo().fieldName == "cc-number") + ) { + dismissedPrompt = true; + } + + const fieldsModified = docState._formHasModifiedFields(form); + if (!fieldsModified && lazy.LoginHelper.userInputRequiredToCapture) { + if (targetField) { + throw new Error("No user input on targetField"); + } + // we know no fields in this form had user modifications, so don't prompt + lazy.log( + `${logMessagePrefix} ignored -- submitting values that are not changed by the user.` + ); + return; + } + + if ( + docState.compareAndUpdatePreviouslySentValues( + form.rootElement, + usernameValue, + newPasswordField.value, + dismissedPrompt, + triggeredByFillingGenerated + ) + ) { + lazy.log( + `${logMessagePrefix} ignored -- already submitted with the same username and password.` + ); + return; + } + + let { login: autoFilledLogin } = + docState.fillsByRootElement.get(form.rootElement) || {}; + let browsingContextId = win.windowGlobalChild.browsingContext.id; + let formActionOrigin = lazy.LoginHelper.getFormActionOrigin(form); + + detail = { + browsingContextId, + formActionOrigin, + autoFilledLoginGuid: autoFilledLogin && autoFilledLogin.guid, + usernameField: mockUsername, + newPasswordField: mockPassword, + oldPasswordField: mockOldPassword, + dismissedPrompt, + triggeredByFillingGenerated, + possibleValues: { + usernames: docState.possibleUsernames, + passwords: docState.possiblePasswords, + }, + messageSent: true, + }; + + if (messageName == "PasswordManager:ShowDoorhanger") { + docState.captureLoginTimeStamp = doc.lastUserGestureTimeStamp; + } + this.sendAsyncMessage(messageName, detail); + } catch (ex) { + console.error(ex); + throw ex; + } finally { + detail.form = form; + const evt = new CustomEvent(messageName, { detail }); + win.windowRoot.dispatchEvent(evt); + } + } + + /** + * Heuristic for whether or not we should consider [field]s value to be 'new' (as opposed to + * 'changed') after applying [event]. + * + * @param {HTMLInputElement} event.target input element being changed. + * @param {string?} event.data new value being input into the field. + * + * @returns {boolean} + */ + _doesEventClearPrevFieldValue({ target, data, inputType }) { + return ( + !target.value || + // We check inputType here as a proxy for the previous field value. + // If the previous field value was empty, e.g. automatically filling + // a confirm password field when a new password field is filled with + // a generated password, there's nothing to replace. + // We may be able to use the "beforeinput" event instead when that + // ships (Bug 1609291). + (data && data == target.value && inputType !== "insertReplacementText") + ); + } + + /** + * The password field has been filled with a generated password, ensure the + * field is handled accordingly. + * @param {HTMLInputElement} passwordField + */ + _filledWithGeneratedPassword(passwordField) { + LoginFormState._highlightFilledField(passwordField); + this._passwordEditedOrGenerated(passwordField, { + triggeredByFillingGenerated: true, + }); + let docState = this.stateForDocument(passwordField.ownerDocument); + docState.fillConfirmFieldWithGeneratedPassword(passwordField); + } + + /** + * Notify the parent that we are ignoring the password edit + * so that tests can listen for this as opposed to waiting for + * nothing to happen. + */ + _ignorePasswordEdit() { + if (Cu.isInAutomation) { + this.sendAsyncMessage("PasswordManager:onIgnorePasswordEdit", {}); + } + } + /** + * Notify the parent that a generated password was filled into a field or + * edited so that it can potentially be saved. + * @param {HTMLInputElement} passwordField + */ + _passwordEditedOrGenerated( + passwordField, + { triggeredByFillingGenerated = false } = {} + ) { + lazy.log( + `Password field with name ${passwordField.name} was filled or edited.` + ); + + if (!lazy.LoginHelper.enabled && triggeredByFillingGenerated) { + throw new Error( + "A generated password was filled while the password manager was disabled." + ); + } + + let loginForm = lazy.LoginFormFactory.createFromField(passwordField); + + if (triggeredByFillingGenerated) { + LoginFormState._highlightFilledField(passwordField); + let docState = this.stateForDocument(passwordField.ownerDocument); + docState._treatAsGeneratedPasswordField(passwordField); + + // Once the generated password was filled we no longer want to autocomplete + // saved logins into a non-empty password field (see LoginAutoComplete.startSearch) + // because it is confusing. + this.#fieldsWithPasswordGenerationForcedOn.delete(passwordField); + } + + this._maybeSendFormInteractionMessage( + loginForm, + "PasswordManager:onPasswordEditedOrGenerated", + { + targetField: passwordField, + isSubmission: false, + triggeredByFillingGenerated, + } + ); + } + + /** + * Filter logins for exact origin/formActionOrigin and dedupe on usernamematche + * @param {nsILoginInfo[]} logins an array of nsILoginInfo that could be + * used for the form, including ones with a different form action origin + * which are only used when the fill is userTriggered + * @param {LoginForm} form + */ + _filterForExactFormOriginLogins(logins, form) { + let loginOrigin = lazy.LoginHelper.getLoginOrigin( + form.ownerDocument.documentURI + ); + let formActionOrigin = lazy.LoginHelper.getFormActionOrigin(form); + logins = logins.filter(l => { + let formActionMatches = lazy.LoginHelper.isOriginMatching( + l.formActionOrigin, + formActionOrigin, + { + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + acceptWildcardMatch: true, + acceptDifferentSubdomains: true, + } + ); + let formOriginMatches = lazy.LoginHelper.isOriginMatching( + l.origin, + loginOrigin, + { + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + acceptWildcardMatch: true, + acceptDifferentSubdomains: false, + } + ); + return formActionMatches && formOriginMatches; + }); + + // Since the logins are already filtered now to only match the origin and formAction, + // dedupe to just the username since remaining logins may have different schemes. + logins = lazy.LoginHelper.dedupeLogins( + logins, + ["username"], + ["scheme", "timePasswordChanged"], + loginOrigin, + formActionOrigin + ); + return logins; + } + + /** + * Attempt to find the username and password fields in a form, and fill them + * in using the provided logins and recipes. + * + * @param {LoginForm} form + * @param {nsILoginInfo[]} foundLogins an array of nsILoginInfo that could be + * used for the form, including ones with a different form action origin + * which are only used when the fill is userTriggered + * @param {Set} recipes a set of recipes that could be used to affect how the + * form is filled + * @param {Object} [options = {}] a list of options for this method + * @param {HTMLInputElement} [options.inputElement = null] an optional target + * input element we want to fill + * @param {bool} [options.autofillForm = false] denotes if we should fill the + * form in automatically + * @param {bool} [options.clobberUsername = false] controls if an existing + * username can be overwritten. If this is false and an inputElement + * of type password is also passed, the username field will be ignored. + * If this is false and no inputElement is passed, if the username + * field value is not found in foundLogins, it will not fill the + * password. + * @param {bool} [options.clobberPassword = false] controls if an existing + * password value can be overwritten + * @param {bool} [options.userTriggered = false] an indication of whether + * this filling was triggered by the user + */ + // eslint-disable-next-line complexity + _fillForm( + form, + foundLogins, + recipes, + { + inputElement = null, + autofillForm = false, + importable = null, + clobberUsername = false, + clobberPassword = false, + userTriggered = false, + style = null, + } = {} + ) { + if (HTMLFormElement.isInstance(form)) { + throw new Error("_fillForm should only be called with LoginForm objects"); + } + + lazy.log(`Found ${form.elements.length} form elements.`); + // Will be set to one of AUTOFILL_RESULT in the `try` block. + let autofillResult = -1; + const AUTOFILL_RESULT = { + FILLED: 0, + NO_PASSWORD_FIELD: 1, + PASSWORD_DISABLED_READONLY: 2, + NO_LOGINS_FIT: 3, + NO_SAVED_LOGINS: 4, + EXISTING_PASSWORD: 5, + EXISTING_USERNAME: 6, + MULTIPLE_LOGINS: 7, + NO_AUTOFILL_FORMS: 8, + AUTOCOMPLETE_OFF: 9, + INSECURE: 10, + PASSWORD_AUTOCOMPLETE_NEW_PASSWORD: 11, + TYPE_NO_LONGER_PASSWORD: 12, + FORM_IN_CROSSORIGIN_SUBFRAME: 13, + FILLED_USERNAME_ONLY_FORM: 14, + }; + const docState = this.stateForDocument(form.ownerDocument); + + // Heuristically determine what the user/pass fields are + // We do this before checking to see if logins are stored, + // so that the user isn't prompted for a primary password + // without need. + let { usernameField, newPasswordField: passwordField } = + docState._getFormFields(form, false, recipes); + + const passwordACFieldName = passwordField?.getAutocompleteInfo().fieldName; + + let scenario; + + // For SignUpFormScenario we expect an email like username + if (this.#relayIsAvailableOrEnabled() && usernameField) { + // Sign-up detection ruleset requires a . + // When form.rootElement is not a form, fall back on the heuristic that + // assumes a form/document and a passwordField with autocomplete new-password + if ( + HTMLFormElement.isInstance(form.rootElement) && + lazy.LoginHelper.signupDetectionEnabled + ) { + if (docState.isProbablyASignUpForm(form.rootElement)) { + scenario = new SignUpFormScenario(usernameField, passwordField); + } + } else if (passwordACFieldName == "new-password") { + scenario = new SignUpFormScenario(usernameField, passwordField); + } + if (scenario) { + docState.setScenario(form.rootElement, scenario); + lazy.gFormFillService.markAsLoginManagerField(usernameField); + } + } + + try { + // Nothing to do if we have no matching (excluding form action + // checks) logins available, and there isn't a need to show + // the insecure form warning. + if ( + !foundLogins.length && + !(importable?.state === "import" && importable?.browsers) && + lazy.InsecurePasswordUtils.isFormSecure(form) + ) { + // We don't log() here since this is a very common case. + autofillResult = AUTOFILL_RESULT.NO_SAVED_LOGINS; + return; + } + + // If we have a password inputElement parameter and it's not + // the same as the one heuristically found, use the parameter + // one instead. + if (inputElement) { + if (inputElement.hasBeenTypePassword) { + passwordField = inputElement; + if (!clobberUsername) { + usernameField = null; + } + } else if (lazy.LoginHelper.isUsernameFieldType(inputElement)) { + usernameField = inputElement; + } else { + throw new Error("Unexpected input element type."); + } + } + + // Need a valid password or username field to do anything. + if (passwordField == null && usernameField == null) { + lazy.log("Not filling form, no password and username field found."); + autofillResult = AUTOFILL_RESULT.NO_PASSWORD_FIELD; + return; + } + + // Attach autocomplete stuff to the username field, if we have + // one. This is normally used to select from multiple accounts, + // but even with one account we should refill if the user edits. + // We would also need this attached to show the insecure login + // warning, regardless of saved login. + if (usernameField) { + lazy.gFormFillService.markAsLoginManagerField(usernameField); + usernameField.addEventListener("keydown", observer); + } + + // If the password field is disabled or read-only, there's nothing to do. + if (passwordField?.disabled || passwordField?.readOnly) { + lazy.log("Not filling form, password field disabled or read-only."); + autofillResult = AUTOFILL_RESULT.PASSWORD_DISABLED_READONLY; + return; + } + + if ( + !userTriggered && + !form.rootElement.ownerGlobal.windowGlobalChild.sameOriginWithTop + ) { + lazy.log("Not filling form; it is in a cross-origin subframe."); + autofillResult = AUTOFILL_RESULT.FORM_IN_CROSSORIGIN_SUBFRAME; + return; + } + + if (!userTriggered) { + // Only autofill logins that match the form's action and origin. In the above code + // we have attached autocomplete for logins that don't match the form action. + foundLogins = this._filterForExactFormOriginLogins(foundLogins, form); + } + + // Nothing to do if we have no matching logins available. + // Only insecure pages reach this block and logs the same + // telemetry flag. + if (!foundLogins.length) { + // We don't log() here since this is a very common case. + autofillResult = AUTOFILL_RESULT.NO_SAVED_LOGINS; + return; + } + + // Prevent autofilling insecure forms. + if ( + !userTriggered && + !lazy.LoginHelper.insecureAutofill && + !lazy.InsecurePasswordUtils.isFormSecure(form) + ) { + lazy.log("Not filling form since it's insecure."); + autofillResult = AUTOFILL_RESULT.INSECURE; + return; + } + + // Discard logins which have username/password values that don't + // fit into the fields (as specified by the maxlength attribute). + // The user couldn't enter these values anyway, and it helps + // with sites that have an extra PIN to be entered (bug 391514) + let maxUsernameLen = Number.MAX_VALUE; + let maxPasswordLen = Number.MAX_VALUE; + + // If attribute wasn't set, default is -1. + if (usernameField?.maxLength >= 0) { + maxUsernameLen = usernameField.maxLength; + } + if (passwordField?.maxLength >= 0) { + maxPasswordLen = passwordField.maxLength; + } + + let logins = foundLogins.filter(function (l) { + let fit = + l.username.length <= maxUsernameLen && + l.password.length <= maxPasswordLen; + if (!fit) { + lazy.log(`Ignored login: won't fit ${l.username.length}.`); + } + + return fit; + }, this); + + if (!logins.length) { + lazy.log("Form not filled, none of the logins fit in the field."); + autofillResult = AUTOFILL_RESULT.NO_LOGINS_FIT; + return; + } + + if (passwordField) { + if (!userTriggered && passwordField.type != "password") { + // We don't want to autofill (without user interaction) into a field + // that's unmasked. + lazy.log( + "Not autofilling, password field isn't currently type=password." + ); + autofillResult = AUTOFILL_RESULT.TYPE_NO_LONGER_PASSWORD; + return; + } + + // If the password field has the autocomplete value of "new-password" + // and we're autofilling without user interaction, there's nothing to do. + if (!userTriggered && passwordACFieldName == "new-password") { + lazy.log( + "Not filling form, password field has the autocomplete new-password value." + ); + autofillResult = AUTOFILL_RESULT.PASSWORD_AUTOCOMPLETE_NEW_PASSWORD; + return; + } + + // Don't clobber an existing password. + if (passwordField.value && !clobberPassword) { + lazy.log("Form not filled, the password field was already filled."); + autofillResult = AUTOFILL_RESULT.EXISTING_PASSWORD; + return; + } + } + + // Select a login to use for filling in the form. + let selectedLogin; + if ( + !clobberUsername && + usernameField && + (usernameField.value || + usernameField.disabled || + usernameField.readOnly) + ) { + // If username was specified in the field, it's disabled or it's readOnly, only fill in the + // password if we find a matching login. + let username = usernameField.value.toLowerCase(); + + let matchingLogins = logins.filter( + l => l.username.toLowerCase() == username + ); + if (!matchingLogins.length) { + lazy.log( + "Password not filled. None of the stored logins match the username already present." + ); + autofillResult = AUTOFILL_RESULT.EXISTING_USERNAME; + return; + } + + // If there are multiple, and one matches case, use it + for (let l of matchingLogins) { + if (l.username == usernameField.value) { + selectedLogin = l; + } + } + // Otherwise just use the first + if (!selectedLogin) { + selectedLogin = matchingLogins[0]; + } + } else if (logins.length == 1) { + selectedLogin = logins[0]; + } else { + // We have multiple logins. Handle a special case here, for sites + // which have a normal user+pass login *and* a password-only login + // (eg, a PIN). Prefer the login that matches the type of the form + // (user+pass or pass-only) when there's exactly one that matches. + let matchingLogins; + if (usernameField) { + matchingLogins = logins.filter(l => l.username); + } else { + matchingLogins = logins.filter(l => !l.username); + } + + if (matchingLogins.length != 1) { + lazy.log("Multiple logins for form, so not filling any."); + autofillResult = AUTOFILL_RESULT.MULTIPLE_LOGINS; + return; + } + + selectedLogin = matchingLogins[0]; + } + + // We will always have a selectedLogin at this point. + + if (!autofillForm) { + lazy.log("autofillForms=false but form can be filled."); + autofillResult = AUTOFILL_RESULT.NO_AUTOFILL_FORMS; + return; + } + + if ( + !userTriggered && + passwordACFieldName == "off" && + !lazy.LoginHelper.autofillAutocompleteOff + ) { + lazy.log( + "Not autofilling the login because we're respecting autocomplete=off." + ); + autofillResult = AUTOFILL_RESULT.AUTOCOMPLETE_OFF; + return; + } + + // Fill the form + + let willAutofill = + usernameField || passwordField.value != selectedLogin.password; + if (willAutofill) { + let autoFilledLogin = { + guid: selectedLogin.QueryInterface(Ci.nsILoginMetaInfo).guid, + username: selectedLogin.username, + usernameField: usernameField + ? Cu.getWeakReference(usernameField) + : null, + password: selectedLogin.password, + passwordField: passwordField + ? Cu.getWeakReference(passwordField) + : null, + }; + // Ensure the state is updated before setUserInput is called. + lazy.log( + "Saving autoFilledLogin", + autoFilledLogin.guid, + "for", + form.rootElement + ); + docState.fillsByRootElement.set(form.rootElement, { + login: autoFilledLogin, + userTriggered, + }); + } + if (usernameField) { + // Don't modify the username field because the user wouldn't be able to change it either. + let disabledOrReadOnly = + usernameField.disabled || usernameField.readOnly; + + if (selectedLogin.username && !disabledOrReadOnly) { + let userNameDiffers = selectedLogin.username != usernameField.value; + // Don't replace the username if it differs only in case, and the user triggered + // this autocomplete. We assume that if it was user-triggered the entered text + // is desired. + let userEnteredDifferentCase = + userTriggered && + userNameDiffers && + usernameField.value.toLowerCase() == + selectedLogin.username.toLowerCase(); + + if (!userEnteredDifferentCase && userNameDiffers) { + usernameField.setUserInput(selectedLogin.username); + } + LoginFormState._highlightFilledField(usernameField); + } + } + + if (passwordField) { + if (passwordField.value != selectedLogin.password) { + // Ensure the field gets re-masked in case a generated password was + // filled into it previously. + docState._stopTreatingAsGeneratedPasswordField(passwordField); + + passwordField.setUserInput(selectedLogin.password); + } + + LoginFormState._highlightFilledField(passwordField); + } + + if (style === "generatedPassword") { + this._filledWithGeneratedPassword(passwordField); + } + + lazy.log("_fillForm succeeded"); + if (passwordField) { + autofillResult = AUTOFILL_RESULT.FILLED; + } else if (usernameField) { + autofillResult = AUTOFILL_RESULT.FILLED_USERNAME_ONLY_FORM; + } + } catch (ex) { + console.error(ex); + throw ex; + } finally { + if (autofillResult == -1) { + // eslint-disable-next-line no-unsafe-finally + throw new Error("_fillForm: autofillResult must be specified"); + } + + if (!userTriggered) { + // Ignore fills as a result of user action for this probe. + Services.telemetry + .getHistogramById("PWMGR_FORM_AUTOFILL_RESULT") + .add(autofillResult); + + if (usernameField) { + let focusedElement = lazy.gFormFillService.focusedInput; + if ( + usernameField == focusedElement && + ![ + AUTOFILL_RESULT.FILLED, + AUTOFILL_STATE.FILLED_USERNAME_ONLY_FORM, + ].includes(autofillResult) + ) { + lazy.log( + "Opening username autocomplete popup since the form wasn't autofilled." + ); + lazy.gFormFillService.showPopup(); + } + } + } + + if (usernameField) { + lazy.log("Attaching event listeners to usernameField."); + usernameField.addEventListener("focus", observer); + usernameField.addEventListener("mousedown", observer); + } + + this.sendAsyncMessage("PasswordManager:formProcessed", { + formid: form.rootElement.id, + }); + } + } + #relayIsAvailableOrEnabled() { + // This code is a mirror of what FirefoxRelay.jsm is doing, + // but we can not load Relay module in the child process. + const value = Services.prefs.getStringPref( + "signon.firefoxRelay.feature", + undefined + ); + return ["available", "offered", "enabled"].includes(value); + } + + getScenario(inputElement) { + const docState = this.stateForDocument(inputElement.ownerDocument); + return docState.getScenario(inputElement); + } +} diff --git a/toolkit/components/passwordmgr/LoginManagerContextMenu.sys.mjs b/toolkit/components/passwordmgr/LoginManagerContextMenu.sys.mjs new file mode 100644 index 0000000000..4a6da4cb32 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerContextMenu.sys.mjs @@ -0,0 +1,240 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +/** + * Password manager object for the browser contextual menu. + */ +export const LoginManagerContextMenu = { + /** + * Look for login items and add them to the contextual menu. + * + * @param {Object} inputElementIdentifier + * An identifier generated for the input element via ContentDOMReference. + * @param {xul:browser} browser + * The browser for the document the context menu was open on. + * @param {string} formOrigin + * The origin of the document that the context menu was activated from. + * This isn't the same as the browser's top-level document origin + * when subframes are involved. + * @returns {DocumentFragment} a document fragment with all the login items. + */ + addLoginsToMenu(inputElementIdentifier, browser, formOrigin) { + let foundLogins = this._findLogins(formOrigin); + + if (!foundLogins.length) { + return null; + } + + let fragment = browser.ownerDocument.createDocumentFragment(); + let duplicateUsernames = this._findDuplicates(foundLogins); + for (let login of foundLogins) { + let item = fragment.ownerDocument.createXULElement("menuitem"); + + let username = login.username; + // If login is empty or duplicated we want to append a modification date to it. + if (!username || duplicateUsernames.has(username)) { + if (!username) { + username = this._getLocalizedString("noUsername"); + } + let meta = login.QueryInterface(Ci.nsILoginMetaInfo); + let time = this.dateAndTimeFormatter.format( + new Date(meta.timePasswordChanged) + ); + username = this._getLocalizedString("loginHostAge", [username, time]); + } + item.setAttribute("label", username); + item.setAttribute("class", "context-login-item"); + + // login is bound so we can keep the reference to each object. + item.addEventListener( + "command", + function (login, event) { + this._fillTargetField( + login, + inputElementIdentifier, + browser, + formOrigin + ); + }.bind(this, login) + ); + + fragment.appendChild(item); + } + + return fragment; + }, + + /** + * Undoes the work of addLoginsToMenu for the same menu. + * + * @param {Document} + * The context menu owner document. + */ + clearLoginsFromMenu(document) { + let loginItems = document.getElementsByClassName("context-login-item"); + while (loginItems.item(0)) { + loginItems.item(0).remove(); + } + }, + + /** + * Show the password autocomplete UI with the generation option forced to appear. + */ + async useGeneratedPassword(inputElementIdentifier, documentURI, browser) { + let browsingContextId = inputElementIdentifier.browsingContextId; + let browsingContext = BrowsingContext.get(browsingContextId); + let actor = browsingContext.currentWindowGlobal.getActor("LoginManager"); + + actor.sendAsyncMessage("PasswordManager:useGeneratedPassword", { + inputElementIdentifier, + }); + }, + + /** + * Find logins for the specified origin.. + * + * @param {string} formOrigin + * Origin of the logins we want to find that has be sanitized by `getLoginOrigin`. + * This isn't the same as the browser's top-level document URI + * when subframes are involved. + * + * @returns {nsILoginInfo[]} a login list + */ + _findLogins(formOrigin) { + let searchParams = { + origin: formOrigin, + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + }; + let logins = lazy.LoginHelper.searchLoginsWithObject(searchParams); + let resolveBy = ["scheme", "timePasswordChanged"]; + logins = lazy.LoginHelper.dedupeLogins( + logins, + ["username", "password"], + resolveBy, + formOrigin + ); + + // Sort logins in alphabetical order and by date. + logins.sort((loginA, loginB) => { + // Sort alphabetically + let result = loginA.username.localeCompare(loginB.username); + if (result) { + // Forces empty logins to be at the end + if (!loginA.username) { + return 1; + } + if (!loginB.username) { + return -1; + } + return result; + } + + // Same username logins are sorted by last change date + let metaA = loginA.QueryInterface(Ci.nsILoginMetaInfo); + let metaB = loginB.QueryInterface(Ci.nsILoginMetaInfo); + return metaB.timePasswordChanged - metaA.timePasswordChanged; + }); + + return logins; + }, + + /** + * Find duplicate usernames in a login list. + * + * @param {nsILoginInfo[]} loginList + * A list of logins we want to look for duplicate usernames. + * + * @returns {Set} a set with the duplicate usernames. + */ + _findDuplicates(loginList) { + let seen = new Set(); + let duplicates = new Set(); + for (let login of loginList) { + if (seen.has(login.username)) { + duplicates.add(login.username); + } + seen.add(login.username); + } + return duplicates; + }, + + /** + * @param {nsILoginInfo} login + * The login we want to fill the form with. + * @param {Object} inputElementIdentifier + * An identifier generated for the input element via ContentDOMReference. + * @param {xul:browser} browser + * The target tab browser. + * @param {string} formOrigin + * Origin of the document we're filling after sanitization via + * `getLoginOrigin`. + * This isn't the same as the browser's top-level + * origin when subframes are involved. + */ + _fillTargetField(login, inputElementIdentifier, browser, formOrigin) { + let browsingContextId = inputElementIdentifier.browsingContextId; + let browsingContext = BrowsingContext.get(browsingContextId); + if (!browsingContext) { + return; + } + + let actor = browsingContext.currentWindowGlobal.getActor("LoginManager"); + if (!actor) { + return; + } + + actor + .fillForm({ + browser, + inputElementIdentifier, + loginFormOrigin: formOrigin, + login, + }) + .catch(console.error); + }, + + /** + * @param {string} key + * The localized string key + * @param {string[]} formatArgs + * An array of formatting argument string + * + * @returns {string} the localized string for the specified key, + * formatted with arguments if required. + */ + _getLocalizedString(key, formatArgs) { + if (formatArgs) { + return this._stringBundle.formatStringFromName(key, formatArgs); + } + return this._stringBundle.GetStringFromName(key); + }, +}; + +XPCOMUtils.defineLazyGetter( + LoginManagerContextMenu, + "_stringBundle", + function () { + return Services.strings.createBundle( + "chrome://passwordmgr/locale/passwordmgr.properties" + ); + } +); + +XPCOMUtils.defineLazyGetter( + LoginManagerContextMenu, + "dateAndTimeFormatter", + function () { + return new Services.intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }); + } +); diff --git a/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs b/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs new file mode 100644 index 0000000000..dcf770b9d1 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs @@ -0,0 +1,1524 @@ +/* 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/. */ + +import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const LoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "LoginRelatedRealmsParent", () => { + const { LoginRelatedRealmsParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginRelatedRealms.sys.mjs" + ); + return new LoginRelatedRealmsParent(); +}); + +XPCOMUtils.defineLazyGetter(lazy, "PasswordRulesManager", () => { + const { PasswordRulesManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/PasswordRulesManager.sys.mjs" + ); + return new PasswordRulesManagerParent(); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + ChromeMigrationUtils: "resource:///modules/ChromeMigrationUtils.sys.mjs", + FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PasswordGenerator: "resource://gre/modules/PasswordGenerator.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "prompterSvc", + "@mozilla.org/login-manager/prompter;1", + Ci.nsILoginManagerPrompter +); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerParent"); + return logger.log.bind(logger); +}); +XPCOMUtils.defineLazyGetter(lazy, "debug", () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerParent"); + return logger.debug.bind(logger); +}); + +/** + * A listener for notifications to tests. + */ +let gListenerForTests = null; + +/** + * A map of a principal's origin (including suffixes) to a generated password string and filled flag + * so that we can offer the same password later (e.g. in a confirmation field). + * + * We don't currently evict from this cache so entries should last until the end of the browser + * session. That may change later but for now a typical session would max out at a few entries. + */ +let gGeneratedPasswordsByPrincipalOrigin = new Map(); + +/** + * Reference to the default LoginRecipesParent (instead of the initialization promise) for + * synchronous access. This is a temporary hack and new consumers should yield on + * recipeParentPromise instead. + * + * @type LoginRecipesParent + * @deprecated + */ +let gRecipeManager = null; + +/** + * Tracks the last time the user cancelled the primary password prompt, + * to avoid spamming primary password prompts on autocomplete searches. + */ +let gLastMPLoginCancelled = Number.NEGATIVE_INFINITY; + +let gGeneratedPasswordObserver = { + addedObserver: false, + + observe(subject, topic, data) { + if (topic == "last-pb-context-exited") { + // The last private browsing context closed so clear all cached generated + // passwords for private window origins. + for (let principalOrigin of gGeneratedPasswordsByPrincipalOrigin.keys()) { + let principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + principalOrigin + ); + if (!principal.privateBrowsingId) { + // The origin isn't for a private context so leave it alone. + continue; + } + gGeneratedPasswordsByPrincipalOrigin.delete(principalOrigin); + } + return; + } + + // We cache generated passwords in gGeneratedPasswordsByPrincipalOrigin. + // When generated password used on the page, + // we store a login with generated password and without username. + // When user updates that autosaved login with username, + // we must clear cached generated password. + // This will generate a new password next time user needs it. + if (topic == "passwordmgr-storage-changed" && data == "modifyLogin") { + const originalLogin = subject.GetElementAt(0); + const updatedLogin = subject.GetElementAt(1); + + if (originalLogin && !originalLogin.username && updatedLogin?.username) { + const generatedPassword = gGeneratedPasswordsByPrincipalOrigin.get( + originalLogin.origin + ); + + if ( + originalLogin.password == generatedPassword.value && + updatedLogin.password == generatedPassword.value + ) { + gGeneratedPasswordsByPrincipalOrigin.delete(originalLogin.origin); + } + } + } + + if ( + topic == "passwordmgr-autosaved-login-merged" || + (topic == "passwordmgr-storage-changed" && data == "removeLogin") + ) { + let { origin, guid } = subject; + let generatedPW = gGeneratedPasswordsByPrincipalOrigin.get(origin); + + // in the case where an autosaved login removed or merged into an existing login, + // clear the guid associated with the generated-password cache entry + if ( + generatedPW && + (guid == generatedPW.storageGUID || + topic == "passwordmgr-autosaved-login-merged") + ) { + lazy.log( + `Removing storageGUID for generated-password cache entry on origin: ${origin}.` + ); + generatedPW.storageGUID = null; + } + } + }, +}; + +Services.ppmm.addMessageListener("PasswordManager:findRecipes", message => { + let formHost = new URL(message.data.formOrigin).host; + return gRecipeManager?.getRecipesForHost(formHost) ?? []; +}); + +/** + * Lazily create a Map of origins to array of browsers with importable logins. + * + * @param {origin} formOrigin + * @returns {Object?} containing array of migration browsers and experiment state. + */ +async function getImportableLogins(formOrigin) { + // Include the experiment state for data and UI decisions; otherwise skip + // importing if not supported or disabled. + const state = + lazy.LoginHelper.suggestImportCount > 0 && + lazy.LoginHelper.showAutoCompleteImport; + return state + ? { + browsers: await lazy.ChromeMigrationUtils.getImportableLogins( + formOrigin + ), + state, + } + : null; +} + +export class LoginManagerParent extends JSWindowActorParent { + possibleValues = { + // This is stored at the parent (i.e., frame) scope because the LoginManagerPrompter + // is shared across all frames. + // + // It is mutated to update values without forcing us to set a new doorhanger. + usernames: new Set(), + passwords: new Set(), + }; + + // This is used by tests to listen to form submission. + static setListenerForTests(listener) { + gListenerForTests = listener; + } + + // Used by tests to clean up recipes only when they were actually used. + static get _recipeManager() { + return gRecipeManager; + } + + // Some unit tests need to access this. + static getGeneratedPasswordsByPrincipalOrigin() { + return gGeneratedPasswordsByPrincipalOrigin; + } + + getRootBrowser() { + let browsingContext = null; + if (this._overrideBrowsingContextId) { + browsingContext = BrowsingContext.get(this._overrideBrowsingContextId); + } else { + browsingContext = this.browsingContext.top; + } + return browsingContext.embedderElement; + } + + /** + * @param {origin} formOrigin + * @param {object} options + * @param {origin?} options.formActionOrigin To match on. Omit this argument to match all action origins. + * @param {origin?} options.httpRealm To match on. Omit this argument to match all realms. + * @param {boolean} options.acceptDifferentSubdomains Include results for eTLD+1 matches + * @param {boolean} options.ignoreActionAndRealm Include all form and HTTP auth logins for the site + * @param {string[]} options.relatedRealms Related realms to match against when searching + */ + static async searchAndDedupeLogins( + formOrigin, + { + acceptDifferentSubdomains, + formActionOrigin, + httpRealm, + ignoreActionAndRealm, + relatedRealms, + } = {} + ) { + let logins; + let matchData = { + origin: formOrigin, + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + acceptDifferentSubdomains, + }; + if (!ignoreActionAndRealm) { + if (typeof formActionOrigin != "undefined") { + matchData.formActionOrigin = formActionOrigin; + } else if (typeof httpRealm != "undefined") { + matchData.httpRealm = httpRealm; + } + } + if (lazy.LoginHelper.relatedRealmsEnabled) { + matchData.acceptRelatedRealms = lazy.LoginHelper.relatedRealmsEnabled; + matchData.relatedRealms = relatedRealms; + } + try { + logins = await Services.logins.searchLoginsAsync(matchData); + } catch (e) { + // Record the last time the user cancelled the MP prompt + // to avoid spamming them with MP prompts for autocomplete. + if (e.result == Cr.NS_ERROR_ABORT) { + lazy.log("User cancelled primary password prompt."); + gLastMPLoginCancelled = Date.now(); + return []; + } + throw e; + } + + logins = lazy.LoginHelper.shadowHTTPLogins(logins); + + let resolveBy = [ + "subdomain", + "actionOrigin", + "scheme", + "timePasswordChanged", + ]; + return lazy.LoginHelper.dedupeLogins( + logins, + ["username", "password"], + resolveBy, + formOrigin, + formActionOrigin + ); + } + + async receiveMessage(msg) { + let data = msg.data; + if (data.origin || data.formOrigin) { + throw new Error( + "The child process should not send an origin to the parent process. See bug 1513003" + ); + } + let context = {}; + XPCOMUtils.defineLazyGetter(context, "origin", () => { + // We still need getLoginOrigin to remove the path for file: URIs until we fix bug 1625391. + let origin = lazy.LoginHelper.getLoginOrigin( + this.manager.documentPrincipal?.originNoSuffix + ); + if (!origin) { + throw new Error("An origin is required. Message name: " + msg.name); + } + return origin; + }); + + switch (msg.name) { + case "PasswordManager:updateDoorhangerSuggestions": { + this.#onUpdateDoorhangerSuggestions(data.possibleValues); + break; + } + + case "PasswordManager:decreaseSuggestImportCount": { + this.decreaseSuggestImportCount(data); + break; + } + + case "PasswordManager:findLogins": { + return this.sendLoginDataToChild( + context.origin, + data.actionOrigin, + data.options + ); + } + + case "PasswordManager:onFormSubmit": { + this.#onFormSubmit(context); + break; + } + + case "PasswordManager:onPasswordEditedOrGenerated": { + this.#onPasswordEditedOrGenerated(context, data); + break; + } + + case "PasswordManager:onIgnorePasswordEdit": { + this.#onIgnorePasswordEdit(); + break; + } + + case "PasswordManager:ShowDoorhanger": { + this.#onShowDoorhanger(context, data); + break; + } + + case "PasswordManager:autoCompleteLogins": { + return this.doAutocompleteSearch(context.origin, data); + } + + case "PasswordManager:removeLogin": { + this.#onRemoveLogin(data.login); + break; + } + + case "PasswordManager:OpenImportableLearnMore": { + this.#onOpenImportableLearnMore(); + break; + } + + case "PasswordManager:HandleImportable": { + await this.#onHandleImportable(data.browserId); + break; + } + + case "PasswordManager:OpenPreferences": { + this.#onOpenPreferences(data.hostname, data.entryPoint); + break; + } + + // Used by tests to detect that a form-fill has occurred. This redirects + // to the top-level browsing context. + case "PasswordManager:formProcessed": { + this.#onFormProcessed(data.formid); + break; + } + + case "PasswordManager:offerRelayIntegration": { + FirefoxRelayTelemetry.recordRelayOfferedEvent( + "clicked", + data.telemetry.flowId, + data.telemetry.scenarioName, + data.telemetry.isRelayUser + ); + return this.#offerRelayIntegration(context.origin); + } + + case "PasswordManager:generateRelayUsername": { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "clicked", + data.telemetry.flowId + ); + return this.#generateRelayUsername(context.origin); + } + } + + return undefined; + } + + #onUpdateDoorhangerSuggestions(possibleValues) { + this.possibleValues.usernames = possibleValues.usernames; + this.possibleValues.passwords = possibleValues.passwords; + } + + #onFormSubmit(context) { + Services.obs.notifyObservers( + null, + "passwordmgr-form-submission-detected", + context.origin + ); + } + + #onPasswordEditedOrGenerated(context, data) { + lazy.log("#onPasswordEditedOrGenerated: Received PasswordManager."); + if (gListenerForTests) { + lazy.log("#onPasswordEditedOrGenerated: Calling gListenerForTests."); + gListenerForTests("PasswordEditedOrGenerated", {}); + } + let browser = this.getRootBrowser(); + this._onPasswordEditedOrGenerated(browser, context.origin, data); + } + + #onIgnorePasswordEdit() { + lazy.log("#onIgnorePasswordEdit: Received PasswordManager."); + if (gListenerForTests) { + lazy.log("#onIgnorePasswordEdit: Calling gListenerForTests."); + gListenerForTests("PasswordIgnoreEdit", {}); + } + } + + #onShowDoorhanger(context, data) { + const browser = this.getRootBrowser(); + const submitPromise = this.showDoorhanger(browser, context.origin, data); + if (gListenerForTests) { + submitPromise.then(() => { + gListenerForTests("ShowDoorhanger", { + origin: context.origin, + data, + }); + }); + } + } + + #onRemoveLogin(login) { + login = lazy.LoginHelper.vanillaObjectToLogin(login); + Services.logins.removeLogin(login); + } + + #onOpenImportableLearnMore() { + const window = this.getRootBrowser().ownerGlobal; + window.openTrustedLinkIn( + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-import", + "tab", + { relatedToCurrent: true } + ); + } + + async #onHandleImportable(browserId) { + // Directly migrate passwords for a single profile. + const migrator = await lazy.MigrationUtils.getMigrator(browserId); + const profiles = await migrator.getSourceProfiles(); + if ( + profiles.length == 1 && + lazy.NimbusFeatures["password-autocomplete"].getVariable( + "directMigrateSingleProfile" + ) + ) { + const loginAdded = new Promise(resolve => { + const obs = (_subject, _topic, data) => { + if (data == "addLogin") { + Services.obs.removeObserver(obs, "passwordmgr-storage-changed"); + resolve(); + } + }; + Services.obs.addObserver(obs, "passwordmgr-storage-changed"); + }); + + await migrator.migrate( + lazy.MigrationUtils.resourceTypes.PASSWORDS, + null, + profiles[0] + ); + await loginAdded; + + // Reshow the popup with the imported password. + this.sendAsyncMessage("PasswordManager:repopulateAutocompletePopup"); + } else { + // Open the migration wizard pre-selecting the appropriate browser. + lazy.MigrationUtils.showMigrationWizard( + this.getRootBrowser().ownerGlobal, + { + entrypoint: lazy.MigrationUtils.MIGRATION_ENTRYPOINTS.PASSWORDS, + migratorKey: browserId, + } + ); + } + } + + #onOpenPreferences(hostname, entryPoint) { + const window = this.getRootBrowser().ownerGlobal; + lazy.LoginHelper.openPasswordManager(window, { + filterString: hostname, + entryPoint, + }); + } + + #onFormProcessed(formid) { + const topActor = + this.browsingContext.currentWindowGlobal.getActor("LoginManager"); + topActor.sendAsyncMessage("PasswordManager:formProcessed", { formid }); + if (gListenerForTests) { + gListenerForTests("FormProcessed", { + browsingContext: this.browsingContext, + }); + } + } + + async #offerRelayIntegration(origin) { + const browser = lazy.LoginHelper.getBrowserForPrompt(this.getRootBrowser()); + return lazy.FirefoxRelay.offerRelayIntegration(browser, origin); + } + + async #generateRelayUsername(origin) { + const browser = lazy.LoginHelper.getBrowserForPrompt(this.getRootBrowser()); + return lazy.FirefoxRelay.generateUsername(browser, origin); + } + + /** + * Update the remaining number of import suggestion impressions with debounce + * to allow multiple popups showing the "same" items to count as one. + */ + decreaseSuggestImportCount(count) { + // Delay an existing timer with a potentially larger count. + if (this._suggestImportTimer) { + this._suggestImportTimer.delay = + LoginManagerParent.SUGGEST_IMPORT_DEBOUNCE_MS; + this._suggestImportCount = Math.max(count, this._suggestImportCount); + return; + } + + this._suggestImportTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + this._suggestImportTimer.init( + () => { + this._suggestImportTimer = null; + Services.prefs.setIntPref( + "signon.suggestImportCount", + lazy.LoginHelper.suggestImportCount - this._suggestImportCount + ); + }, + LoginManagerParent.SUGGEST_IMPORT_DEBOUNCE_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); + this._suggestImportCount = count; + } + + async #getRecipesForHost(origin) { + let recipes; + if (origin) { + try { + const formHost = new URL(origin).host; + let recipeManager = await LoginManagerParent.recipeParentPromise; + recipes = recipeManager.getRecipesForHost(formHost); + } catch (ex) { + // Some schemes e.g. chrome aren't supported by URL + } + } + + return recipes ?? []; + } + + /** + * Trigger a login form fill and send relevant data (e.g. logins and recipes) + * to the child process (LoginManagerChild). + */ + async fillForm({ + browser, + loginFormOrigin, + login, + inputElementIdentifier, + style, + }) { + const recipes = await this.#getRecipesForHost(loginFormOrigin); + + // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo + // doesn't support structured cloning. + const jsLogins = [lazy.LoginHelper.loginToVanillaObject(login)]; + + const browserURI = browser.currentURI.spec; + const originMatches = + lazy.LoginHelper.getLoginOrigin(browserURI) == loginFormOrigin; + + this.sendAsyncMessage("PasswordManager:fillForm", { + inputElementIdentifier, + loginFormOrigin, + originMatches, + logins: jsLogins, + recipes, + style, + }); + } + + /** + * Send relevant data (e.g. logins and recipes) to the child process (LoginManagerChild). + */ + async sendLoginDataToChild( + formOrigin, + actionOrigin, + { guid, showPrimaryPassword } + ) { + const recipes = await this.#getRecipesForHost(formOrigin); + + if (!showPrimaryPassword && !Services.logins.isLoggedIn) { + return { logins: [], recipes }; + } + + // If we're currently displaying a primary password prompt, defer + // processing this form until the user handles the prompt. + if (Services.logins.uiBusy) { + lazy.log( + "UI is busy. Deferring sendLoginDataToChild for form: ", + formOrigin + ); + + let uiBusyPromiseResolve; + const uiBusyPromise = new Promise(resolve => { + uiBusyPromiseResolve = resolve; + }); + + const self = this; + const observer = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + observe(_subject, topic, _data) { + lazy.log("Got deferred sendLoginDataToChild notification:", topic); + // Only run observer once. + Services.obs.removeObserver(this, "passwordmgr-crypto-login"); + Services.obs.removeObserver(this, "passwordmgr-crypto-loginCanceled"); + if (topic == "passwordmgr-crypto-loginCanceled") { + uiBusyPromiseResolve({ logins: [], recipes }); + return; + } + + const result = self.sendLoginDataToChild(formOrigin, actionOrigin, { + showPrimaryPassword, + }); + uiBusyPromiseResolve(result); + }, + }; + + // Possible leak: it's possible that neither of these notifications + // will fire, and if that happens, we'll leak the observer (and + // never return). We should guarantee that at least one of these + // will fire. + // See bug XXX. + Services.obs.addObserver(observer, "passwordmgr-crypto-login"); + Services.obs.addObserver(observer, "passwordmgr-crypto-loginCanceled"); + + return uiBusyPromise; + } + + // Autocomplete results do not need to match actionOrigin or exact origin. + let logins = null; + if (guid) { + logins = await Services.logins.searchLoginsAsync({ + guid, + origin: formOrigin, + }); + } else { + let relatedRealmsOrigins = []; + if (lazy.LoginHelper.relatedRealmsEnabled) { + relatedRealmsOrigins = + await lazy.LoginRelatedRealmsParent.findRelatedRealms(formOrigin); + } + logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { + formActionOrigin: actionOrigin, + ignoreActionAndRealm: true, + acceptDifferentSubdomains: + lazy.LoginHelper.includeOtherSubdomainsInLookup, + relatedRealms: relatedRealmsOrigins, + }); + + if (lazy.LoginHelper.relatedRealmsEnabled) { + lazy.debug( + "Adding related logins on page load", + logins.map(l => l.origin) + ); + } + } + lazy.log(`Deduped ${logins.length} logins.`); + // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo + // doesn't support structured cloning. + let jsLogins = lazy.LoginHelper.loginsToVanillaObjects(logins); + return { + importable: await getImportableLogins(formOrigin), + logins: jsLogins, + recipes, + }; + } + + async doAutocompleteSearch( + formOrigin, + { + actionOrigin, + searchString, + previousResult, + forcePasswordGeneration, + hasBeenTypePassword, + isProbablyANewPasswordField, + scenarioName, + inputMaxLength, + } + ) { + // Note: previousResult is a regular object, not an + // nsIAutoCompleteResult. + + // Cancel if the primary password prompt is already showing or we unsuccessfully prompted for it too recently. + if (!Services.logins.isLoggedIn) { + if (Services.logins.uiBusy) { + lazy.log( + "Not searching logins for autocomplete since the primary password prompt is already showing." + ); + // Return an empty array to make LoginManagerChild clear the + // outstanding request it has temporarily saved. + return { logins: [] }; + } + + const timeDiff = Date.now() - gLastMPLoginCancelled; + if (timeDiff < LoginManagerParent._repromptTimeout) { + lazy.log( + `Not searching logins for autocomplete since the primary password prompt was last cancelled ${Math.round( + timeDiff / 1000 + )} seconds ago.` + ); + // Return an empty array to make LoginManagerChild clear the + // outstanding request it has temporarily saved. + return { logins: [] }; + } + } + + const searchStringLower = searchString.toLowerCase(); + let logins; + if ( + previousResult && + searchStringLower.startsWith(previousResult.searchString.toLowerCase()) + ) { + lazy.log("Using previous autocomplete result."); + + // We have a list of results for a shorter search string, so just + // filter them further based on the new search string. + logins = lazy.LoginHelper.vanillaObjectsToLogins(previousResult.logins); + } else { + lazy.log("Creating new autocomplete search result."); + let relatedRealmsOrigins = []; + if (lazy.LoginHelper.relatedRealmsEnabled) { + relatedRealmsOrigins = + await lazy.LoginRelatedRealmsParent.findRelatedRealms(formOrigin); + } + // Autocomplete results do not need to match actionOrigin or exact origin. + logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { + formActionOrigin: actionOrigin, + ignoreActionAndRealm: true, + acceptDifferentSubdomains: + lazy.LoginHelper.includeOtherSubdomainsInLookup, + relatedRealms: relatedRealmsOrigins, + }); + } + + const matchingLogins = logins.filter(fullMatch => { + // Remove results that are too short, or have different prefix. + // Also don't offer empty usernames as possible results except + // for on password fields. + if (hasBeenTypePassword) { + return true; + } + + const match = fullMatch.username; + + return match && match.toLowerCase().startsWith(searchStringLower); + }); + + let generatedPassword = null; + let willAutoSaveGeneratedPassword = false; + if ( + // If MP was cancelled above, don't try to offer pwgen or access storage again (causing a new MP prompt). + Services.logins.isLoggedIn && + (forcePasswordGeneration || + (isProbablyANewPasswordField && + Services.logins.getLoginSavingEnabled(formOrigin))) + ) { + // We either generate a new password here, or grab the previously generated password + // if we're still on the same domain when we generated the password + generatedPassword = await this.getGeneratedPassword({ inputMaxLength }); + const potentialConflictingLogins = + await Services.logins.searchLoginsAsync({ + origin: formOrigin, + formActionOrigin: actionOrigin, + httpRealm: null, + }); + willAutoSaveGeneratedPassword = !potentialConflictingLogins.find( + login => login.username == "" + ); + } + + // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo + // doesn't support structured cloning. + let jsLogins = lazy.LoginHelper.loginsToVanillaObjects(matchingLogins); + + return { + generatedPassword, + importable: await getImportableLogins(formOrigin), + autocompleteItems: hasBeenTypePassword + ? [] + : await lazy.FirefoxRelay.autocompleteItemsAsync({ + formOrigin, + scenarioName, + hasInput: !!searchStringLower.length, + }), + logins: jsLogins, + willAutoSaveGeneratedPassword, + }; + } + + /** + * Expose `BrowsingContext` so we can stub it in tests. + */ + static get _browsingContextGlobal() { + return BrowsingContext; + } + + // Set an override context within a test. + useBrowsingContext(browsingContextId = 0) { + this._overrideBrowsingContextId = browsingContextId; + } + + getBrowsingContextToUse() { + if (this._overrideBrowsingContextId) { + return BrowsingContext.get(this._overrideBrowsingContextId); + } + + return this.browsingContext; + } + + async getGeneratedPassword({ inputMaxLength } = {}) { + if ( + !lazy.LoginHelper.enabled || + !lazy.LoginHelper.generationAvailable || + !lazy.LoginHelper.generationEnabled + ) { + return null; + } + + let browsingContext = this.getBrowsingContextToUse(); + if (!browsingContext) { + return null; + } + let framePrincipalOrigin = + browsingContext.currentWindowGlobal.documentPrincipal.origin; + // Use the same password if we already generated one for this origin so that it doesn't change + // with each search/keystroke and the user can easily re-enter a password in a confirmation field. + let generatedPW = + gGeneratedPasswordsByPrincipalOrigin.get(framePrincipalOrigin); + if (generatedPW) { + return generatedPW.value; + } + + generatedPW = { + autocompleteShown: false, + edited: false, + filled: false, + /** + * GUID of a login that was already saved for this generated password that + * will be automatically updated with password changes. This shouldn't be + * an existing saved login for the site unless the user chose to + * merge/overwrite via a doorhanger. + */ + storageGUID: null, + }; + if (lazy.LoginHelper.improvedPasswordRulesEnabled) { + generatedPW.value = await lazy.PasswordRulesManager.generatePassword( + browsingContext.currentWindowGlobal.documentURI, + { inputMaxLength } + ); + } else { + generatedPW.value = lazy.PasswordGenerator.generatePassword({ + inputMaxLength, + }); + } + + // Add these observers when a password is assigned. + if (!gGeneratedPasswordObserver.addedObserver) { + Services.obs.addObserver( + gGeneratedPasswordObserver, + "passwordmgr-autosaved-login-merged" + ); + Services.obs.addObserver( + gGeneratedPasswordObserver, + "passwordmgr-storage-changed" + ); + Services.obs.addObserver( + gGeneratedPasswordObserver, + "last-pb-context-exited" + ); + gGeneratedPasswordObserver.addedObserver = true; + } + + gGeneratedPasswordsByPrincipalOrigin.set(framePrincipalOrigin, generatedPW); + return generatedPW.value; + } + + maybeRecordPasswordGenerationShownTelemetryEvent(autocompleteResults) { + if (!autocompleteResults.some(r => r.style == "generatedPassword")) { + return; + } + + let browsingContext = this.getBrowsingContextToUse(); + + let framePrincipalOrigin = + browsingContext.currentWindowGlobal.documentPrincipal.origin; + let generatedPW = + gGeneratedPasswordsByPrincipalOrigin.get(framePrincipalOrigin); + + // We only want to record the first time it was shown for an origin + if (generatedPW.autocompleteShown) { + return; + } + + generatedPW.autocompleteShown = true; + + Services.telemetry.recordEvent( + "pwmgr", + "autocomplete_shown", + "generatedpassword" + ); + } + + /** + * Used for stubbing by tests. + */ + _getPrompter() { + return lazy.prompterSvc; + } + + // Look for an existing login that matches the form login. + #findSameLogin(logins, formLogin) { + return logins.find(login => { + let same; + + // If one login has a username but the other doesn't, ignore + // the username when comparing and only match if they have the + // same password. Otherwise, compare the logins and match even + // if the passwords differ. + if (!login.username && formLogin.username) { + let restoreMe = formLogin.username; + formLogin.username = ""; + same = lazy.LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: false, + ignoreSchemes: lazy.LoginHelper.schemeUpgrades, + }); + formLogin.username = restoreMe; + } else if (!formLogin.username && login.username) { + formLogin.username = login.username; + same = lazy.LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: false, + ignoreSchemes: lazy.LoginHelper.schemeUpgrades, + }); + formLogin.username = ""; // we know it's always blank. + } else { + same = lazy.LoginHelper.doLoginsMatch(formLogin, login, { + ignorePassword: true, + ignoreSchemes: lazy.LoginHelper.schemeUpgrades, + }); + } + + return same; + }); + } + + async showDoorhanger( + browser, + formOrigin, + { + browsingContextId, + formActionOrigin, + autoFilledLoginGuid, + usernameField, + newPasswordField, + oldPasswordField, + dismissedPrompt, + } + ) { + function recordLoginUse(login) { + Services.logins.recordPasswordUse( + login, + browser && lazy.PrivateBrowsingUtils.isBrowserPrivate(browser), + login.username ? "form_login" : "form_password", + !!autoFilledLoginGuid + ); + } + + // If password storage is disabled, bail out. + if (!lazy.LoginHelper.storageEnabled) { + return; + } + + if (!Services.logins.getLoginSavingEnabled(formOrigin)) { + lazy.log( + `Form submission ignored because saving is disabled for origin: ${formOrigin}.` + ); + return; + } + + let browsingContext = BrowsingContext.get(browsingContextId); + let framePrincipalOrigin = + browsingContext.currentWindowGlobal.documentPrincipal.origin; + + let formLogin = new LoginInfo( + formOrigin, + formActionOrigin, + null, + usernameField?.value ?? "", + newPasswordField.value, + usernameField?.name ?? "", + newPasswordField.name + ); + // we don't auto-save logins on form submit + let notifySaved = false; + + if (autoFilledLoginGuid) { + let loginsForGuid = await Services.logins.searchLoginsAsync({ + guid: autoFilledLoginGuid, + origin: formOrigin, // Ignored outside of GV. + }); + if ( + loginsForGuid.length == 1 && + loginsForGuid[0].password == formLogin.password && + (!formLogin.username || // Also cover cases where only the password is requested. + loginsForGuid[0].username == formLogin.username) + ) { + lazy.log( + "The filled login matches the form submission. Nothing to change." + ); + recordLoginUse(loginsForGuid[0]); + return; + } + } + + let existingLogin = null; + let canMatchExistingLogin = true; + // Below here we have one login per hostPort + action + username with the + // matching scheme being preferred. + const logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { + formActionOrigin, + }); + + const generatedPW = + gGeneratedPasswordsByPrincipalOrigin.get(framePrincipalOrigin); + const autoSavedStorageGUID = generatedPW?.storageGUID ?? ""; + + // If we didn't find a username field, but seem to be changing a + // password, allow the user to select from a list of applicable + // logins to update the password for. + if (!usernameField && oldPasswordField && logins.length) { + if (logins.length == 1) { + existingLogin = logins[0]; + + if (existingLogin.password == formLogin.password) { + recordLoginUse(existingLogin); + lazy.log( + "Not prompting to save/change since we have no username and the only saved password matches the new password." + ); + return; + } + + formLogin.username = existingLogin.username; + formLogin.usernameField = existingLogin.usernameField; + } else if (!generatedPW || generatedPW.value != newPasswordField.value) { + // Note: It's possible that that we already have the correct u+p saved + // but since we don't have the username, we don't know if the user is + // changing a second account to the new password so we ask anyways. + canMatchExistingLogin = false; + } + } + + if (canMatchExistingLogin && !existingLogin) { + existingLogin = this.#findSameLogin(logins, formLogin); + } + + const promptBrowser = lazy.LoginHelper.getBrowserForPrompt(browser); + const prompter = this._getPrompter(browser); + + if (!canMatchExistingLogin) { + prompter.promptToChangePasswordWithUsernames( + promptBrowser, + logins, + formLogin + ); + return; + } + + if (existingLogin) { + lazy.log("Found an existing login matching this form submission."); + + // Change password if needed. + if (existingLogin.password != formLogin.password) { + lazy.log("Passwords differ, prompting to change."); + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + dismissedPrompt, + notifySaved, + autoSavedStorageGUID, + autoFilledLoginGuid, + this.possibleValues + ); + } else if (!existingLogin.username && formLogin.username) { + lazy.log("Empty username update, prompting to change."); + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + dismissedPrompt, + notifySaved, + autoSavedStorageGUID, + autoFilledLoginGuid, + this.possibleValues + ); + } else { + recordLoginUse(existingLogin); + } + + return; + } + + // Prompt user to save login (via dialog or notification bar) + prompter.promptToSavePassword( + promptBrowser, + formLogin, + dismissedPrompt, + notifySaved, + autoFilledLoginGuid, + this.possibleValues + ); + } + + /** + * Performs validation of inputs against already-saved logins in order to determine whether and + * how these inputs can be stored. Depending on validation, will either no-op or show a 'save' + * or 'update' dialog to the user. + * + * This is called after any of the following: + * - The user edits a password + * - A generated password is filled + * - The user edits a username (when a matching password field has already been filled) + * + * @param {Element} browser + * @param {string} formOrigin + * @param {string} options.formActionOrigin + * @param {string?} options.autoFilledLoginGuid + * @param {Object} options.newPasswordField + * @param {Object?} options.usernameField + * @param {Element?} options.oldPasswordField + * @param {boolean} [options.triggeredByFillingGenerated = false] + */ + /* eslint-disable-next-line complexity */ + async _onPasswordEditedOrGenerated( + browser, + formOrigin, + { + formActionOrigin, + autoFilledLoginGuid, + newPasswordField, + usernameField = null, + oldPasswordField, + triggeredByFillingGenerated = false, + } + ) { + lazy.log( + `_onPasswordEditedOrGenerated: triggeredByFillingGenerated: ${triggeredByFillingGenerated}.` + ); + + // If password storage is disabled, bail out. + if (!lazy.LoginHelper.storageEnabled) { + return; + } + + if (!Services.logins.getLoginSavingEnabled(formOrigin)) { + // No UI should be shown to offer generation in this case but a user may + // disable saving for the site after already filling one and they may then + // edit it. + lazy.log(`Saving is disabled for origin: ${formOrigin}.`); + return; + } + + if (!newPasswordField.value) { + lazy.log("The password field is empty."); + return; + } + + if (!browser) { + lazy.log("The browser is gone."); + return; + } + + let browsingContext = this.getBrowsingContextToUse(); + if (!browsingContext) { + return; + } + + if (!triggeredByFillingGenerated && !Services.logins.isLoggedIn) { + // Don't show the dismissed doorhanger on "input" or "change" events + // when the Primary Password is locked + lazy.log( + "Edited field is not a generated password field, and Primary Password is locked." + ); + return; + } + + let framePrincipalOrigin = + browsingContext.currentWindowGlobal.documentPrincipal.origin; + + lazy.log("Got framePrincipalOrigin: ", framePrincipalOrigin); + + let formLogin = new LoginInfo( + formOrigin, + formActionOrigin, + null, + usernameField?.value ?? "", + newPasswordField.value, + usernameField?.name ?? "", + newPasswordField.name + ); + let existingLogin = null; + let canMatchExistingLogin = true; + let shouldAutoSaveLogin = triggeredByFillingGenerated; + let autoSavedLogin = null; + let notifySaved = false; + + if (autoFilledLoginGuid) { + let [matchedLogin] = await Services.logins.searchLoginsAsync({ + guid: autoFilledLoginGuid, + origin: formOrigin, // Ignored outside of GV. + }); + if ( + matchedLogin && + matchedLogin.password == formLogin.password && + (!formLogin.username || // Also cover cases where only the password is requested. + matchedLogin.username == formLogin.username) + ) { + lazy.log( + "The filled login matches the changed fields. Nothing to change." + ); + // We may want to update an existing doorhanger + existingLogin = matchedLogin; + } + } + + let generatedPW = + gGeneratedPasswordsByPrincipalOrigin.get(framePrincipalOrigin); + + // Below here we have one login per hostPort + action + username with the + // matching scheme being preferred. + let logins = await LoginManagerParent.searchAndDedupeLogins(formOrigin, { + formActionOrigin, + }); + // only used in the generated pw case where we auto-save + let formLoginWithoutUsername; + + if (triggeredByFillingGenerated && generatedPW) { + lazy.log("Got cached generatedPW."); + formLoginWithoutUsername = new LoginInfo( + formOrigin, + formActionOrigin, + null, + "", + newPasswordField.value + ); + + if (newPasswordField.value != generatedPW.value) { + // The user edited the field after generation to a non-empty value. + lazy.log("The field containing the generated password has changed."); + + // Record telemetry for the first edit + if (!generatedPW.edited) { + Services.telemetry.recordEvent( + "pwmgr", + "filled_field_edited", + "generatedpassword" + ); + lazy.log("filled_field_edited telemetry event recorded."); + generatedPW.edited = true; + } + } + + // This will throw if we can't look up the entry in the password/origin map + if (!generatedPW.filled) { + if (generatedPW.storageGUID) { + throw new Error( + "Generated password was saved in storage without being filled first" + ); + } + // record first use of this generated password + Services.telemetry.recordEvent( + "pwmgr", + "autocomplete_field", + "generatedpassword" + ); + lazy.log("autocomplete_field telemetry event recorded."); + generatedPW.filled = true; + } + + // We may have already autosaved this login + // Note that it could have been saved in a totally different tab in the session. + if (generatedPW.storageGUID) { + [autoSavedLogin] = await Services.logins.searchLoginsAsync({ + guid: generatedPW.storageGUID, + origin: formOrigin, // Ignored outside of GV. + }); + + if (autoSavedLogin) { + lazy.log("login to change is the auto-saved login."); + existingLogin = autoSavedLogin; + } + // The generated password login may have been deleted in the meantime. + // Proceed to maybe save a new login below. + } + generatedPW.value = newPasswordField.value; + + if (!existingLogin) { + lazy.log("Did not match generated-password login."); + + // Check if we already have a login saved for this site since we don't want to overwrite it in + // case the user still needs their old password to successfully complete a password change. + let matchedLogin = logins.find(login => + formLoginWithoutUsername.matches(login, true) + ); + if (matchedLogin) { + shouldAutoSaveLogin = false; + if (matchedLogin.password == formLoginWithoutUsername.password) { + // This login is already saved so show no new UI. + // We may want to update an existing doorhanger though... + lazy.log("Matching login already saved."); + existingLogin = matchedLogin; + } + lazy.log( + "_onPasswordEditedOrGenerated: Login with empty username already saved for this site." + ); + } + } + } + + // If we didn't find a username field, but seem to be changing a + // password, use the first match if there is only one + // If there's more than one we'll prompt to save with the initial formLogin + // and let the doorhanger code resolve this + if ( + !triggeredByFillingGenerated && + !existingLogin && + !usernameField && + oldPasswordField && + logins.length + ) { + if (logins.length == 1) { + existingLogin = logins[0]; + + if (existingLogin.password == formLogin.password) { + lazy.log( + "Not prompting to save/change since we have no username and the " + + "only saved password matches the new password." + ); + return; + } + + formLogin.username = existingLogin.username; + formLogin.usernameField = existingLogin.usernameField; + } else if (!generatedPW || generatedPW.value != newPasswordField.value) { + // Note: It's possible that that we already have the correct u+p saved + // but since we don't have the username, we don't know if the user is + // changing a second account to the new password so we ask anyways. + canMatchExistingLogin = false; + } + } + + if (canMatchExistingLogin && !existingLogin) { + existingLogin = this.#findSameLogin(logins, formLogin); + if (existingLogin) { + lazy.log("Matched saved login."); + } + } + + if (shouldAutoSaveLogin) { + if ( + existingLogin && + existingLogin == autoSavedLogin && + existingLogin.password !== formLogin.password + ) { + lazy.log("Updating auto-saved login."); + + Services.logins.modifyLogin( + existingLogin, + lazy.LoginHelper.newPropertyBag({ + password: formLogin.password, + }) + ); + notifySaved = true; + // Update `existingLogin` with the new password if modifyLogin didn't + // throw so that the prompts later uses the new password. + existingLogin.password = formLogin.password; + } else if (!autoSavedLogin) { + lazy.log("Auto-saving new login with empty username."); + existingLogin = await Services.logins.addLoginAsync( + formLoginWithoutUsername + ); + // Remember the GUID where we saved the generated password so we can update + // the login if the user later edits the generated password. + generatedPW.storageGUID = existingLogin.guid; + notifySaved = true; + } + } else { + lazy.log("Not auto-saving this login."); + } + + const prompter = this._getPrompter(browser); + const promptBrowser = lazy.LoginHelper.getBrowserForPrompt(browser); + + if (existingLogin) { + // Show a change doorhanger to allow modifying an already-saved login + // e.g. to add a username or update the password. + let autoSavedStorageGUID = ""; + if ( + generatedPW && + generatedPW.value == existingLogin.password && + generatedPW.storageGUID == existingLogin.guid + ) { + autoSavedStorageGUID = generatedPW.storageGUID; + } + + // Change password if needed. + if ( + (shouldAutoSaveLogin && !formLogin.username) || + existingLogin.password != formLogin.password + ) { + lazy.log( + `promptToChangePassword with autoSavedStorageGUID: ${autoSavedStorageGUID}` + ); + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + true, // dismissed prompt + notifySaved, + autoSavedStorageGUID, // autoSavedLoginGuid + autoFilledLoginGuid, + this.possibleValues + ); + } else if (!existingLogin.username && formLogin.username) { + lazy.log("Empty username update, prompting to change."); + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + true, // dismissed prompt + notifySaved, + autoSavedStorageGUID, // autoSavedLoginGuid + autoFilledLoginGuid, + this.possibleValues + ); + } else { + lazy.log("No change to existing login."); + // is there a doorhanger we should update? + let popupNotifications = promptBrowser.ownerGlobal.PopupNotifications; + let notif = popupNotifications.getNotification("password", browser); + lazy.log( + `_onPasswordEditedOrGenerated: Has doorhanger? ${ + notif && notif.dismissed + }` + ); + if (notif && notif.dismissed) { + prompter.promptToChangePassword( + promptBrowser, + existingLogin, + formLogin, + true, // dismissed prompt + notifySaved, + autoSavedStorageGUID, // autoSavedLoginGuid + autoFilledLoginGuid, + this.possibleValues + ); + } + } + return; + } + lazy.log("No matching login to save/update."); + prompter.promptToSavePassword( + promptBrowser, + formLogin, + true, // dismissed prompt + notifySaved, + autoFilledLoginGuid, + this.possibleValues + ); + } + + static get recipeParentPromise() { + if (!gRecipeManager) { + const { LoginRecipesParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginRecipes.sys.mjs" + ); + gRecipeManager = new LoginRecipesParent({ + defaults: Services.prefs.getStringPref("signon.recipes.path"), + }); + } + + return gRecipeManager.initializationPromise; + } +} + +LoginManagerParent.SUGGEST_IMPORT_DEBOUNCE_MS = 10000; + +XPCOMUtils.defineLazyPreferenceGetter( + LoginManagerParent, + "_repromptTimeout", + "signon.masterPasswordReprompt.timeout_ms", + 900000 +); // 15 Minutes diff --git a/toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs b/toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs new file mode 100644 index 0000000000..e1b1a85c75 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs @@ -0,0 +1,1116 @@ +/* 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/. */ + +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +/* eslint-disable block-scoped-var, no-var */ + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "usernameAutocompleteSearch", + "@mozilla.org/autocomplete/search;1?name=login-doorhanger-username", + "nsIAutoCompleteSimpleSearch" +); + +XPCOMUtils.defineLazyGetter(lazy, "strBundle", () => { + return Services.strings.createBundle( + "chrome://passwordmgr/locale/passwordmgr.properties" + ); +}); + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" +); + +/** + * The maximum age of the password in ms (using `timePasswordChanged`) whereby + * a user can toggle the password visibility in a doorhanger to add a username to + * a saved login. + */ +const VISIBILITY_TOGGLE_MAX_PW_AGE_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Constants for password prompt telemetry. + */ +const PROMPT_DISPLAYED = 0; +const PROMPT_ADD_OR_UPDATE = 1; +const PROMPT_NOTNOW_OR_DONTUPDATE = 2; +const PROMPT_NEVER = 3; +const PROMPT_DELETE = 3; + +/** + * The minimum age of a doorhanger in ms before it will get removed after a locationchange + */ +const NOTIFICATION_TIMEOUT_MS = 10 * 1000; // 10 seconds + +/** + * The minimum age of an attention-requiring dismissed doorhanger in ms + * before it will get removed after a locationchange + */ +const ATTENTION_NOTIFICATION_TIMEOUT_MS = 60 * 1000; // 1 minute + +function autocompleteSelected(popup) { + let doc = popup.ownerDocument; + let nameField = doc.getElementById("password-notification-username"); + let passwordField = doc.getElementById("password-notification-password"); + + let activeElement = nameField.ownerDocument.activeElement; + if (activeElement == nameField) { + popup.onUsernameSelect(); + } else if (activeElement == passwordField) { + popup.onPasswordSelect(); + } +} + +const observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + // nsIObserver + observe(subject, topic, data) { + switch (topic) { + case "autocomplete-did-enter-text": { + let input = subject.QueryInterface(Ci.nsIAutoCompleteInput); + autocompleteSelected(input.popupElement); + break; + } + } + }, +}; + +/** + * Implements interfaces for prompting the user to enter/save/change login info + * found in HTML forms. + */ +export class LoginManagerPrompter { + get classID() { + return Components.ID("{c47ff942-9678-44a5-bc9b-05e0d676c79c}"); + } + + get QueryInterface() { + return ChromeUtils.generateQI(["nsILoginManagerPrompter"]); + } + + /** + * Called when we detect a password or username that is not yet saved as + * an existing login. + * + * @param {Element} aBrowser + * The browser element that the request came from. + * @param {nsILoginInfo} aLogin + * The new login from the page form. + * @param {boolean} [dismissed = false] + * If the prompt should be automatically dismissed on being shown. + * @param {boolean} [notifySaved = false] + * Whether the notification should indicate that a login has been saved + * @param {string} [autoSavedLoginGuid = ""] + * A guid value for the old login to be removed if the changes match it + * to a different login + * @param {object?} possibleValues + * Contains values from anything that we think, but are not sure, might be + * a username or password. Has two properties, 'usernames' and 'passwords'. + * @param {Set} possibleValues.usernames + * @param {Set} possibleValues.passwords + */ + promptToSavePassword( + aBrowser, + aLogin, + dismissed = false, + notifySaved = false, + autoFilledLoginGuid = "", + possibleValues = undefined + ) { + lazy.log.debug("Prompting user to save login."); + let inPrivateBrowsing = PrivateBrowsingUtils.isBrowserPrivate(aBrowser); + let notification = LoginManagerPrompter._showLoginCaptureDoorhanger( + aBrowser, + aLogin, + "password-save", + { + dismissed: inPrivateBrowsing || dismissed, + extraAttr: notifySaved ? "attention" : "", + }, + possibleValues, + { + notifySaved, + autoFilledLoginGuid, + } + ); + Services.obs.notifyObservers(aLogin, "passwordmgr-prompt-save"); + + return { + dismiss() { + let { PopupNotifications } = aBrowser.ownerGlobal.wrappedJSObject; + PopupNotifications.remove(notification); + }, + }; + } + + /** + * Displays the PopupNotifications.sys.mjs doorhanger for password save or change. + * + * @param {Element} browser + * The browser to show the doorhanger on. + * @param {nsILoginInfo} login + * Login to save or change. For changes, this login should contain the + * new password and/or username + * @param {string} type + * This is "password-save" or "password-change" depending on the + * original notification type. This is used for telemetry and tests. + * @param {object} showOptions + * Options to pass along to PopupNotifications.show(). + * @param {bool} [options.notifySaved = false] + * Whether to indicate to the user that the login was already saved. + * @param {string} [options.messageStringID = undefined] + * An optional string ID to override the default message. + * @param {string} [options.autoSavedLoginGuid = ""] + * A string guid value for the auto-saved login to be removed if the changes + * match it to a different login + * @param {string} [options.autoFilledLoginGuid = ""] + * A string guid value for the autofilled login + * @param {object?} possibleValues + * Contains values from anything that we think, but are not sure, might be + * a username or password. Has two properties, 'usernames' and 'passwords'. + * @param {Set} possibleValues.usernames + * @param {Set} possibleValues.passwords + */ + static _showLoginCaptureDoorhanger( + browser, + login, + type, + showOptions = {}, + possibleValues = undefined, + { + notifySaved = false, + messageStringID, + autoSavedLoginGuid = "", + autoFilledLoginGuid = "", + } = {} + ) { + lazy.log.debug( + `Got autoSavedLoginGuid: ${autoSavedLoginGuid} and autoFilledLoginGuid ${autoFilledLoginGuid}.` + ); + + let saveMsgNames = { + prompt: login.username === "" ? "saveLoginMsgNoUser2" : "saveLoginMsg2", + buttonLabel: "saveLoginButtonAllow.label", + buttonAccessKey: "saveLoginButtonAllow.accesskey", + secondaryButtonLabel: "saveLoginButtonDeny.label", + secondaryButtonAccessKey: "saveLoginButtonDeny.accesskey", + }; + + let changeMsgNames = { + prompt: + login.username === "" ? "updateLoginMsgNoUser3" : "updateLoginMsg3", + buttonLabel: "updateLoginButtonText", + buttonAccessKey: "updateLoginButtonAccessKey", + secondaryButtonLabel: "updateLoginButtonDeny.label", + secondaryButtonAccessKey: "updateLoginButtonDeny.accesskey", + }; + + let initialMsgNames = + type == "password-save" ? saveMsgNames : changeMsgNames; + + if (messageStringID) { + changeMsgNames.prompt = messageStringID; + } + + let host = this._getShortDisplayHost(login.origin); + let promptMsg = + type == "password-save" + ? this._getLocalizedString(saveMsgNames.prompt, [host]) + : this._getLocalizedString(changeMsgNames.prompt, [host]); + + let histogramName = + type == "password-save" + ? "PWMGR_PROMPT_REMEMBER_ACTION" + : "PWMGR_PROMPT_UPDATE_ACTION"; + let histogram = Services.telemetry.getHistogramById(histogramName); + + let chromeDoc = browser.ownerDocument; + let currentNotification; + + let wasModifiedEvent = { + // Values are mutated + did_edit_un: "false", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }; + + let updateButtonStatus = element => { + let mainActionButton = element.button; + // Disable the main button inside the menu-button if the password field is empty. + if (!login.password.length) { + mainActionButton.setAttribute("disabled", true); + chromeDoc + .getElementById("password-notification-password") + .classList.add("popup-notification-invalid-input"); + } else { + mainActionButton.removeAttribute("disabled"); + chromeDoc + .getElementById("password-notification-password") + .classList.remove("popup-notification-invalid-input"); + } + }; + + let updateButtonLabel = () => { + if (!currentNotification) { + console.error("updateButtonLabel, no currentNotification"); + } + let foundLogins = lazy.LoginHelper.searchLoginsWithObject({ + formActionOrigin: login.formActionOrigin, + origin: login.origin, + httpRealm: login.httpRealm, + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + }); + + let logins = this._filterUpdatableLogins( + login, + foundLogins, + autoSavedLoginGuid + ); + let msgNames = !logins.length ? saveMsgNames : changeMsgNames; + + // Update the label based on whether this will be a new login or not. + let label = this._getLocalizedString(msgNames.buttonLabel); + let accessKey = this._getLocalizedString(msgNames.buttonAccessKey); + + // Update the labels for the next time the panel is opened. + currentNotification.mainAction.label = label; + currentNotification.mainAction.accessKey = accessKey; + + // Update the labels in real time if the notification is displayed. + let element = [...currentNotification.owner.panel.childNodes].find( + n => n.notification == currentNotification + ); + if (element) { + element.setAttribute("buttonlabel", label); + element.setAttribute("buttonaccesskey", accessKey); + updateButtonStatus(element); + } + }; + + let writeDataToUI = () => { + let nameField = chromeDoc.getElementById( + "password-notification-username" + ); + + nameField.placeholder = usernamePlaceholder; + nameField.value = login.username; + + let toggleCheckbox = chromeDoc.getElementById( + "password-notification-visibilityToggle" + ); + toggleCheckbox.removeAttribute("checked"); + let passwordField = chromeDoc.getElementById( + "password-notification-password" + ); + // Ensure the type is reset so the field is masked. + passwordField.type = "password"; + passwordField.value = login.password; + + updateButtonLabel(); + }; + + let readDataFromUI = () => { + login.username = chromeDoc.getElementById( + "password-notification-username" + ).value; + login.password = chromeDoc.getElementById( + "password-notification-password" + ).value; + }; + + let onInput = () => { + readDataFromUI(); + updateButtonLabel(); + }; + + let onUsernameInput = () => { + wasModifiedEvent.did_edit_un = "true"; + wasModifiedEvent.did_select_un = "false"; + onInput(); + }; + + let onUsernameSelect = () => { + wasModifiedEvent.did_edit_un = "false"; + wasModifiedEvent.did_select_un = "true"; + }; + + let onPasswordInput = () => { + wasModifiedEvent.did_edit_pw = "true"; + wasModifiedEvent.did_select_pw = "false"; + onInput(); + }; + + let onPasswordSelect = () => { + wasModifiedEvent.did_edit_pw = "false"; + wasModifiedEvent.did_select_pw = "true"; + }; + + let onKeyUp = e => { + if (e.key == "Enter") { + e.target.closest("popupnotification").button.doCommand(); + } + }; + + let onVisibilityToggle = commandEvent => { + let passwordField = chromeDoc.getElementById( + "password-notification-password" + ); + // Gets the caret position before changing the type of the textbox + let selectionStart = passwordField.selectionStart; + let selectionEnd = passwordField.selectionEnd; + passwordField.setAttribute( + "type", + commandEvent.target.checked ? "" : "password" + ); + if (!passwordField.hasAttribute("focused")) { + return; + } + passwordField.selectionStart = selectionStart; + passwordField.selectionEnd = selectionEnd; + }; + + let togglePopup = event => { + event.target.parentElement + .getElementsByClassName("ac-has-end-icon")[0] + .toggleHistoryPopup(); + }; + + let persistData = () => { + let foundLogins = lazy.LoginHelper.searchLoginsWithObject({ + formActionOrigin: login.formActionOrigin, + origin: login.origin, + httpRealm: login.httpRealm, + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + }); + + let logins = this._filterUpdatableLogins( + login, + foundLogins, + autoSavedLoginGuid + ); + let resolveBy = ["scheme", "timePasswordChanged"]; + logins = lazy.LoginHelper.dedupeLogins( + logins, + ["username"], + resolveBy, + login.origin + ); + // sort exact username matches to the top + logins.sort(l => (l.username == login.username ? -1 : 1)); + + lazy.log.debug(`Matched ${logins.length} logins.`); + + let loginToRemove; + let loginToUpdate = logins.shift(); + + if (logins.length && logins[0].guid == autoSavedLoginGuid) { + loginToRemove = logins.shift(); + } + if (logins.length) { + lazy.log.warn( + "persistData:", + logins.length, + "other updatable logins!", + logins.map(l => l.guid), + "loginToUpdate:", + loginToUpdate && loginToUpdate.guid, + "loginToRemove:", + loginToRemove && loginToRemove.guid + ); + // Proceed with updating the login with the best username match rather + // than returning and losing the edit. + } + + if (!loginToUpdate) { + // Create a new login, don't update an original. + // The original login we have been provided with might have its own + // metadata, but we don't want it propagated to the newly created one. + Services.logins.addLogin( + new LoginInfo( + login.origin, + login.formActionOrigin, + login.httpRealm, + login.username, + login.password, + login.usernameField, + login.passwordField + ) + ); + } else if ( + loginToUpdate.password == login.password && + loginToUpdate.username == login.username + ) { + // We only want to touch the login's use count and last used time. + lazy.log.debug(`Touch matched login: ${loginToUpdate.guid}.`); + Services.logins.recordPasswordUse( + loginToUpdate, + PrivateBrowsingUtils.isBrowserPrivate(browser), + loginToUpdate.username ? "form_password" : "form_login", + !!autoFilledLoginGuid + ); + } else { + lazy.log.debug(`Update matched login: ${loginToUpdate.guid}.`); + this._updateLogin(loginToUpdate, login); + // notify that this auto-saved login has been merged + if (loginToRemove && loginToRemove.guid == autoSavedLoginGuid) { + Services.obs.notifyObservers( + loginToRemove, + "passwordmgr-autosaved-login-merged" + ); + } + } + + if (loginToRemove) { + lazy.log.debug(`Removing login ${loginToRemove.guid}.`); + Services.logins.removeLogin(loginToRemove); + } + }; + + // The main action is the "Save" or "Update" button. + let mainAction = { + label: this._getLocalizedString(initialMsgNames.buttonLabel), + accessKey: this._getLocalizedString(initialMsgNames.buttonAccessKey), + callback: () => { + readDataFromUI(); + if ( + type == "password-save" && + !Services.policies.isAllowed("removeMasterPassword") + ) { + if (!lazy.LoginHelper.isPrimaryPasswordSet()) { + browser.ownerGlobal.openDialog( + "chrome://mozapps/content/preferences/changemp.xhtml", + "", + "centerscreen,chrome,modal,titlebar" + ); + if (!lazy.LoginHelper.isPrimaryPasswordSet()) { + return; + } + } + } + histogram.add(PROMPT_ADD_OR_UPDATE); + if (histogramName == "PWMGR_PROMPT_REMEMBER_ACTION") { + Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword"); + } else if (histogramName == "PWMGR_PROMPT_UPDATE_ACTION") { + Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved"); + } else { + throw new Error("Unknown histogram"); + } + + let eventObject; + if (type == "password-change") { + eventObject = "update"; + } else if (type == "password-save") { + eventObject = "save"; + } else { + throw new Error( + `Unexpected doorhanger type. Expected either 'password-save' or 'password-change', got ${type}` + ); + } + + Services.telemetry.recordEvent( + "pwmgr", + "doorhanger_submitted", + eventObject, + null, + wasModifiedEvent + ); + + persistData(); + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + histogramName + ); + browser.focus(); + }, + }; + + let secondaryActions = [ + { + label: this._getLocalizedString(initialMsgNames.secondaryButtonLabel), + accessKey: this._getLocalizedString( + initialMsgNames.secondaryButtonAccessKey + ), + callback: () => { + histogram.add(PROMPT_NOTNOW_OR_DONTUPDATE); + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + histogramName + ); + browser.focus(); + }, + }, + ]; + // Include a "Never for this site" button when saving a new password. + if (type == "password-save") { + secondaryActions.push({ + label: this._getLocalizedString("saveLoginButtonNever.label"), + accessKey: this._getLocalizedString("saveLoginButtonNever.accesskey"), + callback: () => { + histogram.add(PROMPT_NEVER); + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + histogramName + ); + Services.logins.setLoginSavingEnabled(login.origin, false); + browser.focus(); + }, + }); + } + + // Include a "Delete this login" button when updating an existing password + if (type == "password-change") { + secondaryActions.push({ + label: this._getLocalizedString("updateLoginButtonDelete.label"), + accessKey: this._getLocalizedString( + "updateLoginButtonDelete.accesskey" + ), + callback: async () => { + histogram.add(PROMPT_DELETE); + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + histogramName + ); + const matchingLogins = await Services.logins.searchLoginsAsync({ + guid: login.guid, + origin: login.origin, + }); + Services.logins.removeLogin(matchingLogins[0]); + browser.focus(); + // The "password-notification-icon" and "notification-icon-box" are hidden + // at this point, so approximate the location with the next closest, + // visible icon as the anchor. + const anchor = browser.ownerDocument.getElementById("identity-icon"); + lazy.log.debug("Showing the ConfirmationHint"); + anchor.ownerGlobal.ConfirmationHint.show( + anchor, + "confirmation-hint-login-removed" + ); + }, + }); + } + + let usernamePlaceholder = this._getLocalizedString("noUsernamePlaceholder"); + let togglePasswordLabel = this._getLocalizedString("togglePasswordLabel"); + let togglePasswordAccessKey = this._getLocalizedString( + "togglePasswordAccessKey2" + ); + + // .wrappedJSObject needed here -- see bug 422974 comment 5. + let { PopupNotifications } = browser.ownerGlobal.wrappedJSObject; + + let notificationID = "password"; + // keep attention notifications around for longer after a locationchange + const timeoutMs = + showOptions.dismissed && showOptions.extraAttr == "attention" + ? ATTENTION_NOTIFICATION_TIMEOUT_MS + : NOTIFICATION_TIMEOUT_MS; + + let options = Object.assign( + { + timeout: Date.now() + timeoutMs, + persistWhileVisible: true, + passwordNotificationType: type, + hideClose: true, + eventCallback(topic) { + switch (topic) { + case "showing": + lazy.log.debug("showing"); + currentNotification = this; + + // Record the first time this instance of the doorhanger is shown. + if (!this.timeShown) { + histogram.add(PROMPT_DISPLAYED); + Services.obs.notifyObservers( + null, + "weave:telemetry:histogram", + histogramName + ); + } + + chromeDoc + .getElementById("password-notification-password") + .removeAttribute("focused"); + chromeDoc + .getElementById("password-notification-username") + .removeAttribute("focused"); + chromeDoc + .getElementById("password-notification-username") + .addEventListener("input", onUsernameInput); + chromeDoc + .getElementById("password-notification-username") + .addEventListener("keyup", onKeyUp); + chromeDoc + .getElementById("password-notification-password") + .addEventListener("keyup", onKeyUp); + chromeDoc + .getElementById("password-notification-password") + .addEventListener("input", onPasswordInput); + chromeDoc + .getElementById("password-notification-username-dropmarker") + .addEventListener("click", togglePopup); + + LoginManagerPrompter._getUsernameSuggestions( + login, + possibleValues?.usernames + ).then(usernameSuggestions => { + let dropmarker = chromeDoc?.getElementById( + "password-notification-username-dropmarker" + ); + if (dropmarker) { + dropmarker.hidden = !usernameSuggestions.length; + } + + let usernameField = chromeDoc?.getElementById( + "password-notification-username" + ); + if (usernameField) { + usernameField.classList.toggle( + "ac-has-end-icon", + !!usernameSuggestions.length + ); + } + }); + + let toggleBtn = chromeDoc.getElementById( + "password-notification-visibilityToggle" + ); + + if ( + Services.prefs.getBoolPref( + "signon.rememberSignons.visibilityToggle" + ) + ) { + toggleBtn.addEventListener("command", onVisibilityToggle); + toggleBtn.setAttribute("label", togglePasswordLabel); + toggleBtn.setAttribute("accesskey", togglePasswordAccessKey); + + let hideToggle = + lazy.LoginHelper.isPrimaryPasswordSet() || + // Don't show the toggle when the login was autofilled + !!autoFilledLoginGuid || + // Dismissed-by-default prompts should still show the toggle. + (this.timeShown && this.wasDismissed) || + // If we are only adding a username then the password is + // one that is already saved and we don't want to reveal + // it as the submitter of this form may not be the account + // owner, they may just be using the saved password. + (messageStringID == "updateLoginMsgAddUsername2" && + login.timePasswordChanged < + Date.now() - VISIBILITY_TOGGLE_MAX_PW_AGE_MS); + toggleBtn.hidden = hideToggle; + } + + let popup = chromeDoc.getElementById("PopupAutoComplete"); + popup.onUsernameSelect = onUsernameSelect; + popup.onPasswordSelect = onPasswordSelect; + + LoginManagerPrompter._setUsernameAutocomplete( + login, + possibleValues?.usernames + ); + + break; + case "shown": { + lazy.log.debug("shown"); + writeDataToUI(); + let anchorIcon = this.anchorElement; + if (anchorIcon && this.options.extraAttr == "attention") { + anchorIcon.removeAttribute("extraAttr"); + delete this.options.extraAttr; + } + break; + } + case "dismissed": + // Note that this can run after `showing` but before `shown` upon tab switch. + this.wasDismissed = true; + // Fall through. + case "removed": { + // Note that this can run after `showing` and `shown` for the + // notification it's replacing. + lazy.log.debug(topic); + currentNotification = null; + + let usernameField = chromeDoc.getElementById( + "password-notification-username" + ); + usernameField.removeEventListener("input", onUsernameInput); + usernameField.removeEventListener("keyup", onKeyUp); + let passwordField = chromeDoc.getElementById( + "password-notification-password" + ); + passwordField.removeEventListener("input", onPasswordInput); + passwordField.removeEventListener("keyup", onKeyUp); + passwordField.removeEventListener("command", onVisibilityToggle); + chromeDoc + .getElementById("password-notification-username-dropmarker") + .removeEventListener("click", togglePopup); + break; + } + } + return false; + }, + }, + showOptions + ); + + let notification = PopupNotifications.show( + browser, + notificationID, + promptMsg, + "password-notification-icon", + mainAction, + secondaryActions, + options + ); + + if (notifySaved) { + let anchor = notification.anchorElement; + lazy.log.debug("Showing the ConfirmationHint."); + anchor.ownerGlobal.ConfirmationHint.show( + anchor, + "confirmation-hint-password-saved" + ); + } + + return notification; + } + + /** + * Called when we think we detect a password or username change for + * an existing login, when the form being submitted contains multiple + * password fields. + * + * @param {Element} aBrowser + * The browser element that the request came from. + * @param {nsILoginInfo} aOldLogin + * The old login we may want to update. + * @param {nsILoginInfo} aNewLogin + * The new login from the page form. + * @param {boolean} [dismissed = false] + * If the prompt should be automatically dismissed on being shown. + * @param {boolean} [notifySaved = false] + * Whether the notification should indicate that a login has been saved + * @param {string} [autoSavedLoginGuid = ""] + * A guid value for the old login to be removed if the changes match it + * to a different login + * @param {object?} possibleValues + * Contains values from anything that we think, but are not sure, might be + * a username or password. Has two properties, 'usernames' and 'passwords'. + * @param {Set} possibleValues.usernames + * @param {Set} possibleValues.passwords + */ + promptToChangePassword( + aBrowser, + aOldLogin, + aNewLogin, + dismissed = false, + notifySaved = false, + autoSavedLoginGuid = "", + autoFilledLoginGuid = "", + possibleValues = undefined + ) { + let login = aOldLogin.clone(); + login.origin = aNewLogin.origin; + login.formActionOrigin = aNewLogin.formActionOrigin; + login.password = aNewLogin.password; + login.username = aNewLogin.username; + + let messageStringID; + if ( + aOldLogin.username === "" && + login.username !== "" && + login.password == aOldLogin.password + ) { + // If the saved password matches the password we're prompting with then we + // are only prompting to let the user add a username since there was one in + // the form. Change the message so the purpose of the prompt is clearer. + messageStringID = "updateLoginMsgAddUsername2"; + } + + let notification = LoginManagerPrompter._showLoginCaptureDoorhanger( + aBrowser, + login, + "password-change", + { + dismissed, + extraAttr: notifySaved ? "attention" : "", + }, + possibleValues, + { + notifySaved, + messageStringID, + autoSavedLoginGuid, + autoFilledLoginGuid, + } + ); + + let oldGUID = aOldLogin.QueryInterface(Ci.nsILoginMetaInfo).guid; + Services.obs.notifyObservers( + aNewLogin, + "passwordmgr-prompt-change", + oldGUID + ); + + return { + dismiss() { + let { PopupNotifications } = aBrowser.ownerGlobal.wrappedJSObject; + PopupNotifications.remove(notification); + }, + }; + } + + /** + * Called when we detect a password change in a form submission, but we + * don't know which existing login (username) it's for. Asks the user + * to select a username and confirm the password change. + * + * Note: The caller doesn't know the username for aNewLogin, so this + * function fills in .username and .usernameField with the values + * from the login selected by the user. + */ + promptToChangePasswordWithUsernames(browser, logins, aNewLogin) { + lazy.log.debug( + `Prompting user to change passowrd for username with count: ${logins.length}.` + ); + + var usernames = logins.map( + l => l.username || LoginManagerPrompter._getLocalizedString("noUsername") + ); + var dialogText = + LoginManagerPrompter._getLocalizedString("userSelectText2"); + var dialogTitle = LoginManagerPrompter._getLocalizedString( + "passwordChangeTitle" + ); + var selectedIndex = { value: null }; + + // If user selects ok, outparam.value is set to the index + // of the selected username. + var ok = Services.prompt.select( + browser.ownerGlobal, + dialogTitle, + dialogText, + usernames, + selectedIndex + ); + if (ok) { + // Now that we know which login to use, modify its password. + var selectedLogin = logins[selectedIndex.value]; + lazy.log.debug(`Updating password for origin: ${aNewLogin.origin}.`); + var newLoginWithUsername = Cc[ + "@mozilla.org/login-manager/loginInfo;1" + ].createInstance(Ci.nsILoginInfo); + newLoginWithUsername.init( + aNewLogin.origin, + aNewLogin.formActionOrigin, + aNewLogin.httpRealm, + selectedLogin.username, + aNewLogin.password, + selectedLogin.usernameField, + aNewLogin.passwordField + ); + LoginManagerPrompter._updateLogin(selectedLogin, newLoginWithUsername); + } + } + + /* ---------- Internal Methods ---------- */ + + /** + * Helper method to update and persist an existing nsILoginInfo object with new property values. + */ + static _updateLogin(login, aNewLogin) { + var now = Date.now(); + var propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + propBag.setProperty("formActionOrigin", aNewLogin.formActionOrigin); + propBag.setProperty("origin", aNewLogin.origin); + propBag.setProperty("password", aNewLogin.password); + propBag.setProperty("username", aNewLogin.username); + // Explicitly set the password change time here (even though it would + // be changed automatically), to ensure that it's exactly the same + // value as timeLastUsed. + propBag.setProperty("timePasswordChanged", now); + propBag.setProperty("timeLastUsed", now); + propBag.setProperty("timesUsedIncrement", 1); + // Note that we don't call `recordPasswordUse` so telemetry won't record a + // use in this case though that is normally correct since we would instead + // record the save/update in a separate probe and recording it in both would + // be wrong. + Services.logins.modifyLogin(login, propBag); + } + + /** + * Can be called as: + * _getLocalizedString("key1"); + * _getLocalizedString("key2", ["arg1"]); + * _getLocalizedString("key3", ["arg1", "arg2"]); + * (etc) + * + * Returns the localized string for the specified key, + * formatted if required. + * + */ + static _getLocalizedString(key, formatArgs) { + if (formatArgs) { + return lazy.strBundle.formatStringFromName(key, formatArgs); + } + return lazy.strBundle.GetStringFromName(key); + } + + /** + * Converts a login's origin field to a short string for + * prompting purposes. Eg, "http://foo.com" --> "foo.com", or + * "ftp://www.site.co.uk" --> "site.co.uk". + */ + static _getShortDisplayHost(aURIString) { + var displayHost; + + var idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + try { + var uri = Services.io.newURI(aURIString); + var baseDomain = Services.eTLD.getBaseDomain(uri); + displayHost = idnService.convertToDisplayIDN(baseDomain, {}); + } catch (e) { + lazy.log.warn(`Couldn't process supplied URIString: ${aURIString}`); + } + + if (!displayHost) { + displayHost = aURIString; + } + + return displayHost; + } + + /** + * This function looks for existing logins that can be updated + * to match a submitted login, instead of creating a new one. + * + * Given a login and a loginList, it filters the login list + * to find every login with either: + * - the same username as aLogin + * - the same password as aLogin and an empty username + * so the user can add a username. + * - the same guid as the given login when it has an empty username + * + * @param {nsILoginInfo} aLogin + * login to use as filter. + * @param {nsILoginInfo[]} aLoginList + * Array of logins to filter. + * @param {String} includeGUID + * guid value for login that not be filtered out + * @returns {nsILoginInfo[]} the filtered array of logins. + */ + static _filterUpdatableLogins(aLogin, aLoginList, includeGUID) { + return aLoginList.filter( + l => + l.username == aLogin.username || + (l.password == aLogin.password && !l.username) || + (includeGUID && includeGUID == l.guid) + ); + } + + /** + * Set the values that will be used the next time the username autocomplete popup is opened. + * + * @param {nsILoginInfo} login - used only for its information about the current domain. + * @param {Set?} possibleUsernames - values that we believe may be new/changed login usernames. + */ + static async _setUsernameAutocomplete(login, possibleUsernames = new Set()) { + let result = Cc["@mozilla.org/autocomplete/simple-result;1"].createInstance( + Ci.nsIAutoCompleteSimpleResult + ); + result.setDefaultIndex(0); + + let usernames = await this._getUsernameSuggestions( + login, + possibleUsernames + ); + for (let { text, style } of usernames) { + let value = text; + let comment = ""; + let image = ""; + let _style = style; + result.appendMatch(value, comment, image, _style); + } + + if (usernames.length) { + result.setSearchResult(Ci.nsIAutoCompleteResult.RESULT_SUCCESS); + } else { + result.setSearchResult(Ci.nsIAutoCompleteResult.RESULT_NOMATCH); + } + + lazy.usernameAutocompleteSearch.overrideNextResult(result); + } + + /** + * @param {nsILoginInfo} login - used only for its information about the current domain. + * @param {Set?} possibleUsernames - values that we believe may be new/changed login usernames. + * + * @returns {object[]} an ordered list of usernames to be used the next time the username autocomplete popup is opened. + */ + static async _getUsernameSuggestions(login, possibleUsernames = new Set()) { + if (!Services.prefs.getBoolPref("signon.capture.inputChanges.enabled")) { + return []; + } + + // Don't reprompt for Primary Password, as we already prompted at least once + // to show the doorhanger if it is locked + if (!Services.logins.isLoggedIn) { + return []; + } + + let baseDomainLogins = await Services.logins.searchLoginsAsync({ + origin: login.origin, + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + acceptDifferentSubdomains: true, + }); + + let saved = baseDomainLogins.map(login => { + return { text: login.username, style: "login" }; + }); + let possible = [...possibleUsernames].map(username => { + return { text: username, style: "possible-username" }; + }); + + return possible + .concat(saved) + .reduce((acc, next) => { + let alreadyInAcc = + acc.findIndex(entry => entry.text == next.text) != -1; + if (!alreadyInAcc) { + acc.push(next); + } else if (next.style == "possible-username") { + let existingIndex = acc.findIndex(entry => entry.text == next.text); + acc[existingIndex] = next; + } + return acc; + }, []) + .filter(suggestion => !!suggestion.text); + } +} + +// Add this observer once for the process. +Services.obs.addObserver(observer, "autocomplete-did-enter-text"); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + return lazy.LoginHelper.createLogger("LoginManagerPrompter"); +}); diff --git a/toolkit/components/passwordmgr/LoginRecipes.sys.mjs b/toolkit/components/passwordmgr/LoginRecipes.sys.mjs new file mode 100644 index 0000000000..148729d060 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginRecipes.sys.mjs @@ -0,0 +1,383 @@ +/* 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 REQUIRED_KEYS = ["hosts"]; +const OPTIONAL_KEYS = [ + "description", + "notPasswordSelector", + "notUsernameSelector", + "passwordSelector", + "pathRegex", + "usernameSelector", + "schema", + "id", + "last_modified", +]; +const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS); + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => + lazy.LoginHelper.createLogger("LoginRecipes") +); + +/** + * Create an instance of the object to manage recipes in the parent process. + * Consumers should wait until {@link initializationPromise} resolves before + * calling methods on the object. + * + * @constructor + * @param {String} [aOptions.defaults=null] the URI to load the recipes from. + * If it's null, nothing is loaded. + * + */ +export function LoginRecipesParent(aOptions = { defaults: null }) { + if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + throw new Error( + "LoginRecipesParent should only be used from the main process" + ); + } + this._defaults = aOptions.defaults; + this.reset(); +} + +LoginRecipesParent.prototype = { + /** + * Promise resolved with an instance of itself when the module is ready. + * + * @type {Promise} + */ + initializationPromise: null, + + /** + * @type {bool} Whether default recipes were loaded at construction time. + */ + _defaults: null, + + /** + * @type {Map} Map of hosts (including non-default port numbers) to Sets of recipes. + * e.g. "example.com:8080" => Set({...}) + */ + _recipesByHost: null, + + /** + * @type {Object} Instance of Remote Settings client that has access to the + * "password-recipes" collection + */ + _rsClient: null, + + /** + * @param {Object} aRecipes an object containing recipes to load for use. The object + * should be compatible with JSON (e.g. no RegExp). + * @return {Promise} resolving when the recipes are loaded + */ + load(aRecipes) { + let recipeErrors = 0; + for (let rawRecipe of aRecipes.siteRecipes) { + try { + rawRecipe.pathRegex = rawRecipe.pathRegex + ? new RegExp(rawRecipe.pathRegex) + : undefined; + this.add(rawRecipe); + } catch (e) { + recipeErrors++; + lazy.log.error("Error loading recipe.", rawRecipe, e); + } + } + if (recipeErrors) { + return Promise.reject(`There were ${recipeErrors} recipe error(s)`); + } + return Promise.resolve(); + }, + + /** + * Reset the set of recipes to the ones from the time of construction. + */ + reset() { + lazy.log.debug("Resetting recipes with defaults:", this._defaults); + this._recipesByHost = new Map(); + if (this._defaults) { + let initPromise; + /** + * Both branches rely on a JSON dump of the Remote Settings collection, packaged both in Desktop and Android. + * The «legacy» mode will read the dump directly from the packaged resources. + * With Remote Settings, the dump is used to initialize the local database without network, + * and the list of password recipes can be refreshed without restarting and without software update. + */ + if (lazy.LoginHelper.remoteRecipesEnabled) { + if (!this._rsClient) { + this._rsClient = lazy.RemoteSettings( + lazy.LoginHelper.remoteRecipesCollection + ); + // Set up sync observer to update local recipes from Remote Settings recipes + this._rsClient.on("sync", event => this.onRemoteSettingsSync(event)); + } + initPromise = this._rsClient.get(); + } else if (this._defaults.startsWith("resource://")) { + initPromise = fetch(this._defaults) + .then(resp => resp.json()) + .then(({ data }) => data); + } else { + lazy.log.error( + "Invalid recipe path found, setting empty recipes list!" + ); + initPromise = new Promise(() => []); + } + this.initializationPromise = initPromise.then(async siteRecipes => { + Services.ppmm.broadcastAsyncMessage("clearRecipeCache"); + await this.load({ siteRecipes }); + return this; + }); + } else { + this.initializationPromise = Promise.resolve(this); + } + }, + + /** + * Validate the recipe is sane and then add it to the set of recipes. + * + * @param {Object} recipe + */ + add(recipe) { + let recipeKeys = Object.keys(recipe); + let unknownKeys = recipeKeys.filter(key => !SUPPORTED_KEYS.includes(key)); + if (unknownKeys.length) { + throw new Error( + "The following recipe keys aren't supported: " + unknownKeys.join(", ") + ); + } + + let missingRequiredKeys = REQUIRED_KEYS.filter( + key => !recipeKeys.includes(key) + ); + if (missingRequiredKeys.length) { + throw new Error( + "The following required recipe keys are missing: " + + missingRequiredKeys.join(", ") + ); + } + + if (!Array.isArray(recipe.hosts)) { + throw new Error("'hosts' must be a array"); + } + + if (!recipe.hosts.length) { + throw new Error("'hosts' must be a non-empty array"); + } + + if (recipe.pathRegex && recipe.pathRegex.constructor.name != "RegExp") { + throw new Error("'pathRegex' must be a regular expression"); + } + + const OPTIONAL_STRING_PROPS = [ + "description", + "passwordSelector", + "usernameSelector", + ]; + for (let prop of OPTIONAL_STRING_PROPS) { + if (recipe[prop] && typeof recipe[prop] != "string") { + throw new Error(`'${prop}' must be a string`); + } + } + + // Add the recipe to the map for each host + for (let host of recipe.hosts) { + if (!this._recipesByHost.has(host)) { + this._recipesByHost.set(host, new Set()); + } + this._recipesByHost.get(host).add(recipe); + } + }, + + /** + * Currently only exact host matches are returned but this will eventually handle parent domains. + * + * @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com) + * @return {Set} of recipes that apply to the host ordered by host priority + */ + getRecipesForHost(aHost) { + let hostRecipes = this._recipesByHost.get(aHost); + if (!hostRecipes) { + return new Set(); + } + + return hostRecipes; + }, + + /** + * Handles the Remote Settings sync event for the "password-recipes" collection. + * + * @param {Object} aEvent + * @param {Array} event.current Records in the "password-recipes" collection after the sync event + * @param {Array} event.created Records that were created with this particular sync + * @param {Array} event.updated Records that were updated with this particular sync + * @param {Array} event.deleted Records that were deleted with this particular sync + */ + onRemoteSettingsSync(aEvent) { + this._recipesByHost = new Map(); + let { + data: { current }, + } = aEvent; + let recipes = { + siteRecipes: current, + }; + Services.ppmm.broadcastAsyncMessage("clearRecipeCache"); + this.load(recipes); + }, +}; + +export const LoginRecipesContent = { + _recipeCache: new WeakMap(), + + _clearRecipeCache() { + lazy.log.debug("Clearing recipe cache."); + this._recipeCache = new WeakMap(); + }, + + /** + * Locally caches recipes for a given host. + * + * @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com) + * @param {Object} win - the window of the host + * @param {Set} recipes - recipes that apply to the host + */ + cacheRecipes(aHost, win, recipes) { + let recipeMap = this._recipeCache.get(win); + + if (!recipeMap) { + recipeMap = new Map(); + this._recipeCache.set(win, recipeMap); + } + + recipeMap.set(aHost, recipes); + }, + + /** + * Tries to fetch recipes for a given host, using a local cache if possible. + * Otherwise, the recipes are cached for later use. + * + * @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com) + * @param {Object} win - the window of the host + * @return {Set} of recipes that apply to the host + */ + getRecipes(aHost, win) { + let recipes; + const recipeMap = this._recipeCache.get(win); + + if (recipeMap) { + recipes = recipeMap.get(aHost); + + if (recipes) { + return recipes; + } + } + + if (!Cu.isInAutomation) { + // this is a blocking call we expect in tests and rarely expect in + // production, for example when Remote Settings are updated. + lazy.log.warn(`Falling back to a synchronous message for: ${aHost}.`); + } + recipes = Services.cpmm.sendSyncMessage("PasswordManager:findRecipes", { + formOrigin: aHost, + })[0]; + this.cacheRecipes(aHost, win, recipes); + + return recipes; + }, + + /** + * @param {Set} aRecipes - Possible recipes that could apply to the form + * @param {FormLike} aForm - We use a form instead of just a URL so we can later apply + * tests to the page contents. + * @return {Set} a subset of recipes that apply to the form with the order preserved + */ + _filterRecipesForForm(aRecipes, aForm) { + let formDocURL = aForm.ownerDocument.location; + let hostRecipes = aRecipes; + let recipes = new Set(); + if (!hostRecipes) { + return recipes; + } + + for (let hostRecipe of hostRecipes) { + if ( + hostRecipe.pathRegex && + !hostRecipe.pathRegex.test(formDocURL.pathname) + ) { + continue; + } + recipes.add(hostRecipe); + } + + return recipes; + }, + + /** + * Given a set of recipes that apply to the host, choose the one most applicable for + * overriding login fields in the form. + * + * @param {Set} aRecipes The set of recipes to consider for the form + * @param {FormLike} aForm The form where login fields exist. + * @return {Object} The recipe that is most applicable for the form. + */ + getFieldOverrides(aRecipes, aForm) { + let recipes = this._filterRecipesForForm(aRecipes, aForm); + lazy.log.debug(`Filtered recipes size: ${recipes.size}.`); + if (!recipes.size) { + return null; + } + + let chosenRecipe = null; + // Find the first (most-specific recipe that involves field overrides). + for (let recipe of recipes) { + if ( + !recipe.usernameSelector && + !recipe.passwordSelector && + !recipe.notUsernameSelector && + !recipe.notPasswordSelector + ) { + continue; + } + + chosenRecipe = recipe; + break; + } + + return chosenRecipe; + }, + + /** + * @param {HTMLElement} aParent the element to query for the selector from. + * @param {CSSSelector} aSelector the CSS selector to query for the login field. + * @return {HTMLElement|null} + */ + queryLoginField(aParent, aSelector) { + if (!aSelector) { + return null; + } + let field = aParent.ownerDocument.querySelector(aSelector); + if (!field) { + lazy.log.debug(`Login field selector wasn't matched: ${aSelector}.`); + return null; + } + // ownerGlobal doesn't exist in content privileged windows. + if ( + // eslint-disable-next-line mozilla/use-ownerGlobal + !aParent.ownerDocument.defaultView.HTMLInputElement.isInstance(field) + ) { + lazy.log.warn( + `Login field with selector ${aSelector} isn't an so ignoring it.` + ); + return null; + } + return field; + }, +}; diff --git a/toolkit/components/passwordmgr/LoginRelatedRealms.sys.mjs b/toolkit/components/passwordmgr/LoginRelatedRealms.sys.mjs new file mode 100644 index 0000000000..bcfd675b4b --- /dev/null +++ b/toolkit/components/passwordmgr/LoginRelatedRealms.sys.mjs @@ -0,0 +1,110 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let logger = lazy.LoginHelper.createLogger("LoginRelatedRealms"); + return logger; +}); + +export class LoginRelatedRealmsParent extends JSWindowActorParent { + /** + * @type RemoteSettingsClient + * + * @memberof LoginRelatedRealmsParent + */ + _sharedCredentialsClient = null; + /** + * @type string[][] + * + * @memberof LoginRelatedRealmsParent + */ + _relatedDomainsList = [[]]; + + /** + * Handles the Remote Settings sync event + * + * @param {Object} aEvent + * @param {Array} aEvent.current Records that are currently in the collection after the sync event + * @param {Array} aEvent.created Records that were created + * @param {Array} aEvent.updated Records that were updated + * @param {Array} aEvent.deleted Records that were deleted + * @memberof LoginRelatedRealmsParent + */ + onRemoteSettingsSync(aEvent) { + let { + data: { current }, + } = aEvent; + this._relatedDomainsList = current; + } + + async getSharedCredentialsCollection() { + if (!this._sharedCredentialsClient) { + this._sharedCredentialsClient = lazy.RemoteSettings( + lazy.LoginHelper.relatedRealmsCollection + ); + this._sharedCredentialsClient.on("sync", event => + this.onRemoteSettingsSync(event) + ); + this._relatedDomainsList = await this._sharedCredentialsClient.get(); + } + return this._relatedDomainsList; + } + + /** + * Determine if there are any related realms of this `formOrigin` using the related realms collection + * @param {string} formOrigin A form origin + * @return {string[]} filteredRealms An array of domains related to the `formOrigin` + * @async + * @memberof LoginRelatedRealmsParent + */ + async findRelatedRealms(formOrigin) { + try { + let formOriginURI = Services.io.newURI(formOrigin); + let originDomain = formOriginURI.host; + let [{ relatedRealms } = {}] = + await this.getSharedCredentialsCollection(); + if (!relatedRealms) { + return []; + } + let filterOriginIndex; + let shouldInclude = false; + let filteredRealms = relatedRealms.filter(_realms => { + for (let relatedOrigin of _realms) { + // We can't have an origin that matches multiple entries in our related realms collection + // so we exit the loop early + if (shouldInclude) { + return false; + } + if (Services.eTLD.hasRootDomain(originDomain, relatedOrigin)) { + shouldInclude = true; + break; + } + } + return shouldInclude; + }); + // * Filtered realms is a nested array due to its structure in Remote Settings + filteredRealms = filteredRealms.flat(); + + filterOriginIndex = filteredRealms.indexOf(originDomain); + // Removing the current formOrigin match if it exists in the related realms + // so that we don't return duplicates when we search for logins + if (filterOriginIndex !== -1) { + filteredRealms.splice(filterOriginIndex, 1); + } + return filteredRealms; + } catch (e) { + lazy.log.error(e); + return []; + } + } +} diff --git a/toolkit/components/passwordmgr/LoginStore.sys.mjs b/toolkit/components/passwordmgr/LoginStore.sys.mjs new file mode 100644 index 0000000000..acb5a6365c --- /dev/null +++ b/toolkit/components/passwordmgr/LoginStore.sys.mjs @@ -0,0 +1,182 @@ +/* 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/. */ + +/** + * Handles serialization of the data and persistence into a file. + * + * The file is stored in JSON format, without indentation, using UTF-8 encoding. + * With indentation applied, the file would look like this: + * + * { + * "logins": [ + * { + * "id": 2, + * "hostname": "http://www.example.com", + * "httpRealm": null, + * "formSubmitURL": "http://www.example.com", + * "usernameField": "username_field", + * "passwordField": "password_field", + * "encryptedUsername": "...", + * "encryptedPassword": "...", + * "guid": "...", + * "encType": 1, + * "timeCreated": 1262304000000, + * "timeLastUsed": 1262304000000, + * "timePasswordChanged": 1262476800000, + * "timesUsed": 1 + * // only present if other clients had fields we didn't know about + * "encryptedUnknownFields: "...", + * }, + * { + * "id": 4, + * (...) + * } + * ], + * "nextId": 10, + * "version": 1 + * } + */ + +// Globals + +import { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.js", + FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.js", +}); + +/** + * Current data version assigned by the code that last touched the data. + * + * This number should be updated only when it is important to understand whether + * an old version of the code has touched the data, for example to execute an + * update logic. In most cases, this number should not be changed, in + * particular when no special one-time update logic is needed. + * + * For example, this number should NOT be changed when a new optional field is + * added to a login entry. + */ +const kDataVersion = 3; + +const MAX_DATE_MS = 8640000000000000; + +// LoginStore + +/** + * Inherits from JSONFile and handles serialization of login-related data and + * persistence into a file. + * + * @param aPath + * String containing the file path where data should be saved. + */ +export function LoginStore(aPath, aBackupPath = "") { + JSONFile.call(this, { + path: aPath, + dataPostProcessor: this._dataPostProcessor.bind(this), + backupTo: aBackupPath, + }); +} + +LoginStore.prototype = Object.create(JSONFile.prototype); +LoginStore.prototype.constructor = LoginStore; + +LoginStore.prototype._save = async function () { + await JSONFile.prototype._save.call(this); + // Notify tests that writes to the login store is complete. + Services.obs.notifyObservers(null, "password-storage-updated"); + + if (this._options.backupTo) { + await this._backupHandler(); + } +}; + +/** + * Delete logins backup file if the last saved login was removed using + * removeLogin() or if all logins were removed at once using removeAllUserFacingLogins(). + * Note that if the user has a fxa key stored as a login, we just update the + * backup to only store the key when the last saved user facing login is removed. + */ +LoginStore.prototype._backupHandler = async function () { + const logins = this._data.logins; + // Return early if more than one login is stored because the backup won't need + // updating in this case. + if (logins.length > 1) { + return; + } + + // If one login is stored and it's a fxa sync key, we update the backup to store the + // key only. + if ( + logins.length && + logins[0].hostname == lazy.FXA_PWDMGR_HOST && + logins[0].httpRealm == lazy.FXA_PWDMGR_REALM + ) { + try { + await IOUtils.copy(this.path, this._options.backupTo); + + // This notification is specifically sent out for a test. + Services.obs.notifyObservers(null, "logins-backup-updated"); + } catch (ex) { + console.error(ex); + } + } else if (!logins.length) { + // If no logins are stored anymore, delete backup. + await IOUtils.remove(this._options.backupTo, { + ignoreAbsent: true, + }); + } +}; + +/** + * Synchronously work on the data just loaded into memory. + */ +LoginStore.prototype._dataPostProcessor = function (data) { + if (data.nextId === undefined) { + data.nextId = 1; + } + + // Create any arrays that are not present in the saved file. + if (!data.logins) { + data.logins = []; + } + + if (!data.potentiallyVulnerablePasswords) { + data.potentiallyVulnerablePasswords = []; + } + + if (!data.dismissedBreachAlertsByLoginGUID) { + data.dismissedBreachAlertsByLoginGUID = {}; + } + + // sanitize dates in logins + if (!("version" in data) || data.version < 3) { + let dateProperties = ["timeCreated", "timeLastUsed", "timePasswordChanged"]; + let now = Date.now(); + function getEarliestDate(login, defaultDate) { + let earliestDate = dateProperties.reduce((earliest, pname) => { + let ts = login[pname]; + return !ts ? earliest : Math.min(ts, earliest); + }, defaultDate); + return earliestDate; + } + for (let login of data.logins) { + for (let pname of dateProperties) { + let earliestDate; + if (!login[pname] || login[pname] > MAX_DATE_MS) { + login[pname] = + earliestDate || (earliestDate = getEarliestDate(login, now)); + } + } + } + } + + // Indicate that the current version of the code has touched the file. + data.version = kDataVersion; + + return data; +}; diff --git a/toolkit/components/passwordmgr/NewPasswordModel.sys.mjs b/toolkit/components/passwordmgr/NewPasswordModel.sys.mjs new file mode 100644 index 0000000000..142b2e1662 --- /dev/null +++ b/toolkit/components/passwordmgr/NewPasswordModel.sys.mjs @@ -0,0 +1,681 @@ +/* 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/. */ + +/** + * Machine learning model for identifying new password input elements + * using Fathom. + */ + +import { + dom, + element, + out, + rule, + ruleset, + score, + type, + utils, + clusters, +} from "resource://gre/modules/third_party/fathom/fathom.mjs"; + +let { identity, isVisible, min, setDefault } = utils; +let { euclidean } = clusters; + +/** + * ----- Start of model ----- + * + * Everything below this comment up to the "End of model" comment is copied from: + * https://github.com/mozilla-services/fathom-login-forms/blob/78d4bf8f301b5aa6d62c06b45e826a0dd9df1afa/new-password/rulesets.js#L14-L613 + * Deviations from that file: + * - Remove import statements, instead using ``ChromeUtils.defineModuleGetter`` and destructuring assignments above. + * - Set ``DEVELOPMENT`` constant to ``false``. + */ + +// Whether this is running in the Vectorizer, rather than in-application, in a +// privileged Chrome context +const DEVELOPMENT = false; + +// Run me with confidence cutoff = 0.75. +const coefficients = { + new: [ + ["hasNewLabel", 2.9195094108581543], + ["hasConfirmLabel", 2.1672143936157227], + ["hasCurrentLabel", -2.1813206672668457], + ["closestLabelMatchesNew", 2.965045213699341], + ["closestLabelMatchesConfirm", 2.698647975921631], + ["closestLabelMatchesCurrent", -2.147423505783081], + ["hasNewAriaLabel", 2.8312134742736816], + ["hasConfirmAriaLabel", 1.5153108835220337], + ["hasCurrentAriaLabel", -4.368860244750977], + ["hasNewPlaceholder", 1.4374250173568726], + ["hasConfirmPlaceholder", 1.717592477798462], + ["hasCurrentPlaceholder", -1.9401700496673584], + ["forgotPasswordInFormLinkTextContent", -0.6736700534820557], + ["forgotPasswordInFormLinkHref", -1.3025357723236084], + ["forgotPasswordInFormLinkTitle", -2.9019577503204346], + ["forgotInFormLinkTextContent", -1.2455425262451172], + ["forgotInFormLinkHref", 0.4884686768054962], + ["forgotPasswordInFormButtonTextContent", -0.8015769720077515], + ["forgotPasswordOnPageLinkTextContent", 0.04422328248620033], + ["forgotPasswordOnPageLinkHref", -1.0331494808197021], + ["forgotPasswordOnPageLinkTitle", -0.08798415213823318], + ["forgotPasswordOnPageButtonTextContent", -1.5396910905838013], + ["elementAttrsMatchNew", 2.8492355346679688], + ["elementAttrsMatchConfirm", 1.9043376445770264], + ["elementAttrsMatchCurrent", -2.056903839111328], + ["elementAttrsMatchPassword1", 1.5833512544631958], + ["elementAttrsMatchPassword2", 1.3928000926971436], + ["elementAttrsMatchLogin", 1.738782525062561], + ["formAttrsMatchRegister", 2.1345033645629883], + ["formHasRegisterAction", 1.9337323904037476], + ["formButtonIsRegister", 3.0930404663085938], + ["formAttrsMatchLogin", -0.5816961526870728], + ["formHasLoginAction", -0.18886367976665497], + ["formButtonIsLogin", -2.332860231399536], + ["hasAutocompleteCurrentPassword", -0.029974736273288727], + ["formHasRememberMeCheckbox", 0.8600837588310242], + ["formHasRememberMeLabel", 0.06663893908262253], + ["formHasNewsletterCheckbox", -1.4851698875427246], + ["formHasNewsletterLabel", 2.416919231414795], + ["closestHeaderAboveIsLoginy", -2.0047383308410645], + ["closestHeaderAboveIsRegistery", 2.19451642036438], + ["nextInputIsConfirmy", 2.5344431400299072], + ["formHasMultipleVisibleInput", 2.81270694732666], + ["firstFieldInFormWithThreePasswordFields", -2.8964080810546875], + ], +}; + +const biases = [["new", -1.3525885343551636]]; + +const passwordStringRegex = + /password|passwort|رمز عبور|mot de passe|パスワード|비밀번호|암호|wachtwoord|senha|Пароль|parol|密码|contraseña|heslo|كلمة السر|kodeord|Κωδικός|pass code|Kata sandi|hasło|รหัสผ่าน|Şifre/i; +const passwordAttrRegex = /pw|pwd|passwd|pass/i; +const newStringRegex = + /new|erstellen|create|choose|設定|신규|Créer|Nouveau|baru|nouă|nieuw/i; +const newAttrRegex = /new/i; +const confirmStringRegex = + /wiederholen|wiederholung|confirm|repeat|confirmation|verify|retype|repite|確認|の確認|تکرار|re-enter|확인|bevestigen|confirme|Повторите|tassyklamak|再次输入|ještě jednou|gentag|re-type|confirmar|Répéter|conferma|Repetaţi|again|reenter|再入力|재입력|Ulangi|Bekræft/i; +const confirmAttrRegex = /confirm|retype/i; +const currentAttrAndStringRegex = + /current|old|aktuelles|derzeitiges|当前|Atual|actuel|curentă|sekarang/i; +const forgotStringRegex = + /vergessen|vergeten|forgot|oublié|dimenticata|Esqueceu|esqueci|Забыли|忘记|找回|Zapomenuté|lost|忘れた|忘れられた|忘れの方|재설정|찾기|help|فراموشی| را فراموش کرده اید|Восстановить|Unuttu|perdus|重新設定|reset|recover|change|remind|find|request|restore|trouble/i; +const forgotHrefRegex = + /forgot|reset|recover|change|lost|remind|find|request|restore/i; +const password1Regex = + /pw1|pwd1|pass1|passwd1|password1|pwone|pwdone|passone|passwdone|passwordone|pwfirst|pwdfirst|passfirst|passwdfirst|passwordfirst/i; +const password2Regex = + /pw2|pwd2|pass2|passwd2|password2|pwtwo|pwdtwo|passtwo|passwdtwo|passwordtwo|pwsecond|pwdsecond|passsecond|passwdsecond|passwordsecond/i; +const loginRegex = + /login|log in|log on|log-on|Войти|sign in|sigin|sign\/in|sign-in|sign on|sign-on|ورود|登录|Přihlásit se|Přihlaste|Авторизоваться|Авторизация|entrar|ログイン|로그인|inloggen|Συνδέσου|accedi|ログオン|Giriş Yap|登入|connecter|connectez-vous|Connexion|Вход/i; +const loginFormAttrRegex = + /login|log in|log on|log-on|sign in|sigin|sign\/in|sign-in|sign on|sign-on/i; +const registerStringRegex = + /create[a-zA-Z\s]+account|activate[a-zA-Z\s]+account|Zugang anlegen|Angaben prüfen|Konto erstellen|register|sign up|ثبت نام|登録|注册|cadastr|Зарегистрироваться|Регистрация|Bellige alynmak|تسجيل|ΕΓΓΡΑΦΗΣ|Εγγραφή|Créer mon compte|Créer un compte|Mendaftar|가입하기|inschrijving|Zarejestruj się|Deschideți un cont|Создать аккаунт|ร่วม|Üye Ol|registr|new account|ساخت حساب کاربری|Schrijf je|S'inscrire/i; +const registerActionRegex = + /register|signup|sign-up|create-account|account\/create|join|new_account|user\/create|sign\/up|membership\/create/i; +const registerFormAttrRegex = + /signup|join|register|regform|registration|new_user|AccountCreate|create_customer|CreateAccount|CreateAcct|create-account|reg-form|newuser|new-reg|new-form|new_membership/i; +const rememberMeAttrRegex = + /remember|auto_login|auto-login|save_mail|save-mail|ricordami|manter|mantenha|savelogin|auto login/i; +const rememberMeStringRegex = + /remember me|keep me logged in|keep me signed in|save email address|save id|stay signed in|ricordami|次回からログオンIDの入力を省略する|メールアドレスを保存する|を保存|아이디저장|아이디 저장|로그인 상태 유지|lembrar|manter conectado|mantenha-me conectado|Запомни меня|запомнить меня|Запомните меня|Не спрашивать в следующий раз|下次自动登录|记住我/i; +const newsletterStringRegex = /newsletter|ニュースレター/i; +const passwordStringAndAttrRegex = new RegExp( + passwordStringRegex.source + "|" + passwordAttrRegex.source, + "i" +); + +function makeRuleset(coeffs, biases) { + // HTMLElement => (selector => Array) nested map to cache querySelectorAll calls. + let elementToSelectors; + // We want to clear the cache each time the model is executed to get the latest DOM snapshot + // for each classification. + function clearCache() { + // WeakMaps do not have a clear method + elementToSelectors = new WeakMap(); + } + + function hasLabelMatchingRegex(element, regex) { + // Check element.labels + const labels = element.labels; + // TODO: Should I be concerned with multiple labels? + if (labels !== null && labels.length) { + return regex.test(labels[0].textContent); + } + + // Check element.aria-labelledby + let labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy !== null) { + labelledBy = labelledBy + .split(" ") + .map(id => element.getRootNode().getElementById(id)) + .filter(el => el); + if (labelledBy.length === 1) { + return regex.test(labelledBy[0].textContent); + } else if (labelledBy.length > 1) { + return regex.test( + min(labelledBy, node => euclidean(node, element)).textContent + ); + } + } + + const parentElement = element.parentElement; + // Bug 1634819: element.parentElement is null if element.parentNode is a ShadowRoot + if (!parentElement) { + return false; + } + // Check if the input is in a , and, if so, check the textContent of the containing + if (parentElement.tagName === "TD" && parentElement.parentElement) { + // TODO: How bad is the assumption that the won't be the parent of the ? + return regex.test(parentElement.parentElement.textContent); + } + + // Check if the input is in a
, and, if so, check the textContent of the preceding
+ if ( + parentElement.tagName === "DD" && + // previousElementSibling can be null + parentElement.previousElementSibling + ) { + return regex.test(parentElement.previousElementSibling.textContent); + } + return false; + } + + function closestLabelMatchesRegex(element, regex) { + const previousElementSibling = element.previousElementSibling; + if ( + previousElementSibling !== null && + previousElementSibling.tagName === "LABEL" + ) { + return regex.test(previousElementSibling.textContent); + } + + const nextElementSibling = element.nextElementSibling; + if (nextElementSibling !== null && nextElementSibling.tagName === "LABEL") { + return regex.test(nextElementSibling.textContent); + } + + const closestLabelWithinForm = closestSelectorElementWithinElement( + element, + element.form, + "label" + ); + return containsRegex( + regex, + closestLabelWithinForm, + closestLabelWithinForm => closestLabelWithinForm.textContent + ); + } + + function containsRegex(regex, thingOrNull, thingToString = identity) { + return thingOrNull !== null && regex.test(thingToString(thingOrNull)); + } + + function closestSelectorElementWithinElement( + toElement, + withinElement, + querySelector + ) { + if (withinElement !== null) { + let nodeList = Array.from(withinElement.querySelectorAll(querySelector)); + if (nodeList.length) { + return min(nodeList, node => euclidean(node, toElement)); + } + } + return null; + } + + function hasAriaLabelMatchingRegex(element, regex) { + return containsRegex(regex, element.getAttribute("aria-label")); + } + + function hasPlaceholderMatchingRegex(element, regex) { + return containsRegex(regex, element.getAttribute("placeholder")); + } + + function testRegexesAgainstAnchorPropertyWithinElement( + property, + element, + ...regexes + ) { + return hasSomeMatchingPredicateForSelectorWithinElement( + element, + "a", + anchor => { + const propertyValue = anchor[property]; + return regexes.every(regex => regex.test(propertyValue)); + } + ); + } + + function testFormButtonsAgainst(element, stringRegex) { + const form = element.form; + if (form !== null) { + let inputs = Array.from( + form.querySelectorAll("input[type=submit],input[type=button]") + ); + inputs = inputs.filter(input => { + return stringRegex.test(input.value); + }); + if (inputs.length) { + return true; + } + + return hasSomeMatchingPredicateForSelectorWithinElement( + form, + "button", + button => { + return ( + stringRegex.test(button.value) || + stringRegex.test(button.textContent) || + stringRegex.test(button.id) || + stringRegex.test(button.title) + ); + } + ); + } + return false; + } + + function hasAutocompleteCurrentPassword(fnode) { + return fnode.element.autocomplete === "current-password"; + } + + // Check cache before calling querySelectorAll on element + function getElementDescendants(element, selector) { + // Use the element to look up the selector map: + const selectorToDescendants = setDefault( + elementToSelectors, + element, + () => new Map() + ); + + // Use the selector to grab the descendants: + return setDefault(selectorToDescendants, selector, () => + Array.from(element.querySelectorAll(selector)) + ); + } + + /** + * Return whether the form element directly after this one looks like a + * confirm-password input. + */ + function nextInputIsConfirmy(fnode) { + const form = fnode.element.form; + const me = fnode.element; + if (form !== null) { + let afterMe = false; + for (const formEl of form.elements) { + if (formEl === me) { + afterMe = true; + } else if (afterMe) { + if ( + formEl.type === "password" && + !formEl.disabled && + formEl.getAttribute("aria-hidden") !== "true" + ) { + // Now we're looking at a passwordy, visible input[type=password] + // directly after me. + return elementAttrsMatchRegex(formEl, confirmAttrRegex); + // We could check other confirmy smells as well. Balance accuracy + // against time and complexity. + } + // We look only at the very next element, so we may be thrown off by + // Hide buttons and such. + break; + } + } + } + return false; + } + + /** + * Returns true when the number of visible input found in the form is over + * the given threshold. + * + * Since the idea in the signal is based on the fact that registration pages + * often have multiple inputs, this rule only selects inputs whose type is + * either email, password, text, tel or empty, which are more likely a input + * field for users to fill their information. + */ + function formHasMultipleVisibleInput(element, selector, threshold) { + let form = element.form; + if (!form) { + // For password fields that don't have an associated form, we apply a heuristic + // to find a "form" for it. The heuristic works as follow: + // 1. Locate the closest preceding input. + // 2. Find the lowest common ancestor of the password field and the closet + // preceding input. + // 3. Assume the common ancestor is the "form" of the password input. + const previous = closestElementAbove(element, selector); + if (!previous) { + return false; + } + form = findLowestCommonAncestor(previous, element); + if (!form) { + return false; + } + } + const inputs = Array.from(form.querySelectorAll(selector)); + for (const input of inputs) { + // don't need to check visibility for the element we're testing against + if (element === input || isVisible(input)) { + threshold--; + if (threshold === 0) { + return true; + } + } + } + return false; + } + + /** + * Returns true when there are three password fields in the form and the passed + * element is the first one. + * + * The signal is based on that change-password forms with 3 password fields often + * have the "current password", "new password", and "confirm password" pattern. + */ + function firstFieldInFormWithThreePasswordFields(fnode) { + const element = fnode.element; + const form = element.form; + if (form) { + let elements = form.querySelectorAll( + "input[type=password]:not([disabled], [aria-hidden=true])" + ); + // Only care forms with three password fields. If there are more than three password + // fields found, probably we include some hidden fields, so just ignore it. + if (elements.length == 3 && elements[0] == element) { + return true; + } + } + return false; + } + + function hasSomeMatchingPredicateForSelectorWithinElement( + element, + selector, + matchingPredicate + ) { + if (element === null) { + return false; + } + const elements = getElementDescendants(element, selector); + return elements.some(matchingPredicate); + } + + function textContentMatchesRegexes(element, ...regexes) { + const textContent = element.textContent; + return regexes.every(regex => regex.test(textContent)); + } + + function closestHeaderAboveMatchesRegex(element, regex) { + const closestHeader = closestElementAbove( + element, + "h1,h2,h3,h4,h5,h6,div[class*=heading],div[class*=header],div[class*=title],legend" + ); + if (closestHeader !== null) { + return regex.test(closestHeader.textContent); + } + return false; + } + + function closestElementAbove(element, selector) { + let elements = Array.from(element.ownerDocument.querySelectorAll(selector)); + for (let i = elements.length - 1; i >= 0; --i) { + if ( + element.compareDocumentPosition(elements[i]) & + Node.DOCUMENT_POSITION_PRECEDING + ) { + return elements[i]; + } + } + return null; + } + + function findLowestCommonAncestor(elementA, elementB) { + // Walk up the ancestor chain of both elements and compare whether the + // ancestors in the depth are the same. If they are not the same, the + // ancestor in the previous run is the lowest common ancestor. + function getAncestorChain(element) { + let ancestors = []; + let p = element.parentNode; + while (p) { + ancestors.push(p); + p = p.parentNode; + } + return ancestors; + } + + let aAncestors = getAncestorChain(elementA); + let bAncestors = getAncestorChain(elementB); + let posA = aAncestors.length - 1; + let posB = bAncestors.length - 1; + for (; posA >= 0 && posB >= 0; posA--, posB--) { + if (aAncestors[posA] != bAncestors[posB]) { + return aAncestors[posA + 1]; + } + } + return null; + } + + function elementAttrsMatchRegex(element, regex) { + if (element !== null) { + return ( + regex.test(element.id) || + regex.test(element.name) || + regex.test(element.className) + ); + } + return false; + } + + /** + * Let us compactly represent a collection of rules that all take a single + * type with no .when() clause and have only a score() call on the right-hand + * side. + */ + function* simpleScoringRulesTakingType(inType, ruleMap) { + for (const [name, scoringCallback] of Object.entries(ruleMap)) { + yield rule(type(inType), score(scoringCallback), { name }); + } + } + + return ruleset( + [ + rule( + DEVELOPMENT + ? dom( + "input[type=password]:not([disabled], [aria-hidden=true])" + ).when(isVisible) + : element("input"), + type("new").note(clearCache) + ), + ...simpleScoringRulesTakingType("new", { + hasNewLabel: fnode => + hasLabelMatchingRegex(fnode.element, newStringRegex), + hasConfirmLabel: fnode => + hasLabelMatchingRegex(fnode.element, confirmStringRegex), + hasCurrentLabel: fnode => + hasLabelMatchingRegex(fnode.element, currentAttrAndStringRegex), + closestLabelMatchesNew: fnode => + closestLabelMatchesRegex(fnode.element, newStringRegex), + closestLabelMatchesConfirm: fnode => + closestLabelMatchesRegex(fnode.element, confirmStringRegex), + closestLabelMatchesCurrent: fnode => + closestLabelMatchesRegex(fnode.element, currentAttrAndStringRegex), + hasNewAriaLabel: fnode => + hasAriaLabelMatchingRegex(fnode.element, newStringRegex), + hasConfirmAriaLabel: fnode => + hasAriaLabelMatchingRegex(fnode.element, confirmStringRegex), + hasCurrentAriaLabel: fnode => + hasAriaLabelMatchingRegex(fnode.element, currentAttrAndStringRegex), + hasNewPlaceholder: fnode => + hasPlaceholderMatchingRegex(fnode.element, newStringRegex), + hasConfirmPlaceholder: fnode => + hasPlaceholderMatchingRegex(fnode.element, confirmStringRegex), + hasCurrentPlaceholder: fnode => + hasPlaceholderMatchingRegex(fnode.element, currentAttrAndStringRegex), + forgotPasswordInFormLinkTextContent: fnode => + testRegexesAgainstAnchorPropertyWithinElement( + "textContent", + fnode.element.form, + passwordStringRegex, + forgotStringRegex + ), + forgotPasswordInFormLinkHref: fnode => + testRegexesAgainstAnchorPropertyWithinElement( + "href", + fnode.element.form, + passwordStringAndAttrRegex, + forgotHrefRegex + ), + forgotPasswordInFormLinkTitle: fnode => + testRegexesAgainstAnchorPropertyWithinElement( + "title", + fnode.element.form, + passwordStringRegex, + forgotStringRegex + ), + forgotInFormLinkTextContent: fnode => + testRegexesAgainstAnchorPropertyWithinElement( + "textContent", + fnode.element.form, + forgotStringRegex + ), + forgotInFormLinkHref: fnode => + testRegexesAgainstAnchorPropertyWithinElement( + "href", + fnode.element.form, + forgotHrefRegex + ), + forgotPasswordInFormButtonTextContent: fnode => + hasSomeMatchingPredicateForSelectorWithinElement( + fnode.element.form, + "button", + button => + textContentMatchesRegexes( + button, + passwordStringRegex, + forgotStringRegex + ) + ), + forgotPasswordOnPageLinkTextContent: fnode => + testRegexesAgainstAnchorPropertyWithinElement( + "textContent", + fnode.element.ownerDocument, + passwordStringRegex, + forgotStringRegex + ), + forgotPasswordOnPageLinkHref: fnode => + testRegexesAgainstAnchorPropertyWithinElement( + "href", + fnode.element.ownerDocument, + passwordStringAndAttrRegex, + forgotHrefRegex + ), + forgotPasswordOnPageLinkTitle: fnode => + testRegexesAgainstAnchorPropertyWithinElement( + "title", + fnode.element.ownerDocument, + passwordStringRegex, + forgotStringRegex + ), + forgotPasswordOnPageButtonTextContent: fnode => + hasSomeMatchingPredicateForSelectorWithinElement( + fnode.element.ownerDocument, + "button", + button => + textContentMatchesRegexes( + button, + passwordStringRegex, + forgotStringRegex + ) + ), + elementAttrsMatchNew: fnode => + elementAttrsMatchRegex(fnode.element, newAttrRegex), + elementAttrsMatchConfirm: fnode => + elementAttrsMatchRegex(fnode.element, confirmAttrRegex), + elementAttrsMatchCurrent: fnode => + elementAttrsMatchRegex(fnode.element, currentAttrAndStringRegex), + elementAttrsMatchPassword1: fnode => + elementAttrsMatchRegex(fnode.element, password1Regex), + elementAttrsMatchPassword2: fnode => + elementAttrsMatchRegex(fnode.element, password2Regex), + elementAttrsMatchLogin: fnode => + elementAttrsMatchRegex(fnode.element, loginRegex), + formAttrsMatchRegister: fnode => + elementAttrsMatchRegex(fnode.element.form, registerFormAttrRegex), + formHasRegisterAction: fnode => + containsRegex( + registerActionRegex, + fnode.element.form, + form => form.action + ), + formButtonIsRegister: fnode => + testFormButtonsAgainst(fnode.element, registerStringRegex), + formAttrsMatchLogin: fnode => + elementAttrsMatchRegex(fnode.element.form, loginFormAttrRegex), + formHasLoginAction: fnode => + containsRegex(loginRegex, fnode.element.form, form => form.action), + formButtonIsLogin: fnode => + testFormButtonsAgainst(fnode.element, loginRegex), + hasAutocompleteCurrentPassword, + formHasRememberMeCheckbox: fnode => + hasSomeMatchingPredicateForSelectorWithinElement( + fnode.element.form, + "input[type=checkbox]", + checkbox => + rememberMeAttrRegex.test(checkbox.id) || + rememberMeAttrRegex.test(checkbox.name) + ), + formHasRememberMeLabel: fnode => + hasSomeMatchingPredicateForSelectorWithinElement( + fnode.element.form, + "label", + label => rememberMeStringRegex.test(label.textContent) + ), + formHasNewsletterCheckbox: fnode => + hasSomeMatchingPredicateForSelectorWithinElement( + fnode.element.form, + "input[type=checkbox]", + checkbox => + checkbox.id.includes("newsletter") || + checkbox.name.includes("newsletter") + ), + formHasNewsletterLabel: fnode => + hasSomeMatchingPredicateForSelectorWithinElement( + fnode.element.form, + "label", + label => newsletterStringRegex.test(label.textContent) + ), + closestHeaderAboveIsLoginy: fnode => + closestHeaderAboveMatchesRegex(fnode.element, loginRegex), + closestHeaderAboveIsRegistery: fnode => + closestHeaderAboveMatchesRegex(fnode.element, registerStringRegex), + nextInputIsConfirmy, + formHasMultipleVisibleInput: fnode => + formHasMultipleVisibleInput( + fnode.element, + "input[type=email],input[type=password],input[type=text],input[type=tel]", + 3 + ), + firstFieldInFormWithThreePasswordFields, + }), + rule(type("new"), out("new")), + ], + coeffs, + biases + ); +} + +/* + * ----- End of model ----- + */ + +export const NewPasswordModel = { + type: "new", + rules: makeRuleset([...coefficients.new], biases), +}; diff --git a/toolkit/components/passwordmgr/OSCrypto_win.sys.mjs b/toolkit/components/passwordmgr/OSCrypto_win.sys.mjs new file mode 100644 index 0000000000..5b3f1d7929 --- /dev/null +++ b/toolkit/components/passwordmgr/OSCrypto_win.sys.mjs @@ -0,0 +1,284 @@ +/* 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/. */ + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +const FLAGS_NOT_SET = 0; + +const wintypes = { + BOOL: ctypes.bool, + BYTE: ctypes.uint8_t, + DWORD: ctypes.uint32_t, + PBYTE: ctypes.unsigned_char.ptr, + PCHAR: ctypes.char.ptr, + PDWORD: ctypes.uint32_t.ptr, + PVOID: ctypes.voidptr_t, + WORD: ctypes.uint16_t, +}; + +export function OSCrypto() { + this._structs = {}; + this._functions = new Map(); + this._libs = new Map(); + this._structs.DATA_BLOB = new ctypes.StructType("DATA_BLOB", [ + { cbData: wintypes.DWORD }, + { pbData: wintypes.PVOID }, + ]); + + try { + this._libs.set("crypt32", ctypes.open("Crypt32")); + this._libs.set("kernel32", ctypes.open("Kernel32")); + + this._functions.set( + "CryptProtectData", + this._libs + .get("crypt32") + .declare( + "CryptProtectData", + ctypes.winapi_abi, + wintypes.DWORD, + this._structs.DATA_BLOB.ptr, + wintypes.PVOID, + wintypes.PVOID, + wintypes.PVOID, + wintypes.PVOID, + wintypes.DWORD, + this._structs.DATA_BLOB.ptr + ) + ); + this._functions.set( + "CryptUnprotectData", + this._libs + .get("crypt32") + .declare( + "CryptUnprotectData", + ctypes.winapi_abi, + wintypes.DWORD, + this._structs.DATA_BLOB.ptr, + wintypes.PVOID, + wintypes.PVOID, + wintypes.PVOID, + wintypes.PVOID, + wintypes.DWORD, + this._structs.DATA_BLOB.ptr + ) + ); + this._functions.set( + "LocalFree", + this._libs + .get("kernel32") + .declare("LocalFree", ctypes.winapi_abi, wintypes.DWORD, wintypes.PVOID) + ); + } catch (ex) { + console.error(ex); + this.finalize(); + throw ex; + } +} + +OSCrypto.prototype = { + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + }, + + /** + * Convert a string to an array. + * @param {string} str - the string that needs to be converted. + * @returns {number[]} the array representation of the string. + */ + stringToArray(str) { + let arr = []; + for (let i = 0; i < str.length; i++) { + arr.push(str.charCodeAt(i)); + } + return arr; + }, + + /** + * Calculate the hash value used by IE as the name of the registry value where login details are + * stored. + * @param {string} data - the string value that needs to be hashed. + * @returns {string} the hash value of the string. + */ + getIELoginHash(data) { + // return the two-digit hexadecimal code for a byte + function toHexString(charCode) { + return ("00" + charCode.toString(16)).slice(-2); + } + + // the data needs to be encoded in null terminated UTF-16 + data += "\0"; + + // dataArray is an array of bytes + let dataArray = new Array(data.length * 2); + for (let i = 0; i < data.length; i++) { + let c = data.charCodeAt(i); + let lo = c & 0xff; + let hi = (c & 0xff00) >> 8; + dataArray[i * 2] = lo; + dataArray[i * 2 + 1] = hi; + } + + // calculation of SHA1 hash value + let cryptoHash = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + cryptoHash.init(cryptoHash.SHA1); + cryptoHash.update(dataArray, dataArray.length); + let hash = cryptoHash.finish(false); + + let tail = 0; // variable to calculate value for the last 2 bytes + // convert to a character string in hexadecimal notation + for (let c of hash) { + tail += c.charCodeAt(0); + } + hash += String.fromCharCode(tail % 256); + + // convert the binary hash data to a hex string. + let hashStr = Array.from(hash, (c, i) => + toHexString(hash.charCodeAt(i)) + ).join(""); + return hashStr.toUpperCase(); + }, + + _getEntropyParam(entropy) { + if (!entropy) { + return null; + } + let entropyArray = this.stringToArray(entropy); + entropyArray.push(0); // Null-terminate the data. + let entropyData = wintypes.WORD.array(entropyArray.length)(entropyArray); + let optionalEntropy = new this._structs.DATA_BLOB( + entropyData.length * wintypes.WORD.size, + entropyData + ); + return optionalEntropy.address(); + }, + + _convertWinArrayToJSArray(dataBlob) { + // Convert DATA_BLOB to JS accessible array + return ctypes.cast( + dataBlob.pbData, + wintypes.BYTE.array(dataBlob.cbData).ptr + ).contents; + }, + + /** + * Decrypt a string using the windows CryptUnprotectData API. + * @param {string} data - the encrypted string that needs to be decrypted. + * @param {?string} entropy - the entropy value of the decryption (could be null). Its value must + * be the same as the one used when the data was encrypted. + * @param {?string} output - how to return the decrypted data default string + * @returns {string|Uint8Array} the decryption of the string. + */ + decryptData(data, entropy = null, output = "string") { + let array = this.stringToArray(data); + let encryptedData = wintypes.BYTE.array(array.length)(array); + let inData = new this._structs.DATA_BLOB( + encryptedData.length, + encryptedData + ); + let outData = new this._structs.DATA_BLOB(); + let entropyParam = this._getEntropyParam(entropy); + + try { + let status = this._functions.get("CryptUnprotectData")( + inData.address(), + null, + entropyParam, + null, + null, + FLAGS_NOT_SET, + outData.address() + ); + if (status === 0) { + throw new Error("decryptData failed: " + ctypes.winLastError); + } + + let decrypted = this._convertWinArrayToJSArray(outData); + + // Return raw bytes to handle non-string results + const bytes = new Uint8Array(decrypted); + if (output === "bytes") { + return bytes; + } + + // Output that may include characters outside of the 0-255 (byte) range needs to use TextDecoder. + return new TextDecoder().decode(bytes); + } finally { + if (outData.pbData) { + this._functions.get("LocalFree")(outData.pbData); + } + } + }, + + /** + * Encrypt a string using the windows CryptProtectData API. + * @param {string} data - the string that is going to be encrypted. + * @param {?string} entropy - the entropy value of the encryption (could be null). Its value must + * be the same as the one that is going to be used for the decryption. + * @returns {string} the encrypted string. + */ + encryptData(data, entropy = null) { + // Input that may include characters outside of the 0-255 (byte) range needs to use TextEncoder. + let decryptedByteData = [...new TextEncoder().encode(data)]; + let decryptedData = wintypes.BYTE.array(decryptedByteData.length)( + decryptedByteData + ); + + let inData = new this._structs.DATA_BLOB( + decryptedData.length, + decryptedData + ); + let outData = new this._structs.DATA_BLOB(); + let entropyParam = this._getEntropyParam(entropy); + + try { + let status = this._functions.get("CryptProtectData")( + inData.address(), + null, + entropyParam, + null, + null, + FLAGS_NOT_SET, + outData.address() + ); + if (status === 0) { + throw new Error("encryptData failed: " + ctypes.winLastError); + } + + let encrypted = this._convertWinArrayToJSArray(outData); + return this.arrayToString(encrypted); + } finally { + if (outData.pbData) { + this._functions.get("LocalFree")(outData.pbData); + } + } + }, + + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions.clear(); + for (let lib of this._libs.values()) { + try { + lib.close(); + } catch (ex) { + console.error(ex); + } + } + this._libs.clear(); + }, +}; diff --git a/toolkit/components/passwordmgr/PasswordGenerator.sys.mjs b/toolkit/components/passwordmgr/PasswordGenerator.sys.mjs new file mode 100644 index 0000000000..0f2a407cea --- /dev/null +++ b/toolkit/components/passwordmgr/PasswordGenerator.sys.mjs @@ -0,0 +1,229 @@ +/* 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/. */ + +/** + * This file is a port of a subset of Chromium's implementation from + * https://cs.chromium.org/chromium/src/components/password_manager/core/browser/generation/password_generator.cc?l=93&rcl=a896a3ac4ea731b5ab3d2ab5bd76a139885d5c4f + * which is Copyright 2018 The Chromium Authors. All rights reserved. + */ + +const DEFAULT_PASSWORD_LENGTH = 15; +const MAX_UINT8 = Math.pow(2, 8) - 1; +const MAX_UINT32 = Math.pow(2, 32) - 1; + +// Some characters are removed due to visual similarity: +const LOWER_CASE_ALPHA = "abcdefghijkmnpqrstuvwxyz"; // no 'l' or 'o' +const UPPER_CASE_ALPHA = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no 'I' or 'O' +const DIGITS = "23456789"; // no '1' or '0' +const SPECIAL_CHARACTERS = "-~!@#$%^&*_+=)}:;\"'>,.?]"; + +const REQUIRED_CHARACTER_CLASSES = [ + LOWER_CASE_ALPHA, + UPPER_CASE_ALPHA, + DIGITS, + SPECIAL_CHARACTERS, +]; + +// Consts for different password rules +const REQUIRED = "required"; +const MAX_LENGTH = "maxlength"; +const MIN_LENGTH = "minlength"; +const MAX_CONSECUTIVE = "max-consecutive"; +const UPPER = "upper"; +const LOWER = "lower"; +const DIGIT = "digit"; +const SPECIAL = "special"; + +// Default password rules +const DEFAULT_RULES = new Map(); +DEFAULT_RULES.set(MIN_LENGTH, REQUIRED_CHARACTER_CLASSES.length); +DEFAULT_RULES.set(MAX_LENGTH, MAX_UINT8); +DEFAULT_RULES.set(REQUIRED, [UPPER, LOWER, DIGIT, SPECIAL]); + +export const PasswordGenerator = { + /** + * @param {Object} options + * @param {number} options.length - length of the generated password if there are no rules that override the length + * @param {Map} options.rules - map of password rules + * @returns {string} password that was generated + * @throws Error if `length` is invalid + * @copyright 2018 The Chromium Authors. All rights reserved. + * @see https://cs.chromium.org/chromium/src/components/password_manager/core/browser/generation/password_generator.cc?l=93&rcl=a896a3ac4ea731b5ab3d2ab5bd76a139885d5c4f + */ + generatePassword({ + length = DEFAULT_PASSWORD_LENGTH, + rules = DEFAULT_RULES, + inputMaxLength, + }) { + rules = new Map([...DEFAULT_RULES, ...rules]); + if (rules.get(MIN_LENGTH) > length) { + length = rules.get(MIN_LENGTH); + } + if (rules.get(MAX_LENGTH) < length) { + length = rules.get(MAX_LENGTH); + } + if (inputMaxLength > 0 && inputMaxLength < length) { + length = inputMaxLength; + } + + let password = ""; + let requiredClasses = []; + let allRequiredCharacters = ""; + + // Generate one character of each required class and/or required character list from the rules + this._addRequiredClassesAndCharacters(rules, requiredClasses); + + // Generate one of each required class + for (const charClassString of requiredClasses) { + password += + charClassString[this._randomUInt8Index(charClassString.length)]; + if (Array.isArray(charClassString)) { + // Convert array into single string so that commas aren't + // concatenated with each character in the arbitrary character array. + allRequiredCharacters += charClassString.join(""); + } else { + allRequiredCharacters += charClassString; + } + } + + // Now fill the rest of the password with random characters. + while (password.length < length) { + password += + allRequiredCharacters[ + this._randomUInt8Index(allRequiredCharacters.length) + ]; + } + + // So far the password contains the minimally required characters at the + // the beginning. Therefore, we create a random permutation. + password = this._shuffleString(password); + + // Make sure the password passes the "max-consecutive" rule, if the rule exists + if (rules.has(MAX_CONSECUTIVE)) { + // Ensures that a password isn't shuffled an infinite number of times. + const DEFAULT_NUMBER_OF_SHUFFLES = 15; + let shuffleCount = 0; + let consecutiveFlag = this._checkConsecutiveCharacters( + password, + rules.get(MAX_CONSECUTIVE) + ); + while (!consecutiveFlag) { + password = this._shuffleString(password); + consecutiveFlag = this._checkConsecutiveCharacters( + password, + rules.get(MAX_CONSECUTIVE) + ); + ++shuffleCount; + if (shuffleCount === DEFAULT_NUMBER_OF_SHUFFLES) { + consecutiveFlag = true; + } + } + } + + return password; + }, + + /** + * Adds special characters and/or other required characters to the requiredCharacters array. + * @param {Map} rules + * @param {string[]} requiredClasses + */ + _addRequiredClassesAndCharacters(rules, requiredClasses) { + for (const charClass of rules.get(REQUIRED)) { + if (charClass === UPPER) { + requiredClasses.push(UPPER_CASE_ALPHA); + } else if (charClass === LOWER) { + requiredClasses.push(LOWER_CASE_ALPHA); + } else if (charClass === DIGIT) { + requiredClasses.push(DIGITS); + } else if (charClass === SPECIAL) { + requiredClasses.push(SPECIAL_CHARACTERS); + } else { + requiredClasses.push(charClass); + } + } + }, + + /** + * @param range to generate the number in + * @returns a random number in range [0, range). + * @copyright 2018 The Chromium Authors. All rights reserved. + * @see https://cs.chromium.org/chromium/src/base/rand_util.cc?l=58&rcl=648a59893e4ed5303b5c381b03ce0c75e4165617 + */ + _randomUInt8Index(range) { + if (range > MAX_UINT8) { + throw new Error("`range` cannot fit into uint8"); + } + // We must discard random results above this number, as they would + // make the random generator non-uniform (consider e.g. if + // MAX_UINT64 was 7 and |range| was 5, then a result of 1 would be twice + // as likely as a result of 3 or 4). + // See https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#Modulo_bias + const MAX_ACCEPTABLE_VALUE = Math.floor(MAX_UINT8 / range) * range - 1; + + const randomValueArr = new Uint8Array(1); + do { + crypto.getRandomValues(randomValueArr); + } while (randomValueArr[0] > MAX_ACCEPTABLE_VALUE); + return randomValueArr[0] % range; + }, + + /** + * Shuffle the order of characters in a string. + * @param {string} str to shuffle + * @returns {string} shuffled string + */ + _shuffleString(str) { + let arr = Array.from(str); + // Generate all the random numbers that will be needed. + const randomValues = new Uint32Array(arr.length - 1); + crypto.getRandomValues(randomValues); + + // Fisher-Yates Shuffle + // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor((randomValues[i - 1] / MAX_UINT32) * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr.join(""); + }, + + /** + * Determine the number of consecutive characters in a string. + * This is primarily used to validate the "max-consecutive" rule + * of a generated password. + * @param {string} generatedPassword + * @param {number} value the number of consecutive characters allowed + * @return {boolean} `true` if the generatePassword has less than the value argument number of characters, `false` otherwise + */ + _checkConsecutiveCharacters(generatedPassword, value) { + let max = 0; + for (let start = 0, end = 1; end < generatedPassword.length; ) { + if (generatedPassword[end] === generatedPassword[start]) { + if (max < end - start + 1) { + max = end - start + 1; + if (max > value) { + return false; + } + } + end++; + } else { + start = end++; + } + } + return true; + }, + _getUpperCaseCharacters() { + return UPPER_CASE_ALPHA; + }, + _getLowerCaseCharacters() { + return LOWER_CASE_ALPHA; + }, + _getDigits() { + return DIGITS; + }, + _getSpecialCharacters() { + return SPECIAL_CHARACTERS; + }, +}; diff --git a/toolkit/components/passwordmgr/PasswordRulesManager.sys.mjs b/toolkit/components/passwordmgr/PasswordRulesManager.sys.mjs new file mode 100644 index 0000000000..e80fb51962 --- /dev/null +++ b/toolkit/components/passwordmgr/PasswordRulesManager.sys.mjs @@ -0,0 +1,130 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + PasswordGenerator: "resource://gre/modules/PasswordGenerator.sys.mjs", + PasswordRulesParser: "resource://gre/modules/PasswordRulesParser.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let logger = lazy.LoginHelper.createLogger("PasswordRulesManager"); + return logger.log.bind(logger); +}); + +const IMPROVED_PASSWORD_GENERATION_HISTOGRAM = + "PWMGR_NUM_IMPROVED_GENERATED_PASSWORDS"; + +/** + * Handles interactions between PasswordRulesParser and the "password-rules" Remote Settings collection + * + * @class PasswordRulesManagerParent + * @extends {JSWindowActorParent} + */ +export class PasswordRulesManagerParent extends JSWindowActorParent { + /** + * @type RemoteSettingsClient + * + * @memberof PasswordRulesManagerParent + */ + _passwordRulesClient = null; + + async initPasswordRulesCollection() { + if (!this._passwordRulesClient) { + this._passwordRulesClient = lazy.RemoteSettings( + lazy.LoginHelper.improvedPasswordRulesCollection + ); + } + } + /** + * Transforms the parsed rules returned from PasswordRulesParser into a Map for easier access. + * The returned Map could have the following keys: "allowed", "required", "maxlength", "minlength", and "max-consecutive" + * @example + * // Returns a Map with a key-value pair of "allowed": "ascii-printable" + * _transformRulesToMap([{ _name: "allowed", value: [{ _name: "ascii-printable" }] }]) + * @param {Object[]} rules rules from PasswordRulesParser.parsePasswordRules + * @return {Map} mapped rules + * @memberof PasswordRulesManagerParent + */ + _transformRulesToMap(rules) { + let map = new Map(); + for (let rule of rules) { + let { _name, value } = rule; + if ( + _name === "minlength" || + _name === "maxlength" || + _name === "max-consecutive" + ) { + map.set(_name, value); + } else { + let _value = []; + if (map.get(_name)) { + _value = map.get(_name); + } + for (let _class of value) { + let { _name: _className } = _class; + if (_className) { + _value.push(_className); + } else { + let { _characters } = _class; + _value.push(_characters); + } + } + map.set(_name, _value); + } + } + return map; + } + + /** + * Generates a password based on rules from the origin parameters. + * @param {nsIURI} uri + * @return {string} password + * @memberof PasswordRulesManagerParent + */ + async generatePassword(uri, { inputMaxLength } = {}) { + await this.initPasswordRulesCollection(); + let originDisplayHost = uri.displayHost; + let records = await this._passwordRulesClient.get(); + let currentRecord; + for (let record of records) { + if (Services.eTLD.hasRootDomain(originDisplayHost, record.Domain)) { + currentRecord = record; + break; + } + } + let isCustomRule = false; + // If we found a matching result, use that to generate a stronger password. + // Otherwise, generate a password using the default rules set. + if (currentRecord?.Domain) { + isCustomRule = true; + lazy.log( + `Password rules for ${currentRecord.Domain}: ${currentRecord["password-rules"]}.` + ); + let currentRules = lazy.PasswordRulesParser.parsePasswordRules( + currentRecord["password-rules"] + ); + let mapOfRules = this._transformRulesToMap(currentRules); + Services.telemetry + .getHistogramById(IMPROVED_PASSWORD_GENERATION_HISTOGRAM) + .add(isCustomRule); + return lazy.PasswordGenerator.generatePassword({ + rules: mapOfRules, + inputMaxLength, + }); + } + lazy.log( + `No password rules for specified origin, generating standard password.` + ); + Services.telemetry + .getHistogramById(IMPROVED_PASSWORD_GENERATION_HISTOGRAM) + .add(isCustomRule); + return lazy.PasswordGenerator.generatePassword({ inputMaxLength }); + } +} diff --git a/toolkit/components/passwordmgr/PasswordRulesParser.sys.mjs b/toolkit/components/passwordmgr/PasswordRulesParser.sys.mjs new file mode 100644 index 0000000000..8ce7cba990 --- /dev/null +++ b/toolkit/components/passwordmgr/PasswordRulesParser.sys.mjs @@ -0,0 +1,695 @@ +// Sourced from https://github.com/apple/password-manager-resources/blob/5f6da89483e75cdc4165a6fc4756796e0ced7a21/tools/PasswordRulesParser.js +// Copyright (c) 2019 - 2020 Apple Inc. Licensed under MIT License. + +export const PasswordRulesParser = { + parsePasswordRules, +}; + +const Identifier = { + ASCII_PRINTABLE: "ascii-printable", + DIGIT: "digit", + LOWER: "lower", + SPECIAL: "special", + UNICODE: "unicode", + UPPER: "upper", +}; + +const RuleName = { + ALLOWED: "allowed", + MAX_CONSECUTIVE: "max-consecutive", + REQUIRED: "required", + MIN_LENGTH: "minlength", + MAX_LENGTH: "maxlength", +}; + +const CHARACTER_CLASS_START_SENTINEL = "["; +const CHARACTER_CLASS_END_SENTINEL = "]"; +const PROPERTY_VALUE_SEPARATOR = ","; +const PROPERTY_SEPARATOR = ";"; +const PROPERTY_VALUE_START_SENTINEL = ":"; + +const SPACE_CODE_POINT = " ".codePointAt(0); + +const SHOULD_NOT_BE_REACHED = "Should not be reached"; + +class Rule { + constructor(name, value) { + this._name = name; + this.value = value; + } + get name() { + return this._name; + } + toString() { + return JSON.stringify(this); + } +} + +class NamedCharacterClass { + constructor(name) { + console.assert(_isValidRequiredOrAllowedPropertyValueIdentifier(name)); + this._name = name; + } + get name() { + return this._name.toLowerCase(); + } + toString() { + return this._name; + } + toHTMLString() { + return this._name; + } +} + +class CustomCharacterClass { + constructor(characters) { + console.assert(characters instanceof Array); + this._characters = characters; + } + get characters() { + return this._characters; + } + toString() { + return `[${this._characters.join("")}]`; + } + toHTMLString() { + return `[${this._characters.join("").replace('"', """)}]`; + } +} + +// MARK: Lexer functions + +function _isIdentifierCharacter(c) { + console.assert(c.length === 1); + return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "-"; +} + +function _isASCIIDigit(c) { + console.assert(c.length === 1); + return c >= "0" && c <= "9"; +} + +function _isASCIIPrintableCharacter(c) { + console.assert(c.length === 1); + return c >= " " && c <= "~"; +} + +function _isASCIIWhitespace(c) { + console.assert(c.length === 1); + return c === " " || c === "\f" || c === "\n" || c === "\r" || c === "\t"; +} + +// MARK: ASCII printable character bit set and canonicalization functions + +function _bitSetIndexForCharacter(c) { + console.assert(c.length == 1); + return c.codePointAt(0) - SPACE_CODE_POINT; +} + +function _characterAtBitSetIndex(index) { + return String.fromCodePoint(index + SPACE_CODE_POINT); +} + +function _markBitsForNamedCharacterClass(bitSet, namedCharacterClass) { + console.assert(bitSet instanceof Array); + console.assert(namedCharacterClass.name !== Identifier.UNICODE); + console.assert(namedCharacterClass.name !== Identifier.ASCII_PRINTABLE); + if (namedCharacterClass.name === Identifier.UPPER) { + bitSet.fill( + true, + _bitSetIndexForCharacter("A"), + _bitSetIndexForCharacter("Z") + 1 + ); + } else if (namedCharacterClass.name === Identifier.LOWER) { + bitSet.fill( + true, + _bitSetIndexForCharacter("a"), + _bitSetIndexForCharacter("z") + 1 + ); + } else if (namedCharacterClass.name === Identifier.DIGIT) { + bitSet.fill( + true, + _bitSetIndexForCharacter("0"), + _bitSetIndexForCharacter("9") + 1 + ); + } else if (namedCharacterClass.name === Identifier.SPECIAL) { + bitSet.fill( + true, + _bitSetIndexForCharacter(" "), + _bitSetIndexForCharacter("/") + 1 + ); + bitSet.fill( + true, + _bitSetIndexForCharacter(":"), + _bitSetIndexForCharacter("@") + 1 + ); + bitSet.fill( + true, + _bitSetIndexForCharacter("["), + _bitSetIndexForCharacter("`") + 1 + ); + bitSet.fill( + true, + _bitSetIndexForCharacter("{"), + _bitSetIndexForCharacter("~") + 1 + ); + } else { + console.assert(false, SHOULD_NOT_BE_REACHED, namedCharacterClass); + } +} + +function _markBitsForCustomCharacterClass(bitSet, customCharacterClass) { + for (let character of customCharacterClass.characters) { + bitSet[_bitSetIndexForCharacter(character)] = true; + } +} + +function _canonicalizedPropertyValues( + propertyValues, + keepCustomCharacterClassFormatCompliant +) { + let asciiPrintableBitSet = new Array( + "~".codePointAt(0) - " ".codePointAt(0) + 1 + ); + + for (let propertyValue of propertyValues) { + if (propertyValue instanceof NamedCharacterClass) { + if (propertyValue.name === Identifier.UNICODE) { + return [new NamedCharacterClass(Identifier.UNICODE)]; + } + + if (propertyValue.name === Identifier.ASCII_PRINTABLE) { + return [new NamedCharacterClass(Identifier.ASCII_PRINTABLE)]; + } + + _markBitsForNamedCharacterClass(asciiPrintableBitSet, propertyValue); + } else if (propertyValue instanceof CustomCharacterClass) { + _markBitsForCustomCharacterClass(asciiPrintableBitSet, propertyValue); + } + } + + let charactersSeen = []; + + function checkRange(start, end) { + let temp = []; + for ( + let i = _bitSetIndexForCharacter(start); + i <= _bitSetIndexForCharacter(end); + ++i + ) { + if (asciiPrintableBitSet[i]) { + temp.push(_characterAtBitSetIndex(i)); + } + } + + let result = + temp.length === + _bitSetIndexForCharacter(end) - _bitSetIndexForCharacter(start) + 1; + if (!result) { + charactersSeen = charactersSeen.concat(temp); + } + return result; + } + + let hasAllUpper = checkRange("A", "Z"); + let hasAllLower = checkRange("a", "z"); + let hasAllDigits = checkRange("0", "9"); + + // Check for special characters, accounting for characters that are given special treatment (i.e. '-' and ']') + let hasAllSpecial = false; + let hasDash = false; + let hasRightSquareBracket = false; + let temp = []; + for ( + let i = _bitSetIndexForCharacter(" "); + i <= _bitSetIndexForCharacter("/"); + ++i + ) { + if (!asciiPrintableBitSet[i]) { + continue; + } + + let character = _characterAtBitSetIndex(i); + if (keepCustomCharacterClassFormatCompliant && character === "-") { + hasDash = true; + } else { + temp.push(character); + } + } + for ( + let i = _bitSetIndexForCharacter(":"); + i <= _bitSetIndexForCharacter("@"); + ++i + ) { + if (asciiPrintableBitSet[i]) { + temp.push(_characterAtBitSetIndex(i)); + } + } + for ( + let i = _bitSetIndexForCharacter("["); + i <= _bitSetIndexForCharacter("`"); + ++i + ) { + if (!asciiPrintableBitSet[i]) { + continue; + } + + let character = _characterAtBitSetIndex(i); + if (keepCustomCharacterClassFormatCompliant && character === "]") { + hasRightSquareBracket = true; + } else { + temp.push(character); + } + } + for ( + let i = _bitSetIndexForCharacter("{"); + i <= _bitSetIndexForCharacter("~"); + ++i + ) { + if (asciiPrintableBitSet[i]) { + temp.push(_characterAtBitSetIndex(i)); + } + } + + if (hasDash) { + temp.unshift("-"); + } + if (hasRightSquareBracket) { + temp.push("]"); + } + + let numberOfSpecialCharacters = + _bitSetIndexForCharacter("/") - + _bitSetIndexForCharacter(" ") + + 1 + + (_bitSetIndexForCharacter("@") - _bitSetIndexForCharacter(":") + 1) + + (_bitSetIndexForCharacter("`") - _bitSetIndexForCharacter("[") + 1) + + (_bitSetIndexForCharacter("~") - _bitSetIndexForCharacter("{") + 1); + hasAllSpecial = temp.length === numberOfSpecialCharacters; + if (!hasAllSpecial) { + charactersSeen = charactersSeen.concat(temp); + } + + let result = []; + if (hasAllUpper && hasAllLower && hasAllDigits && hasAllSpecial) { + return [new NamedCharacterClass(Identifier.ASCII_PRINTABLE)]; + } + if (hasAllUpper) { + result.push(new NamedCharacterClass(Identifier.UPPER)); + } + if (hasAllLower) { + result.push(new NamedCharacterClass(Identifier.LOWER)); + } + if (hasAllDigits) { + result.push(new NamedCharacterClass(Identifier.DIGIT)); + } + if (hasAllSpecial) { + result.push(new NamedCharacterClass(Identifier.SPECIAL)); + } + if (charactersSeen.length) { + result.push(new CustomCharacterClass(charactersSeen)); + } + return result; +} + +// MARK: Parser functions + +function _indexOfNonWhitespaceCharacter(input, position = 0) { + console.assert(position >= 0); + console.assert(position <= input.length); + + let length = input.length; + while (position < length && _isASCIIWhitespace(input[position])) { + ++position; + } + + return position; +} + +function _parseIdentifier(input, position) { + console.assert(position >= 0); + console.assert(position < input.length); + console.assert(_isIdentifierCharacter(input[position])); + + let length = input.length; + let seenIdentifiers = []; + do { + let c = input[position]; + if (!_isIdentifierCharacter(c)) { + break; + } + + seenIdentifiers.push(c); + ++position; + } while (position < length); + + return [seenIdentifiers.join(""), position]; +} + +function _isValidRequiredOrAllowedPropertyValueIdentifier(identifier) { + return ( + identifier && Object.values(Identifier).includes(identifier.toLowerCase()) + ); +} + +function _parseCustomCharacterClass(input, position) { + console.assert(position >= 0); + console.assert(position < input.length); + console.assert(input[position] === CHARACTER_CLASS_START_SENTINEL); + + let length = input.length; + ++position; + if (position >= length) { + console.error("Found end-of-line instead of character class character"); + return [null, position]; + } + + let initialPosition = position; + let result = []; + do { + let c = input[position]; + if (!_isASCIIPrintableCharacter(c)) { + ++position; + continue; + } + + if (c === "-" && position - initialPosition > 0) { + // FIXME: Should this be an error? + console.warn( + "Ignoring '-'; a '-' may only appear as the first character in a character class" + ); + ++position; + continue; + } + + result.push(c); + ++position; + if (c === CHARACTER_CLASS_END_SENTINEL) { + break; + } + } while (position < length); + + if ( + (position < length && input[position] !== CHARACTER_CLASS_END_SENTINEL) || + (position == length && input[position - 1] == CHARACTER_CLASS_END_SENTINEL) + ) { + // Fix up result; we over consumed. + result.pop(); + return [result, position]; + } + + if (position < length && input[position] == CHARACTER_CLASS_END_SENTINEL) { + return [result, position + 1]; + } + + console.error("Found end-of-line instead of end of character class"); + return [null, position]; +} + +function _parsePasswordRequiredOrAllowedPropertyValue(input, position) { + console.assert(position >= 0); + console.assert(position < input.length); + + let length = input.length; + let propertyValues = []; + while (true) { + if (_isIdentifierCharacter(input[position])) { + let identifierStartPosition = position; + var [propertyValue, position] = _parseIdentifier(input, position); + if (!_isValidRequiredOrAllowedPropertyValueIdentifier(propertyValue)) { + console.error( + "Unrecognized property value identifier: " + propertyValue + ); + return [null, identifierStartPosition]; + } + propertyValues.push(new NamedCharacterClass(propertyValue)); + } else if (input[position] == CHARACTER_CLASS_START_SENTINEL) { + var [propertyValue, position] = _parseCustomCharacterClass( + input, + position + ); + if (propertyValue && propertyValue.length) { + propertyValues.push(new CustomCharacterClass(propertyValue)); + } + } else { + console.error( + "Failed to find start of property value: " + input.substr(position) + ); + return [null, position]; + } + + position = _indexOfNonWhitespaceCharacter(input, position); + if (position >= length || input[position] === PROPERTY_SEPARATOR) { + break; + } + + if (input[position] === PROPERTY_VALUE_SEPARATOR) { + position = _indexOfNonWhitespaceCharacter(input, position + 1); + if (position >= length) { + console.error( + "Found end-of-line instead of start of next property value" + ); + return [null, position]; + } + continue; + } + + console.error( + "Failed to find start of next property or property value: " + + input.substr(position) + ); + return [null, position]; + } + return [propertyValues, position]; +} + +function _parsePasswordRule(input, position) { + console.assert(position >= 0); + console.assert(position < input.length); + console.assert(_isIdentifierCharacter(input[position])); + + let length = input.length; + + let mayBeIdentifierStartPosition = position; + var [identifier, position] = _parseIdentifier(input, position); + if (!Object.values(RuleName).includes(identifier)) { + console.error("Unrecognized property name: " + identifier); + return [null, mayBeIdentifierStartPosition]; + } + + if (position >= length) { + console.error("Found end-of-line instead of start of property value"); + return [null, position]; + } + + if (input[position] !== PROPERTY_VALUE_START_SENTINEL) { + console.error( + "Failed to find start of property value: " + input.substr(position) + ); + return [null, position]; + } + + let property = { name: identifier, value: null }; + + position = _indexOfNonWhitespaceCharacter(input, position + 1); + // Empty value + if (position >= length || input[position] === PROPERTY_SEPARATOR) { + return [new Rule(property.name, property.value), position]; + } + + switch (identifier) { + case RuleName.ALLOWED: + case RuleName.REQUIRED: { + var [ + propertyValue, + position, + ] = _parsePasswordRequiredOrAllowedPropertyValue(input, position); + if (propertyValue) { + property.value = propertyValue; + } + return [new Rule(property.name, property.value), position]; + } + case RuleName.MAX_CONSECUTIVE: { + var [propertyValue, position] = _parseMaxConsecutivePropertyValue( + input, + position + ); + if (propertyValue) { + property.value = propertyValue; + } + return [new Rule(property.name, property.value), position]; + } + case RuleName.MIN_LENGTH: + case RuleName.MAX_LENGTH: { + var [propertyValue, position] = _parseMinLengthMaxLengthPropertyValue( + input, + position + ); + if (propertyValue) { + property.value = propertyValue; + } + return [new Rule(property.name, property.value), position]; + } + } + console.assert(false, SHOULD_NOT_BE_REACHED); +} + +function _parseMinLengthMaxLengthPropertyValue(input, position) { + return _parseInteger(input, position); +} + +function _parseMaxConsecutivePropertyValue(input, position) { + return _parseInteger(input, position); +} + +function _parseInteger(input, position) { + console.assert(position >= 0); + console.assert(position < input.length); + + if (!_isASCIIDigit(input[position])) { + console.error( + "Failed to parse value of type integer; not a number: " + + input.substr(position) + ); + return [null, position]; + } + + let length = input.length; + let initialPosition = position; + let result = 0; + do { + result = 10 * result + parseInt(input[position], 10); + ++position; + } while ( + position < length && + input[position] !== PROPERTY_SEPARATOR && + _isASCIIDigit(input[position]) + ); + + if (position >= length || input[position] === PROPERTY_SEPARATOR) { + return [result, position]; + } + + console.error( + "Failed to parse value of type integer; not a number: " + + input.substr(initialPosition) + ); + return [null, position]; +} + +function _parsePasswordRulesInternal(input) { + let parsedProperties = []; + let length = input.length; + + var position = _indexOfNonWhitespaceCharacter(input); + while (position < length) { + if (!_isIdentifierCharacter(input[position])) { + console.warn( + "Failed to find start of property: " + input.substr(position) + ); + return parsedProperties; + } + + var [parsedProperty, position] = _parsePasswordRule(input, position); + if (parsedProperty && parsedProperty.value) { + parsedProperties.push(parsedProperty); + } + + position = _indexOfNonWhitespaceCharacter(input, position); + if (position >= length) { + break; + } + + if (input[position] === PROPERTY_SEPARATOR) { + position = _indexOfNonWhitespaceCharacter(input, position + 1); + if (position >= length) { + return parsedProperties; + } + + continue; + } + + console.error( + "Failed to find start of next property: " + input.substr(position) + ); + return null; + } + + return parsedProperties; +} + +function parsePasswordRules(input, formatRulesForMinifiedVersion) { + let passwordRules = _parsePasswordRulesInternal(input) || []; + + // When formatting rules for minified version, we should keep the formatted rules + // as similar to the input as possible. Avoid copying required rules to allowed rules. + let suppressCopyingRequiredToAllowed = formatRulesForMinifiedVersion; + + let newPasswordRules = []; + let newAllowedValues = []; + let minimumMaximumConsecutiveCharacters = null; + let maximumMinLength = 0; + let minimumMaxLength = null; + + for (let rule of passwordRules) { + switch (rule.name) { + case RuleName.MAX_CONSECUTIVE: + minimumMaximumConsecutiveCharacters = minimumMaximumConsecutiveCharacters + ? Math.min(rule.value, minimumMaximumConsecutiveCharacters) + : rule.value; + break; + + case RuleName.MIN_LENGTH: + maximumMinLength = Math.max(rule.value, maximumMinLength); + break; + + case RuleName.MAX_LENGTH: + minimumMaxLength = minimumMaxLength + ? Math.min(rule.value, minimumMaxLength) + : rule.value; + break; + + case RuleName.REQUIRED: + rule.value = _canonicalizedPropertyValues( + rule.value, + formatRulesForMinifiedVersion + ); + newPasswordRules.push(rule); + if (!suppressCopyingRequiredToAllowed) { + newAllowedValues = newAllowedValues.concat(rule.value); + } + break; + + case RuleName.ALLOWED: + newAllowedValues = newAllowedValues.concat(rule.value); + break; + } + } + + newAllowedValues = _canonicalizedPropertyValues( + newAllowedValues, + suppressCopyingRequiredToAllowed + ); + if (!suppressCopyingRequiredToAllowed && !newAllowedValues.length) { + newAllowedValues = [new NamedCharacterClass(Identifier.ASCII_PRINTABLE)]; + } + if (newAllowedValues.length) { + newPasswordRules.push(new Rule(RuleName.ALLOWED, newAllowedValues)); + } + + if (minimumMaximumConsecutiveCharacters !== null) { + newPasswordRules.push( + new Rule(RuleName.MAX_CONSECUTIVE, minimumMaximumConsecutiveCharacters) + ); + } + + if (maximumMinLength > 0) { + newPasswordRules.push(new Rule(RuleName.MIN_LENGTH, maximumMinLength)); + } + + if (minimumMaxLength !== null) { + newPasswordRules.push(new Rule(RuleName.MAX_LENGTH, minimumMaxLength)); + } + + return newPasswordRules; +} diff --git a/toolkit/components/passwordmgr/SignUpFormRuleset.sys.mjs b/toolkit/components/passwordmgr/SignUpFormRuleset.sys.mjs new file mode 100644 index 0000000000..711bb9a6cb --- /dev/null +++ b/toolkit/components/passwordmgr/SignUpFormRuleset.sys.mjs @@ -0,0 +1,579 @@ +/* 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/. */ + +/** + * Fathom ML model for identifying sign up + * + * This is developed out-of-tree at https://github.com/mozilla-services/fathom-login-forms, + * where there is also over a GB of training, validation, and + * testing data. To make changes, do your edits there (whether adding new + * training pages, adding new rules, or both), retrain and evaluate as + * documented at https://mozilla.github.io/fathom/training.html, paste the + * coefficients emitted by the trainer into the ruleset, and finally copy the + * ruleset's "CODE TO COPY INTO PRODUCTION" section to this file's "CODE FROM + * TRAINING REPOSITORY" section. + */ + +import { + dom, + out, + rule, + ruleset, + score, + type, + element, + utils, +} from "resource://gre/modules/third_party/fathom/fathom.mjs"; + +let { isVisible, attributesMatch, setDefault } = utils; + +const DEVELOPMENT = false; + +/** + * --- START OF RULESET --- + */ +const coefficients = { + form: new Map([ + ["formAttributesMatchRegisterRegex", 0.4614015519618988], + ["formAttributesMatchLoginRegex", -2.608457326889038], + ["formAttributesMatchSubscriptionRegex", -3.253319501876831], + ["formAttributesMatchLoginAndRegisterRegex", 3.6423728466033936], + ["formHasAcNewPassword", 2.214113473892212], + ["formHasAcCurrentPassword", -0.43707895278930664], + ["formHasEmailField", 1.760241150856018], + ["formHasUsernameField", 1.1527059078216553], + ["formHasPasswordField", 1.6670876741409302], + ["formHasFirstOrLastNameField", 0.9517516493797302], + ["formHasRegisterButton", 1.574048638343811], + ["formHasLoginButton", -1.1688978672027588], + ["formHasSubscribeButton", -0.26299405097961426], + ["formHasContinueButton", 2.3797709941864014], + ["formHasTermsAndConditionsHyperlink", 1.764896035194397], + ["formHasPasswordForgottenHyperlink", -0.32138824462890625], + ["formHasAlreadySignedUpHyperlink", 3.160510301589966], + ["closestElementIsEmailLabelLike", 1.0336143970489502], + ["formHasRememberMeCheckbox", -1.2176686525344849], + ["formHasSubcriptionCheckbox", 0.6100747585296631], + ["docTitleMatchesRegisterRegex", 0.680654764175415], + ["docTitleMatchesEditProfileRegex", -4.104133605957031], + ["closestHeaderMatchesRegisterRegex", 1.3462989330291748], + ["closestHeaderMatchesLoginRegex", -0.1804502159357071], + ["closestHeaderMatchesSubscriptionRegex", -1.3057124614715576], + ]), +}; + +const biases = [["form", -4.402400970458984]]; + +const loginRegex = + /login|log-in|log_in|log in|signon|sign-on|sign_on|sign on|signin|sign-in|sign_in|sign in|einloggen|anmelden|logon|log-on|log_on|log on|Войти|ورود|登录|Přihlásit se|Přihlaste|Авторизоваться|Авторизация|entrar|ログイン|로그인|inloggen|Συνδέσου|accedi|ログオン|Giriş Yap|登入|connecter|connectez-vous|Connexion|Вход|inicia/i; +const registerRegex = + /regist|sign up|signup|sign-up|sign_up|join|new|登録|neu|erstellen|設定|신규|Créer|Nouveau|baru|nouă|nieuw|create[a-zA-Z\s]+account|create[a-zA-Z\s]+profile|activate[a-zA-Z\s]+account|Zugang anlegen|Angaben prüfen|Konto erstellen|ثبت نام|登録|注册|cadastr|Зарегистрироваться|Регистрация|Bellige alynmak|تسجيل|ΕΓΓΡΑΦΗΣ|Εγγραφή|Créer mon compte|Créer un compte|Mendaftar|가입하기|inschrijving|Zarejestruj się|Deschideți un cont|Создать аккаунт|ร่วม|Üye Ol|ساخت حساب کاربری|Schrijf je|S'inscrire/i; +const emailRegex = /mail/i; +const usernameRegex = /user|member/i; +const nameRegex = /first|last|middle/i; +const subscriptionRegex = + /subscri|trial|offer|information|angebote|probe|ニュースレター|abonn|promotion|news/i; +const termsAndConditionsRegex = + /terms|condition|rules|policy|privacy|nutzungsbedingungen|AGB|richtlinien|datenschutz|términos|condiciones/i; +const pwForgottenRegex = + /forgot|reset|set password|vergessen|vergeten|oublié|dimenticata|Esqueceu|esqueci|Забыли|忘记|找回|Zapomenuté|lost|忘れた|忘れられた|忘れの方|재설정|찾기|help|فراموشی| را فراموش کرده اید|Восстановить|Unuttu|perdus|重新設定|recover|remind|request|restore|trouble|olvidada/i; +const continueRegex = + /continue|go on|weiter|fortfahren|ga verder|next|continuar/i; +const rememberMeRegex = + /remember|stay|speichern|merken|bleiben|auto_login|auto-login|auto login|ricordami|manter|mantenha|savelogin|keep me logged in|keep me signed in|save email address|save id|stay signed in|次回からログオンIDの入力を省略する|メールアドレスを保存する|を保存|아이디저장|아이디 저장|로그인 상태 유지|lembrar|mantenha-me conectado|Запомни меня|запомнить меня|Запомните меня|Не спрашивать в следующий раз|下次自动登录|记住我|recordar|angemeldet bleiben/i; +const alreadySignedUpRegex = /already|bereits|schon|ya tienes cuenta/i; +const editProfile = /edit/i; + +function createRuleset(coeffs, biases) { + let elementToSelectors; + + /** + * Check document characteristics + */ + function docTitleMatchesRegisterRegex(fnode) { + const docTitle = fnode.element.ownerDocument.title; + return checkValueAgainstRegex(docTitle, registerRegex); + } + function docTitleMatchesEditProfileRegex(fnode) { + const docTitle = fnode.element.ownerDocument.title; + return checkValueAgainstRegex(docTitle, editProfile); + } + + /** + * Check header + */ + function closestHeaderMatchesLoginRegex(fnode) { + return closestHeaderMatchesPredicate(fnode.element, header => + checkValueAgainstRegex(header.innerText, loginRegex) + ); + } + function closestHeaderMatchesRegisterRegex(fnode) { + return closestHeaderMatchesPredicate(fnode.element, header => + checkValueAgainstRegex(header.innerText, registerRegex) + ); + } + function closestHeaderMatchesSubscriptionRegex(fnode) { + return closestHeaderMatchesPredicate(fnode.element, header => + checkValueAgainstRegex(header.innerText, subscriptionRegex) + ); + } + + /** + * Check checkboxes + */ + function formHasRememberMeCheckbox(fnode) { + return elementHasRegexMatchingCheckbox(fnode.element, rememberMeRegex); + } + function formHasSubcriptionCheckbox(fnode) { + return elementHasRegexMatchingCheckbox(fnode.element, subscriptionRegex); + } + + /** + * Check input fields + */ + function formHasFirstOrLastNameField(fnode) { + const acValues = ["name", "given-name", "family-name"]; + return elementHasPredicateMatchingInput( + fnode.element, + elem => + atLeastOne(acValues.filter(ac => elem.autocomplete == ac)) || + inputFieldMatchesPredicate(elem, attr => + checkValueAgainstRegex(attr, nameRegex) + ) + ); + } + function formHasEmailField(fnode) { + return elementHasPredicateMatchingInput( + fnode.element, + elem => + elem.autocomplete == "email" || + elem.type == "email" || + inputFieldMatchesPredicate(elem, attr => + checkValueAgainstRegex(attr, emailRegex) + ) + ); + } + function formHasUsernameField(fnode) { + return elementHasPredicateMatchingInput( + fnode.element, + elem => + elem.autocomplete == "username" || + inputFieldMatchesPredicate(elem, attr => + checkValueAgainstRegex(attr, usernameRegex) + ) + ); + } + function formHasPasswordField(fnode) { + const acValues = ["current-password", "new-password"]; + return elementHasPredicateMatchingInput( + fnode.element, + elem => + atLeastOne(acValues.filter(ac => elem.autocomplete == ac)) || + elem.type == "password" + ); + } + + /** + * Check autocomplete values + */ + function formHasAcCurrentPassword(fnode) { + return inputFieldMatchesSelector( + fnode.element, + "autocomplete=current-password" + ); + } + function formHasAcNewPassword(fnode) { + return inputFieldMatchesSelector( + fnode.element, + "autocomplete=new-password" + ); + } + + /** + * Check hyperlinks within form + */ + function formHasTermsAndConditionsHyperlink(fnode) { + return elementHasPredicateMatchingHyperlink( + fnode.element, + termsAndConditionsRegex + ); + } + function formHasPasswordForgottenHyperlink(fnode) { + return elementHasPredicateMatchingHyperlink( + fnode.element, + pwForgottenRegex + ); + } + function formHasAlreadySignedUpHyperlink(fnode) { + return elementHasPredicateMatchingHyperlink( + fnode.element, + alreadySignedUpRegex + ); + } + + /** + * Check labels + */ + function closestElementIsEmailLabelLike(fnode) { + return elementHasPredicateMatchingInput(fnode.element, elem => + previousSiblingLabelMatchesRegex(elem, emailRegex) + ); + } + + /** + * Check buttons + */ + function formHasRegisterButton(fnode) { + return elementHasPredicateMatchingButton( + fnode.element, + button => + checkValueAgainstRegex(button.innerText, registerRegex) || + buttonMatchesPredicate(button, attr => + checkValueAgainstRegex(attr, registerRegex) + ) + ); + } + function formHasLoginButton(fnode) { + return elementHasPredicateMatchingButton( + fnode.element, + button => + checkValueAgainstRegex(button.innerText, loginRegex) || + buttonMatchesPredicate(button, attr => + checkValueAgainstRegex(attr, loginRegex) + ) + ); + } + function formHasContinueButton(fnode) { + return elementHasPredicateMatchingButton( + fnode.element, + button => + checkValueAgainstRegex(button.innerText, continueRegex) || + buttonMatchesPredicate(button, attr => + checkValueAgainstRegex(attr, continueRegex) + ) + ); + } + function formHasSubscribeButton(fnode) { + return elementHasPredicateMatchingButton( + fnode.element, + button => + checkValueAgainstRegex(button.innerText, subscriptionRegex) || + buttonMatchesPredicate(button, attr => + checkValueAgainstRegex(attr, subscriptionRegex) + ) + ); + } + + /** + * Check form attributes + */ + function formAttributesMatchRegisterRegex(fnode) { + return formMatchesPredicate(fnode.element, attr => + checkValueAgainstRegex(attr, registerRegex) + ); + } + function formAttributesMatchLoginRegex(fnode) { + return formMatchesPredicate(fnode.element, attr => + checkValueAgainstRegex(attr, loginRegex) + ); + } + function formAttributesMatchSubscriptionRegex(fnode) { + return formMatchesPredicate(fnode.element, attr => + checkValueAgainstRegex(attr, subscriptionRegex) + ); + } + function formAttributesMatchLoginAndRegisterRegex(fnode) { + return formMatchesPredicate(fnode.element, attr => + checkValueAgainstAllRegex(attr, [registerRegex, loginRegex]) + ); + } + + /** + * HELPER FUNCTIONS + */ + function elementMatchesPredicate(element, predicate, additional = []) { + return attributesMatch( + element, + predicate, + ["id", "name", "className"].concat(additional) + ); + } + function formMatchesPredicate(element, predicate) { + return elementMatchesPredicate(element, predicate, ["action"]); + } + function inputFieldMatchesPredicate(element, predicate) { + return elementMatchesPredicate(element, predicate, ["placeholder"]); + } + function inputFieldMatchesSelector(element, selector) { + return atLeastOne(getElementDescendants(element, `input[${selector}]`)); + } + function buttonMatchesPredicate(element, predicate) { + return elementMatchesPredicate(element, predicate, [ + "value", + "id", + "title", + ]); + } + /** + * ELEMENT HAS PREDICATE MATCHING X FUNCTIONS + */ + function elementHasPredicateMatchingDescendant(element, selector, predicate) { + const matchingElements = getElementDescendants(element, selector); + return matchingElements.some(predicate); + } + function elementHasPredicateMatchingHeader(element, predicate) { + return ( + elementHasPredicateMatchingDescendant( + element, + "h1,h2,h3,h4,h5,h6", + predicate + ) || + elementHasPredicateMatchingDescendant( + element, + "div[class*=heading],div[class*=header],div[class*=title],header", + predicate + ) + ); + } + function elementHasPredicateMatchingButton(element, predicate) { + return elementHasPredicateMatchingDescendant( + element, + "button,input[type=submit],input[type=button]", + predicate + ); + } + function elementHasPredicateMatchingInput(element, predicate) { + return elementHasPredicateMatchingDescendant(element, "input", predicate); + } + function elementHasPredicateMatchingHyperlink(element, regexExp) { + return elementHasPredicateMatchingDescendant( + element, + "a", + link => + previousSiblingLabelMatchesRegex(link, regexExp) || + checkValueAgainstRegex(link.innerText, regexExp) || + elementMatchesPredicate( + link, + attr => checkValueAgainstRegex(attr, regexExp), + ["href"] + ) || + nextSiblingLabelMatchesRegex(link, regexExp) + ); + } + function elementHasRegexMatchingCheckbox(element, regexExp) { + return elementHasPredicateMatchingDescendant( + element, + "input[type=checkbox], div[class*=checkbox]", + box => + elementMatchesPredicate(box, attr => + checkValueAgainstRegex(attr, regexExp) + ) || nextSiblingLabelMatchesRegex(box, regexExp) + ); + } + + function nextSiblingLabelMatchesRegex(element, regexExp) { + let nextElem = element.nextElementSibling; + if (nextElem && nextElem.tagName == "LABEL") { + return checkValueAgainstRegex(nextElem.innerText, regexExp); + } + let closestElem = closestElementFollowing(element, "label"); + return closestElem + ? checkValueAgainstRegex(closestElem.innerText, regexExp) + : false; + } + + function previousSiblingLabelMatchesRegex(element, regexExp) { + let previousElem = element.previousElementSibling; + if (previousElem && previousElem.tagName == "LABEL") { + return checkValueAgainstRegex(previousElem.innerText, regexExp); + } + let closestElem = closestElementAbove(element, "label"); + return closestElem + ? checkValueAgainstRegex(closestElem.innerText, regexExp) + : false; + } + function getElementDescendants(element, selector) { + const selectorToDescendants = setDefault( + elementToSelectors, + element, + () => new Map() + ); + + return setDefault( + selectorToDescendants, // prettier-ignore + selector, + () => Array.from(element.querySelectorAll(selector)) + ); + } + function clearCache() { + elementToSelectors = new WeakMap(); + } + function closestHeaderMatchesPredicate(element, predicate) { + return ( + elementHasPredicateMatchingHeader(element, predicate) || + closestHeaderAboveMatchesPredicate(element, predicate) + ); + } + function closestHeaderAboveMatchesPredicate(element, predicate) { + let closestHeader = closestElementAbove(element, "h1,h2,h3,h4,h5,h6"); + + if (closestHeader !== null) { + if (predicate(closestHeader)) { + return true; + } + } + closestHeader = closestElementAbove( + element, + "div[class*=heading],div[class*=header],div[class*=title],header" + ); + return closestHeader ? predicate(closestHeader) : false; + } + function closestElementAbove(element, selector) { + let elements = Array.from( + getElementDescendants(element.ownerDocument, selector) + ); + for (let i = elements.length - 1; i >= 0; --i) { + if ( + element.compareDocumentPosition(elements[i]) & + Node.DOCUMENT_POSITION_PRECEDING + ) { + return elements[i]; + } + } + return null; + } + function closestElementFollowing(element, selector) { + let elements = Array.from( + getElementDescendants(element.ownerDocument, selector) + ); + for (let i = 0; i < elements.length; ++i) { + if ( + element.compareDocumentPosition(elements[i]) & + Node.DOCUMENT_POSITION_FOLLOWING + ) { + return elements[i]; + } + } + return null; + } + function checkValueAgainstAllRegex(value, regexExp = []) { + return regexExp.every(reg => checkValueAgainstRegex(value, reg)); + } + + function checkValueAgainstRegex(value, regexExp) { + return value ? regexExp.test(value) : false; + } + function atLeastOne(iter) { + return iter.length >= 1; + } + + /** + * CREATION OF RULESET + */ + const rules = ruleset( + [ + rule( + DEVELOPMENT ? dom("form").when(isVisible) : element("form"), + type("form").note(clearCache) + ), + // Check form attributes + rule(type("form"), score(formAttributesMatchRegisterRegex), { + name: "formAttributesMatchRegisterRegex", + }), + rule(type("form"), score(formAttributesMatchLoginRegex), { + name: "formAttributesMatchLoginRegex", + }), + rule(type("form"), score(formAttributesMatchSubscriptionRegex), { + name: "formAttributesMatchSubscriptionRegex", + }), + rule(type("form"), score(formAttributesMatchLoginAndRegisterRegex), { + name: "formAttributesMatchLoginAndRegisterRegex", + }), + // Check autocomplete attributes + rule(type("form"), score(formHasAcCurrentPassword), { + name: "formHasAcCurrentPassword", + }), + rule(type("form"), score(formHasAcNewPassword), { + name: "formHasAcNewPassword", + }), + // Check input fields + rule(type("form"), score(formHasEmailField), { + name: "formHasEmailField", + }), + rule(type("form"), score(formHasUsernameField), { + name: "formHasUsernameField", + }), + rule(type("form"), score(formHasPasswordField), { + name: "formHasPasswordField", + }), + rule(type("form"), score(formHasFirstOrLastNameField), { + name: "formHasFirstOrLastNameField", + }), + // Check buttons + rule(type("form"), score(formHasRegisterButton), { + name: "formHasRegisterButton", + }), + rule(type("form"), score(formHasLoginButton), { + name: "formHasLoginButton", + }), + rule(type("form"), score(formHasContinueButton), { + name: "formHasContinueButton", + }), + rule(type("form"), score(formHasSubscribeButton), { + name: "formHasSubscribeButton", + }), + // Check hyperlinks + rule(type("form"), score(formHasTermsAndConditionsHyperlink), { + name: "formHasTermsAndConditionsHyperlink", + }), + rule(type("form"), score(formHasPasswordForgottenHyperlink), { + name: "formHasPasswordForgottenHyperlink", + }), + rule(type("form"), score(formHasAlreadySignedUpHyperlink), { + name: "formHasAlreadySignedUpHyperlink", + }), + // Check labels + rule(type("form"), score(closestElementIsEmailLabelLike), { + name: "closestElementIsEmailLabelLike", + }), + // Check checkboxes + rule(type("form"), score(formHasRememberMeCheckbox), { + name: "formHasRememberMeCheckbox", + }), + rule(type("form"), score(formHasSubcriptionCheckbox), { + name: "formHasSubcriptionCheckbox", + }), + // Check header + rule(type("form"), score(closestHeaderMatchesRegisterRegex), { + name: "closestHeaderMatchesRegisterRegex", + }), + rule(type("form"), score(closestHeaderMatchesLoginRegex), { + name: "closestHeaderMatchesLoginRegex", + }), + rule(type("form"), score(closestHeaderMatchesSubscriptionRegex), { + name: "closestHeaderMatchesSubscriptionRegex", + }), + // Check doc title + rule(type("form"), score(docTitleMatchesRegisterRegex), { + name: "docTitleMatchesRegisterRegex", + }), + rule(type("form"), score(docTitleMatchesEditProfileRegex), { + name: "docTitleMatchesEditProfileRegex", + }), + rule(type("form"), out("form")), + ], + coeffs, + biases + ); + return rules; +} + +/** + * --- END OF RULESET --- + */ + +export const SignUpFormRuleset = { + type: "form", + rules: createRuleset([...coefficients.form], biases), +}; diff --git a/toolkit/components/passwordmgr/components.conf b/toolkit/components/passwordmgr/components.conf new file mode 100644 index 0000000000..9be23b98f5 --- /dev/null +++ b/toolkit/components/passwordmgr/components.conf @@ -0,0 +1,86 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [ + { + 'js_name': 'logins', + 'cid': '{cb9e0de8-3598-4ed7-857b-827f011ad5d8}', + 'contract_ids': ['@mozilla.org/login-manager;1'], + 'interfaces': ['nsILoginManager'], + 'esModule': 'resource://gre/modules/LoginManager.sys.mjs', + 'constructor': 'LoginManager', + }, + { + 'cid': '{749e62f4-60ae-4569-a8a2-de78b649660e}', + 'contract_ids': ['@mozilla.org/passwordmanager/authpromptfactory;1'], + 'esModule': 'resource://gre/modules/LoginManagerAuthPrompter.sys.mjs', + 'constructor': 'LoginManagerAuthPromptFactory', + }, + { + 'cid': '{2bdac17c-53f1-4896-a521-682ccdeef3a8}', + 'contract_ids': ['@mozilla.org/login-manager/autocompletesearch;1'], + 'esModule': 'resource://gre/modules/LoginAutoComplete.sys.mjs', + 'constructor': 'LoginAutoComplete', + }, + { + 'cid': '{8aa66d77-1bbb-45a6-991e-b8f47751c291}', + 'contract_ids': ['@mozilla.org/login-manager/authprompter;1'], + 'esModule': 'resource://gre/modules/LoginManagerAuthPrompter.sys.mjs', + 'constructor': 'LoginManagerAuthPrompter', + }, + { + 'cid': '{0f2f347c-1e4f-40cc-8efd-792dea70a85e}', + 'contract_ids': ['@mozilla.org/login-manager/loginInfo;1'], + 'esModule': 'resource://gre/modules/LoginInfo.sys.mjs', + 'constructor': 'nsLoginInfo', + }, + { + 'cid': '{dc6c2976-0f73-4f1f-b9ff-3d72b4e28309}', + 'contract_ids': ['@mozilla.org/login-manager/crypto/SDR;1'], + 'esModule': 'resource://gre/modules/crypto-SDR.sys.mjs', + 'constructor': 'LoginManagerCrypto_SDR', + }, +] + +if buildconfig.substs['OS_TARGET'] == 'Android': + Classes += [ + { + 'cid': '{337f317f-f713-452a-962d-db831c785fec}', + 'contract_ids': [ + '@mozilla.org/login-manager/storage/geckoview;1', + '@mozilla.org/login-manager/storage/default;1', + ], + 'esModule': 'resource://gre/modules/storage-geckoview.sys.mjs', + 'constructor': 'LoginManagerStorage_geckoview', + }, + ] +else: + Classes += [ + { + 'cid': '{c00c432d-a0c9-46d7-bef6-9c45b4d07341}', + 'contract_ids': [ + '@mozilla.org/login-manager/storage/json;1', + '@mozilla.org/login-manager/storage/default;1', + ], + 'esModule': 'resource://gre/modules/storage-json.sys.mjs', + 'constructor': 'LoginManagerStorage_json', + }, + { + 'cid': '{c47ff942-9678-44a5-bc9b-05e0d676c79c}', + 'contract_ids': ['@mozilla.org/login-manager/prompter;1'], + 'esModule': 'resource://gre/modules/LoginManagerPrompter.sys.mjs', + 'constructor': 'LoginManagerPrompter', + }, + { + 'cid': '{dc185a77-ba88-4caa-8f16-465253f7599a}', + 'contract_ids': [ + '@mozilla.org/autocomplete/search;1?name=login-doorhanger-username', + '@mozilla.org/autocomplete/search;1?name=login-doorhanger-password' + ], + 'esModule': 'resource://gre/modules/AutoCompleteSimpleSearch.sys.mjs', + 'constructor': 'AutoCompleteSimpleSearch', + }, + ] diff --git a/toolkit/components/passwordmgr/crypto-SDR.sys.mjs b/toolkit/components/passwordmgr/crypto-SDR.sys.mjs new file mode 100644 index 0000000000..da3ffe9b58 --- /dev/null +++ b/toolkit/components/passwordmgr/crypto-SDR.sys.mjs @@ -0,0 +1,309 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +export function LoginManagerCrypto_SDR() { + this.init(); +} + +LoginManagerCrypto_SDR.prototype = { + classID: Components.ID("{dc6c2976-0f73-4f1f-b9ff-3d72b4e28309}"), + QueryInterface: ChromeUtils.generateQI(["nsILoginManagerCrypto"]), + + __decoderRing: null, // nsSecretDecoderRing service + get _decoderRing() { + if (!this.__decoderRing) { + this.__decoderRing = Cc["@mozilla.org/security/sdr;1"].getService( + Ci.nsISecretDecoderRing + ); + } + return this.__decoderRing; + }, + + __utfConverter: null, // UCS2 <--> UTF8 string conversion + get _utfConverter() { + if (!this.__utfConverter) { + this.__utfConverter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + this.__utfConverter.charset = "UTF-8"; + } + return this.__utfConverter; + }, + + _utfConverterReset() { + this.__utfConverter = null; + }, + + _uiBusy: false, + + init() { + // Check to see if the internal PKCS#11 token has been initialized. + // If not, set a blank password. + let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService( + Ci.nsIPK11TokenDB + ); + + let token = tokenDB.getInternalKeyToken(); + if (token.needsUserInit) { + this.log("Initializing key3.db with default blank password."); + token.initPassword(""); + } + }, + + /* + * encrypt + * + * Encrypts the specified string, using the SecretDecoderRing. + * + * Returns the encrypted string, or throws an exception if there was a + * problem. + */ + encrypt(plainText) { + let cipherText = null; + + let wasLoggedIn = this.isLoggedIn; + let canceledMP = false; + + this._uiBusy = !wasLoggedIn; + try { + let plainOctet = this._utfConverter.ConvertFromUnicode(plainText); + plainOctet += this._utfConverter.Finish(); + cipherText = this._decoderRing.encryptString(plainOctet); + } catch (e) { + this.log(`Failed to encrypt string with error ${e.name}.`); + // If the user clicks Cancel, we get NS_ERROR_FAILURE. + // (unlike decrypting, which gets NS_ERROR_NOT_AVAILABLE). + if (e.result == Cr.NS_ERROR_FAILURE) { + canceledMP = true; + throw Components.Exception( + "User canceled primary password entry", + Cr.NS_ERROR_ABORT + ); + } else { + throw Components.Exception( + "Couldn't encrypt string", + Cr.NS_ERROR_FAILURE + ); + } + } finally { + this._uiBusy = false; + // If we triggered a primary password prompt, notify observers. + if (!wasLoggedIn && this.isLoggedIn) { + this._notifyObservers("passwordmgr-crypto-login"); + } else if (canceledMP) { + this._notifyObservers("passwordmgr-crypto-loginCanceled"); + } + } + return cipherText; + }, + + /* + * encryptMany + * + * Encrypts the specified strings, using the SecretDecoderRing. + * + * Returns a promise which resolves with the the encrypted strings, + * or throws/rejects with an error if there was a problem. + */ + async encryptMany(plaintexts) { + if (!Array.isArray(plaintexts) || !plaintexts.length) { + throw Components.Exception( + "Need at least one plaintext to encrypt", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let cipherTexts; + + let wasLoggedIn = this.isLoggedIn; + let canceledMP = false; + + this._uiBusy = !wasLoggedIn; + try { + cipherTexts = await this._decoderRing.asyncEncryptStrings(plaintexts); + } catch (e) { + this.log(`Failed to encrypt strings with error ${e.name}.`); + // If the user clicks Cancel, we get NS_ERROR_FAILURE. + // (unlike decrypting, which gets NS_ERROR_NOT_AVAILABLE). + if (e.result == Cr.NS_ERROR_FAILURE) { + canceledMP = true; + throw Components.Exception( + "User canceled primary password entry", + Cr.NS_ERROR_ABORT + ); + } else { + throw Components.Exception( + "Couldn't encrypt strings", + Cr.NS_ERROR_FAILURE + ); + } + } finally { + this._uiBusy = false; + // If we triggered a primary password prompt, notify observers. + if (!wasLoggedIn && this.isLoggedIn) { + this._notifyObservers("passwordmgr-crypto-login"); + } else if (canceledMP) { + this._notifyObservers("passwordmgr-crypto-loginCanceled"); + } + } + return cipherTexts; + }, + + /* + * decrypt + * + * Decrypts the specified string, using the SecretDecoderRing. + * + * Returns the decrypted string, or throws an exception if there was a + * problem. + */ + decrypt(cipherText) { + let plainText = null; + + let wasLoggedIn = this.isLoggedIn; + let canceledMP = false; + + this._uiBusy = !wasLoggedIn; + try { + let plainOctet; + plainOctet = this._decoderRing.decryptString(cipherText); + plainText = this._utfConverter.ConvertToUnicode(plainOctet); + } catch (e) { + this.log( + `Failed to decrypt cipher text of length ${cipherText.length} with error ${e.name}.` + ); + + // In the unlikely event the converter threw, reset it. + this._utfConverterReset(); + + // If the user clicks Cancel, we get NS_ERROR_NOT_AVAILABLE. + // If the cipherText is bad / wrong key, we get NS_ERROR_FAILURE + // Wrong passwords are handled by the decoderRing reprompting; + // we get no notification. + if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + canceledMP = true; + throw Components.Exception( + "User canceled primary password entry", + Cr.NS_ERROR_ABORT + ); + } else { + throw Components.Exception( + "Couldn't decrypt string", + Cr.NS_ERROR_FAILURE + ); + } + } finally { + this._uiBusy = false; + // If we triggered a primary password prompt, notify observers. + if (!wasLoggedIn && this.isLoggedIn) { + this._notifyObservers("passwordmgr-crypto-login"); + } else if (canceledMP) { + this._notifyObservers("passwordmgr-crypto-loginCanceled"); + } + } + + return plainText; + }, + + /** + * Decrypts the specified strings, using the SecretDecoderRing. + * + * @resolve {string[]} The decrypted strings. If a string cannot + * be decrypted, the empty string is returned for that instance. + * Callers will need to use decrypt() to determine if the encrypted + * string is invalid or intentionally empty. Throws/reject with + * an error if there was a problem. + */ + async decryptMany(cipherTexts) { + if (!Array.isArray(cipherTexts) || !cipherTexts.length) { + throw Components.Exception( + "Need at least one ciphertext to decrypt", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let plainTexts = []; + + let wasLoggedIn = this.isLoggedIn; + let canceledMP = false; + + this._uiBusy = !wasLoggedIn; + try { + plainTexts = await this._decoderRing.asyncDecryptStrings(cipherTexts); + } catch (e) { + this.log(`Failed to decrypt strings with error ${e.name}.`); + // If the user clicks Cancel, we get NS_ERROR_NOT_AVAILABLE. + // If the cipherText is bad / wrong key, we get NS_ERROR_FAILURE + // Wrong passwords are handled by the decoderRing reprompting; + // we get no notification. + if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) { + canceledMP = true; + throw Components.Exception( + "User canceled primary password entry", + Cr.NS_ERROR_ABORT + ); + } else { + throw Components.Exception( + "Couldn't decrypt strings: " + e.result, + Cr.NS_ERROR_FAILURE + ); + } + } finally { + this._uiBusy = false; + // If we triggered a primary password prompt, notify observers. + if (!wasLoggedIn && this.isLoggedIn) { + this._notifyObservers("passwordmgr-crypto-login"); + } else if (canceledMP) { + this._notifyObservers("passwordmgr-crypto-loginCanceled"); + } + } + return plainTexts; + }, + + /* + * uiBusy + */ + get uiBusy() { + return this._uiBusy; + }, + + /* + * isLoggedIn + */ + get isLoggedIn() { + let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService( + Ci.nsIPK11TokenDB + ); + let token = tokenDB.getInternalKeyToken(); + return !token.hasPassword || token.isLoggedIn(); + }, + + /* + * defaultEncType + */ + get defaultEncType() { + return Ci.nsILoginManagerCrypto.ENCTYPE_SDR; + }, + + /* + * _notifyObservers + */ + _notifyObservers(topic) { + this.log(`Prompted for a primary password, notifying for ${topic}`); + Services.obs.notifyObservers(null, topic); + }, +}; // end of nsLoginManagerCrypto_SDR implementation + +XPCOMUtils.defineLazyGetter(LoginManagerCrypto_SDR.prototype, "log", () => { + let logger = lazy.LoginHelper.createLogger("Login crypto"); + return logger.log.bind(logger); +}); diff --git a/toolkit/components/passwordmgr/jar.mn b/toolkit/components/passwordmgr/jar.mn new file mode 100644 index 0000000000..89b96a3869 --- /dev/null +++ b/toolkit/components/passwordmgr/jar.mn @@ -0,0 +1,6 @@ +# 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/. + +toolkit.jar: +% content passwordmgr %content/passwordmgr/ diff --git a/toolkit/components/passwordmgr/moz.build b/toolkit/components/passwordmgr/moz.build new file mode 100644 index 0000000000..537fe23868 --- /dev/null +++ b/toolkit/components/passwordmgr/moz.build @@ -0,0 +1,86 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +if CONFIG["MOZ_BUILD_APP"] == "browser": + DEFINES["MOZ_BUILD_APP_IS_BROWSER"] = True + +MOCHITEST_MANIFESTS += ["test/mochitest/mochitest.ini"] +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] + +TESTING_JS_MODULES += [ + "test/LoginTestUtils.sys.mjs", +] + +XPIDL_SOURCES += [ + "nsILoginAutoCompleteSearch.idl", + "nsILoginInfo.idl", + "nsILoginManager.idl", + "nsILoginManagerAuthPrompter.idl", + "nsILoginManagerCrypto.idl", + "nsILoginManagerPrompter.idl", + "nsILoginManagerStorage.idl", + "nsILoginMetaInfo.idl", + "nsIPromptInstance.idl", +] + +XPIDL_MODULE = "loginmgr" + +EXTRA_JS_MODULES += [ + "crypto-SDR.sys.mjs", + "FirefoxRelay.sys.mjs", + "FirefoxRelayTelemetry.mjs", + "InsecurePasswordUtils.sys.mjs", + "LoginAutoComplete.sys.mjs", + "LoginFormFactory.sys.mjs", + "LoginHelper.sys.mjs", + "LoginInfo.sys.mjs", + "LoginManager.shared.mjs", + "LoginManager.sys.mjs", + "LoginManagerAuthPrompter.sys.mjs", + "LoginManagerChild.sys.mjs", + "LoginManagerParent.sys.mjs", + "LoginManagerPrompter.sys.mjs", + "LoginRecipes.sys.mjs", + "LoginRelatedRealms.sys.mjs", + "NewPasswordModel.sys.mjs", + "PasswordGenerator.sys.mjs", + "PasswordRulesManager.sys.mjs", + "PasswordRulesParser.sys.mjs", + "SignUpFormRuleset.sys.mjs", + "storage-json.sys.mjs", +] + +if CONFIG["OS_TARGET"] == "Android": + EXTRA_JS_MODULES += [ + "storage-geckoview.sys.mjs", + ] +else: + EXTRA_JS_MODULES += [ + "CSV.sys.mjs", + "LoginCSVImport.sys.mjs", + "LoginExport.sys.mjs", + "LoginStore.sys.mjs", + ] + +if CONFIG["OS_TARGET"] == "WINNT": + EXTRA_JS_MODULES += [ + "OSCrypto_win.sys.mjs", + ] + +if CONFIG["MOZ_BUILD_APP"] == "browser" or CONFIG["MOZ_SUITE"]: + EXTRA_JS_MODULES += [ + "LoginManagerContextMenu.sys.mjs", + ] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +JAR_MANIFESTS += ["jar.mn"] + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Password Manager") diff --git a/toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl b/toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl new file mode 100644 index 0000000000..6c0e94b445 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl @@ -0,0 +1,31 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIAutoCompleteResult; +interface nsIFormAutoCompleteObserver; + +webidl HTMLInputElement; + +[scriptable, uuid(2bdac17c-53f1-4896-a521-682ccdeef3a8)] +interface nsILoginAutoCompleteSearch : nsISupports { + /** + * Generate results for a login field autocomplete menu. + * + * NOTE: This interface is provided for use only by the FormFillController, + * which calls it directly. This isn't really ideal, it should + * probably be callback registered through the FFC. + * NOTE: This API is different than nsIAutoCompleteSearch. + */ + void startSearch(in AString aSearchString, + in nsIAutoCompleteResult aPreviousResult, + in HTMLInputElement aElement, + in nsIFormAutoCompleteObserver aListener); + + /** + * Stop a previously-started search. + */ + void stopSearch(); +}; diff --git a/toolkit/components/passwordmgr/nsILoginInfo.idl b/toolkit/components/passwordmgr/nsILoginInfo.idl new file mode 100644 index 0000000000..030ed3dbdb --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginInfo.idl @@ -0,0 +1,149 @@ +/* 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/. */ + + +#include "nsISupports.idl" + +/** + * An object containing information for a login stored by the + * password manager. + */ +[scriptable, uuid(c41b7dff-6b9b-42fe-b78d-113051facb05)] +interface nsILoginInfo : nsISupports { + /** + * A string to display to the user for the origin which includes the httpRealm, + * where applicable. + * e.g. "site.com", "site.com:1234", or "site.com (My Secure Realm)" + */ + readonly attribute AString displayOrigin; + + /** + * The origin the login applies to. + * + * For example, + * "https://site.com", "http://site.com:1234", "ftp://ftp.site.com", + * "moz-proxy://127.0.0.1:8888, "chrome://FirefoxAccounts", "file://". + */ + attribute AString origin; + + /** + * The origin the login applies to, incorrectly called a hostname. + * @deprecated in favor of `origin` + */ + attribute AString hostname; + + /** + * The origin a form-based login was submitted to. + * + * For logins obtained from HTML forms, this field is the origin of the |action| + * attribute from the |form| element. For + * example "http://www.site.com". [Forms with no |action| attribute + * default to submitting to their origin URL, so we store that.] + * + * For logins obtained from a HTTP or FTP protocol authentication, + * this field is NULL. + */ + attribute AString formActionOrigin; + + /** + * The origin a form-based login was submitted to, incorrectly referred to as a URL. + * @deprecated in favor of `formActionOrigin` + */ + attribute AString formSubmitURL; + + /** + * The HTTP Realm a login was requested for. + * + * When an HTTP server sends a 401 result, the WWW-Authenticate + * header includes a realm to identify the "protection space." See + * RFC2617. If the response sent has a missing or blank realm, the + * hostname is used instead. + * + * For logins obtained from HTML forms, this field is NULL. + */ + attribute AString httpRealm; + + /** + * The username for the login. + */ + attribute AString username; + + /** + * The |name| attribute for the username input field. + * + * For logins obtained from a HTTP or FTP protocol authentication, + * this field is an empty string. + * + * @note This attribute is currently saved but not used. + */ + attribute AString usernameField; + + /** + * The password for the login. + */ + attribute AString password; + + /** + * The |name| attribute for the password input field. + * + * For logins obtained from a HTTP or FTP protocol authentication, + * this field is an empty string. + * + * @note This attribute is currently saved but not used. + */ + attribute AString passwordField; + + /** + * Unknown fields this client doesn't know about but will be roundtripped + * for other clients to prevent data loss + */ + attribute AString unknownFields; + + /** + * Initialize a newly created nsLoginInfo object. + * + * The arguments are the fields for the new object. + */ + void init(in AString aOrigin, + in AString aFormActionOrigin, in AString aHttpRealm, + in AString aUsername, in AString aPassword, + [optional] in AString aUsernameField, [optional] in AString aPasswordField); + + /** + * Test for strict equality with another nsILoginInfo object. + * + * @param aLoginInfo + * The other object to test. + */ + boolean equals(in nsILoginInfo aLoginInfo); + + /** + * Test for loose equivalency with another nsILoginInfo object. The + * passwordField and usernameField values are ignored, and the password + * values may be optionally ignored. If one login's formSubmitURL is an + * empty string (but not null), it will be treated as a wildcard. [The + * blank value indicates the login was stored before bug 360493 was fixed.] + * + * @param aLoginInfo + * The other object to test. + * @param ignorePassword + * If true, ignore the password when checking for match. + */ + boolean matches(in nsILoginInfo aLoginInfo, in boolean ignorePassword); + + /** + * Create an identical copy of the login, duplicating all of the login's + * nsILoginInfo and nsILoginMetaInfo properties. + * + * This allows code to be forwards-compatible, when additional properties + * are added to nsILoginMetaInfo (or nsILoginInfo) in the future. + */ + nsILoginInfo clone(); +}; + +%{C++ + +#define NS_LOGININFO_CONTRACTID "@mozilla.org/login-manager/loginInfo;1" + +%} diff --git a/toolkit/components/passwordmgr/nsILoginManager.idl b/toolkit/components/passwordmgr/nsILoginManager.idl new file mode 100644 index 0000000000..69e6521ff5 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManager.idl @@ -0,0 +1,334 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsILoginInfo; +interface nsIPropertyBag; + +[scriptable, uuid(43429075-ede6-41eb-ac69-a8cd4376b041)] +interface nsILoginSearchCallback : nsISupports +{ + /** + * Called when a search is complete and the results are ready. + * + * @param aLogins + * Logins found in the search. + */ + void onSearchComplete(in Array aLogins); +}; + +[scriptable, uuid(38c7f6af-7df9-49c7-b558-2776b24e6cc1)] +interface nsILoginManager : nsISupports { + /** + * This promise is resolved when initialization is complete, and is rejected + * in case initialization failed. This includes the initial loading of the + * login data as well as any migration from previous versions. + * + * Calling any method of nsILoginManager before this promise is resolved + * might trigger the synchronous initialization fallback. + */ + readonly attribute Promise initializationPromise; + + /** + * Store a new login in the login manager. + * + * @param aLogin + * The login to be added. + * @return a clone of the login info with the guid set (even if it was not provided) + * + * Default values for the login's nsILoginMetaInfo properties will be + * created. However, if the caller specifies non-default values, they will + * be used instead. + */ + nsILoginInfo addLogin(in nsILoginInfo aLogin); + + /** + * Like addLogin, but asynchronous. + * + * @param aLogin + * The login to be added. + * @return A promise which resolves with a cloned login with the guid set. + * + * Default values for each login's nsILoginMetaInfo properties will be + * created. However, if the caller specifies non-default values, they will + * be used instead. + */ + Promise addLoginAsync(in nsILoginInfo aLogin); + + /** + * Like addLogin, but asynchronous and for many logins. + * + * @param aLogins + * A JS Array of nsILoginInfos to add. + * @return A promise which resolves with a JS Array of cloned logins with + * the guids set. + * + * Default values for each login's nsILoginMetaInfo properties will be + * created. However, if the caller specifies non-default values, they will + * be used instead. + */ + Promise addLogins(in jsval aLogins); + + /** + * Remove a login from the login manager. + * + * @param aLogin + * The login to be removed. + * + * The specified login must exactly match a stored login. However, the + * values of any nsILoginMetaInfo properties are ignored. + */ + void removeLogin(in nsILoginInfo aLogin); + + /** + * Modify an existing login in the login manager. + * + * @param oldLogin + * The login to be modified. + * @param newLoginData + * The new login values (either a nsILoginInfo or nsIProperyBag) + * + * If newLoginData is a nsILoginInfo, all of the old login's nsILoginInfo + * properties are changed to the values from newLoginData (but the old + * login's nsILoginMetaInfo properties are unmodified). + * + * If newLoginData is a nsIPropertyBag, only the specified properties + * will be changed. The nsILoginMetaInfo properties of oldLogin can be + * changed in this manner. + * + * If the propertybag contains an item named "timesUsedIncrement", the + * login's timesUsed property will be incremented by the item's value. + */ + void modifyLogin(in nsILoginInfo oldLogin, in nsISupports newLoginData); + + /** + * Record that the password of a saved login was used (e.g. submitted or copied). + * + * @param {nsILoginInfo} aLogin + * The login record of the password that was used. + * @param {boolean} aPrivateContextWithoutExplicitConsent + * If the use was in private browsing AND without the user explicitly choosing to save/update. + * Login use metadata will not be updated in this case but it will stil be counted for telemetry. + * @param {AString} aLoginType + * One of "form_login", "form_password", "auth_login", or "prompt_login". + * See saved_login_used in Events.yaml. + * Don't assume that an auth. login is never used in a form and vice-versa. This argument + * indicates the context of how it was used. + * @param {boolean} aFilled + * Whether the login was filled, rather than being typed manually. + * + * If only the username was used, this method shouldn't be called as we don't + * want to double-count the use if both the username and password are copied. + * Copying of the username normally precedes the copying of the password anyways. + */ + void recordPasswordUse(in nsILoginInfo aLogin, in boolean aPrivateContextWithoutExplicitConsent, in AString aLoginType, in boolean aFilled); + + /** + * Remove all stored user facing logins. + * + * This will remove all the logins that a user can access through about:logins. + * This will not remove the FxA Sync key which is stored with the rest of a user's logins + * but is not accessible through about:logins + * + * The browser sanitization feature allows the user to clear any stored + * passwords. This interface allows that to be done without getting each + * login first. + * + */ + void removeAllUserFacingLogins(); + + /** + * Completely remove all logins, including the user's FxA Sync key. + * + */ + void removeAllLogins(); + + /** + * Fetch all logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * + * @deprecated Use `getAllLoginsAsync` instead. + * + * @return An array of nsILoginInfo objects. + */ + Array getAllLogins(); + + /** + * Like getAllLogins, but asynchronous. This method is faster when large + * amounts of logins are present since it will handle decryption in one batch. + * + * @return A promise which resolves with a JS Array of nsILoginInfo objects. + */ + Promise getAllLoginsAsync(); + + /** + * Like getAllLoginsAsync, but with a callback returning the search results. + * + * @param {nsILoginSearchCallback} aCallback + * The interface to notify when the search is complete. + * + */ + void getAllLoginsWithCallbackAsync(in nsILoginSearchCallback aCallback); + + /** + * Obtain a list of all origins for which password saving is disabled. + * + * @return An array of origin strings. For example: ["https://www.site.com"]. + */ + Array getAllDisabledHosts(); + + /** + * Check to see if saving logins has been disabled for an origin. + * + * @param aHost + * The origin to check. For example: "http://foo.com". + */ + boolean getLoginSavingEnabled(in AString aHost); + + /** + * Disable (or enable) storing logins for the specified origin. When + * disabled, the login manager will not prompt to store logins for + * that origin. Existing logins are not affected. + * + * @param aHost + * The origin to set. For example: "http://foo.com". + * @param isEnabled + * Specify if saving logins should be enabled (true) or + * disabled (false) + */ + void setLoginSavingEnabled(in AString aHost, in boolean isEnabled); + + /** + * Search for logins matching the specified criteria. Called when looking + * for logins that might be applicable to a form or authentication request. + * + * @deprecated Use `searchLoginsAsync` instead. + * + * @param aOrigin + * The origin to restrict searches to. For example: "http://www.site.com". + * To find logins for a given nsIURI, you would typically pass in + * its prePath (excluding userPass). + * @param aActionOrigin + * For form logins, this argument should be the origin to which the + * form will be submitted, not the whole URL. + * For HTTP auth. logins, specify null. + * An empty string ("") will match any value (except null). + * @param aHttpRealm + * For HTTP auth. logins, this argument should be the HTTP Realm + * for which the login applies. This is obtained from the + * WWW-Authenticate header. See RFC2617. For form logins, + * specify null. + * An empty string ("") will match any value (except null). + * @return An array of nsILoginInfo objects. + */ + Array findLogins(in AString aOrigin, in AString aActionOrigin, + in AString aHttpRealm); + + /** + * Search for logins matching the specified criteria, as with + * findLogins(). This interface only returns the number of matching + * logins (and not the logins themselves), which allows a caller to + * check for logins without causing the user to be prompted for a primary + * password to decrypt the logins. + * + * @param aOrigin + * The origin to restrict searches to. Specify an empty string + * to match all origins. A null value will not match any logins, and + * will thus always return a count of 0. + * @param aActionOrigin + * The origin to which a form login will be submitted. To match any + * form login, specify an empty string. To not match any form + * login, specify null. + * @param aHttpRealm + * The HTTP Realm for which the login applies. To match logins for + * any realm, specify an empty string. To not match logins for any + * realm, specify null. + */ + unsigned long countLogins(in AString aOrigin, in AString aActionOrigin, + in AString aHttpRealm); + + /** + * Asynchonously search for logins in the login manager. The Promise always + * resolves to an array; if there are no logins the array is empty. + * + * @param {object} matchData + * The data used to search as a JS object. This does not follow the same + * requirements as findLogins for those fields. Wildcard matches are + * simply not specified. If a `guid` is specified then no other properties + * are used (outside of GeckoView). + * @return A promise resolving to an array of nsILoginInfo objects. + */ + Promise searchLoginsAsync(in jsval matchData); + + /** + * Search for logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * @deprecated New code should use `searchLoginsAsync`. + * Only autocomplete, prompt, and test code still use this. + * + * @param matchData + * The data used to search. This does not follow the same + * requirements as findLogins for those fields. Wildcard matches are + * simply not specified. If a `guid` is specified then no other properties + * are used (outside of GeckoView). + * @return An array of nsILoginInfo objects. + */ + Array searchLogins(in nsIPropertyBag matchData); + + /** + * Returns the "sync id" used by Sync to know whether the store is current with + * respect to the sync servers. + * + * Returns null if the data doesn't exist or if the data can't be + * decrypted (including if the primary-password prompt is cancelled). This is + * OK for Sync as it can't even begin syncing if the primary-password is + * locked as the sync encrytion keys are stored in this login manager. + */ + Promise getSyncID(); + + /** + * Sets the "sync id" used by Sync to know whether the store is current with + * respect to the sync servers. May be set to null. + * + * Throws if the data can't be encrypted (including if the primary-password + * prompt is cancelled) + */ + Promise setSyncID(in AString syncID); + + /** + * Returns the timestamp of the last sync as a double (in seconds since Epoch + * rounded to two decimal places), or 0.0 if the data doesn't exist. + */ + Promise getLastSync(); + + /** + * Sets the timestamp of the last sync. + */ + Promise setLastSync(in double timestamp); + + /** + * Ensures that the local sync ID for the engine matches the sync ID for + * the collection on the server. If they don't match, then we set + * the local sync ID to newSyncID and reset the last sync timestamp. + */ + Promise ensureCurrentSyncID(in AString newSyncID); + + /** + * True when a primary password prompt is being displayed. + */ + readonly attribute boolean uiBusy; + + /** + * True when the primary password has already been entered, and so a caller + * can ask for decrypted logins without triggering a prompt. + */ + readonly attribute boolean isLoggedIn; +}; + +%{C++ + +#define NS_LOGINMANAGER_CONTRACTID "@mozilla.org/login-manager;1" + +%} diff --git a/toolkit/components/passwordmgr/nsILoginManagerAuthPrompter.idl b/toolkit/components/passwordmgr/nsILoginManagerAuthPrompter.idl new file mode 100644 index 0000000000..422981a0a3 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManagerAuthPrompter.idl @@ -0,0 +1,44 @@ +/* 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/. */ + + +#include "nsISupports.idl" + +interface nsILoginInfo; +interface nsIDOMWindow; + +webidl Element; + +[scriptable, uuid(425f73b9-b2db-4e8a-88c5-9ac2512934ce)] +interface nsILoginManagerAuthPrompter : nsISupports { + /** + * Initialize the prompter. Must be called before using other interfaces. + * + * @param aWindow + * The window in which the user is doing some login-related action that's + * resulting in a need to prompt them for something. The prompt + * will be associated with this window (or, if a notification bar + * is being used, topmost opener in some cases). + * + * aWindow can be null if there is no associated window, e.g. in a JSM + * or Sandbox. In this case there will be no checkbox to save the login + * since the window is needed to know if this is a private context. + * + * If this window is a content window, the corresponding window and browser + * elements will be calculated. If this window is a chrome window, the + * corresponding browser element needs to be set using setBrowser. + */ + void init(in nsIDOMWindow aWindow); + + /** + * The browser this prompter is being created for. + * This is required if the init function received a chrome window as argument. + */ + attribute Element browser; +}; +%{C++ + +#define NS_LOGINMANAGERAUTHPROMPTER_CONTRACTID "@mozilla.org/login-manager/authprompter/;1" + +%} diff --git a/toolkit/components/passwordmgr/nsILoginManagerCrypto.idl b/toolkit/components/passwordmgr/nsILoginManagerCrypto.idl new file mode 100644 index 0000000000..936228548a --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManagerCrypto.idl @@ -0,0 +1,89 @@ +/* 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/. */ + + +#include "nsISupports.idl" + +[scriptable, uuid(2030770e-542e-40cd-8061-cd9d4ad4227f)] +interface nsILoginManagerCrypto : nsISupports { + + const unsigned long ENCTYPE_BASE64 = 0; // obsolete + const unsigned long ENCTYPE_SDR = 1; + + /** + * encrypt + * + * @param plainText + * The string to be encrypted. + * + * Encrypts the specified string, returning the ciphertext value. + * + * NOTE: The current implemention of this inferface simply uses NSS/PSM's + * "Secret Decoder Ring" service. It is not recommended for general + * purpose encryption/decryption. + * + * Can throw if the user cancels entry of their primary password. + */ + AString encrypt(in AString plainText); + + /* + * encryptMany + * + * @param plainTexts + * The strings to be encrypted. + * + * Encrypts the specified strings, similar to encrypt, but returning a promise + * which resolves with the the encrypted strings. + */ + Promise encryptMany(in jsval plainTexts); + + /** + * decrypt + * + * @param cipherText + * The string to be decrypted. + * + * Decrypts the specified string, returning the plaintext value. + * + * Can throw if the user cancels entry of their primary password, or if the + * cipherText value can not be successfully decrypted (eg, if it was + * encrypted with some other key). + */ + AString decrypt(in AString cipherText); + + /** + * @param cipherTexts + * The strings to be decrypted. + * + * Decrypts the specified strings, returning the plaintext values. + * + * Can throw if the user cancels entry of their primary password, or if the + * cipherText value can not be successfully decrypted (eg, if it was + * encrypted with some other key). + */ + Promise decryptMany(in jsval cipherTexts); + + /** + * uiBusy + * + * True when a primary password prompt is being displayed. + */ + readonly attribute boolean uiBusy; + + /** + * isLoggedIn + * + * Current login state of the token used for encryption. If the user is + * not logged in, performing a crypto operation will result in a primary + * password prompt. + */ + readonly attribute boolean isLoggedIn; + + /** + * defaultEncType + * + * Default encryption type used by an implementation of this interface. + */ + readonly attribute unsigned long defaultEncType; +}; diff --git a/toolkit/components/passwordmgr/nsILoginManagerPrompter.idl b/toolkit/components/passwordmgr/nsILoginManagerPrompter.idl new file mode 100644 index 0000000000..4f244258c0 --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManagerPrompter.idl @@ -0,0 +1,103 @@ +/* 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/. */ + + +#include "nsISupports.idl" +#include "nsIPromptInstance.idl" + +interface nsILoginInfo; +interface nsIDOMWindow; + +webidl Element; + +[scriptable, uuid(c47ff942-9678-44a5-bc9b-05e0d676c79c)] +interface nsILoginManagerPrompter : nsISupports { + + /** + * Ask the user if they want to save a login (Yes, Never, Not Now) + * + * @param aBrowser + * The browser of the webpage request that triggered the prompt. + * @param aLogin + * The login to be saved. + * @param dismissed + * A boolean value indicating whether the save logins doorhanger should + * be dismissed automatically when shown. + * @param notifySaved + * A boolean value indicating whether the notification should indicate that + * a login has been saved + * @param autoFilledLoginGuid + * A string guid value for the login which was autofilled into the form + * @param possibleValues + * Contains values from anything that we think, but are not sure, might be + * a username or password. Has two properties, 'usernames' and 'passwords'. + */ + nsIPromptInstance promptToSavePassword( + in Element aBrowser, + in nsILoginInfo aLogin, + [optional] in boolean dismissed, + [optional] in boolean notifySaved, + [optional] in AString autoFilledLoginGuid, + [optional] in jsval possibleValues); + + /** + * Ask the user if they want to change a login's password or username. + * If the user consents, modifyLogin() will be called. + * + * @param aBrowser + * The browser of the webpage request that triggered the prompt. + * @param aOldLogin + * The existing login (with the old password). + * @param aNewLogin + * The new login. + * @param dismissed + * A boolean value indicating whether the save logins doorhanger should + * be dismissed automatically when shown. + * @param autoSavedLoginGuid + * A string guid value for the old login to be removed if the changes + * match it to a different login + * @param autoFilledLoginGuid + * A string guid value for the login which was autofilled into the form + * @param possibleValues + * Contains values from anything that we think, but are not sure, might be + * a username or password. Has two properties, 'usernames' and 'passwords'. + */ + nsIPromptInstance promptToChangePassword( + in Element aBrowser, + in nsILoginInfo aOldLogin, + in nsILoginInfo aNewLogin, + [optional] in boolean dismissed, + [optional] in boolean notifySaved, + [optional] in AString autoSavedLoginGuid, + [optional] in AString autoFilledLoginGuid, + [optional] in jsval possibleValues); + + /** + * Ask the user if they want to change the password for one of + * multiple logins, when the caller can't determine exactly which + * login should be changed. If the user consents, modifyLogin() will + * be called. + * + * @param aBrowser + * The browser of the webpage request that triggered the prompt. + * @param logins + * An array of existing logins. + * @param aNewLogin + * The new login. + * + * Note: Because the caller does not know the username of the login + * to be changed, aNewLogin.username and aNewLogin.usernameField + * will be set (using the user's selection) before modifyLogin() + * is called. + */ + nsIPromptInstance promptToChangePasswordWithUsernames( + in Element aBrowser, + in Array logins, + in nsILoginInfo aNewLogin); +}; +%{C++ + +#define NS_LOGINMANAGERPROMPTER_CONTRACTID "@mozilla.org/login-manager/prompter/;1" + +%} diff --git a/toolkit/components/passwordmgr/nsILoginManagerStorage.idl b/toolkit/components/passwordmgr/nsILoginManagerStorage.idl new file mode 100644 index 0000000000..8792b144ec --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginManagerStorage.idl @@ -0,0 +1,258 @@ +/* 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/. */ + + +#include "nsISupports.idl" + +interface nsIFile; +interface nsILoginInfo; +interface nsIPropertyBag; + +/** + * NOTE: This interface is intended to be implemented by modules + * providing storage mechanisms for the login manager. + * Other code should use the login manager's interfaces + * (nsILoginManager), and should not call storage modules + * directly. + */ +[scriptable, uuid(5df81a93-25e6-4b45-a696-089479e15c7d)] +interface nsILoginManagerStorage : nsISupports { + /** + * Initialize the component. + * + * At present, other methods of this interface may be called before the + * returned promise is resolved or rejected. + * + * @return {Promise} + * @resolves When initialization is complete. + * @rejects JavaScript exception. + */ + Promise initialize(); + + /** + * Ensures that all data has been written to disk and all files are closed. + * + * At present, this method is called by regression tests only. Finalization + * on shutdown is done by observers within the component. + * + * @return {Promise} + * @resolves When finalization is complete. + * @rejects JavaScript exception. + */ + Promise terminate(); + + /** + * Store a new login in the storage module. + * + * @param aLogin + * The login to be added. + * @param aPreEncrypted + * Whether the login was already encrypted or not. + * @param aPlaintextUsername + * The plaintext username, if the login was already encrypted. + * @param aPlaintextPassword + * The plaintext password, if the login was already encrypted. + * @return a clone of the login info with the guid set (even if it was not provided). + * + * Default values for the login's nsILoginMetaInfo properties will be + * created. However, if the caller specifies non-default values, they will + * be used instead. + */ + nsILoginInfo addLogin(in nsILoginInfo aLogin, [optional] in boolean aPreEncrypted, [optional] in jsval aPlaintextUsername, [optional] in jsval aPlaintextPassword); + + /** + * Remove a login from the storage module. + * + * @param aLogin + * The login to be removed. + * + * The specified login must exactly match a stored login. However, the + * values of any nsILoginMetaInfo properties are ignored. + */ + void removeLogin(in nsILoginInfo aLogin); + + /** + * Modify an existing login in the storage module. + * + * @param oldLogin + * The login to be modified. + * @param newLoginData + * The new login values (either a nsILoginInfo or nsIProperyBag) + * + * If newLoginData is a nsILoginInfo, all of the old login's nsILoginInfo + * properties are changed to the values from newLoginData (but the old + * login's nsILoginMetaInfo properties are unmodified). + * + * If newLoginData is a nsIPropertyBag, only the specified properties + * will be changed. The nsILoginMetaInfo properties of oldLogin can be + * changed in this manner. + * + * If the propertybag contains an item named "timesUsedIncrement", the + * login's timesUsed property will be incremented by the item's value. + */ + void modifyLogin(in nsILoginInfo oldLogin, in nsISupports newLoginData); + + /** + * Record that the password of a saved login was used (e.g. submitted or copied). + * + * @param nsILoginInfo aLogin + * The login record of the password that was used. + * + * If only the username was used, this method shouldn't be called as we don't + * want to double-count the use if both the username and password are copied. + * Copying of the username normally precedes the copying of the password anyways. + */ + void recordPasswordUse(in nsILoginInfo aLogin); + + /** + * Remove all stored user facing logins. + * + * This will remove all the logins that a user can access through about:logins. + * This will not remove the FxA Sync key which is stored with the rest of a user's logins + * but is not accessible through about:logins + * + * The browser sanitization feature allows the user to clear any stored + * passwords. This interface allows that to be done without getting each + * login first. + * + */ + void removeAllUserFacingLogins(); + + /** + * Completely remove all logins, including the user's FxA key. + * + */ + void removeAllLogins(); + + /** + * Fetch all logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * + * @deprecated Use `getAllLoginsAsync` instead. + * + * @return An array of nsILoginInfo objects. + */ + Array getAllLogins(); + + /** + * Fetch all logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * + * @return An array of nsILoginInfo objects. + */ + Promise getAllLoginsAsync(); + + /** + * Asynchonously search for logins in the login manager. The Promise always + * resolves to an array; if there are no logins the array is empty. + * + * @param {object} matchData + * The data used to search as a JS object. This does not follow the same + * requirements as findLogins for those fields. Wildcard matches are + * simply not specified. + * @return A promise resolving to an array of nsILoginInfo objects. + */ + Promise searchLoginsAsync(in jsval matchData); + + /** + * Search for logins in the login manager. An array is always returned; + * if there are no logins the array is empty. + * + * @deprecated New code should use `searchLoginsAsync`. + * Only autocomplete, prompt, and test code still use this. + * + * @param matchData + * The data used to search. This does not follow the same + * requirements as findLogins for those fields. Wildcard matches are + * simply not specified. + * @return An array of nsILoginInfo objects. + */ + Array searchLogins(in nsIPropertyBag matchData); + + /** + * Search for logins matching the specified criteria. Called when looking + * for logins that might be applicable to a form or authentication request. + * + * @deprecated Use `searchLoginsAsync` instead. + * + * @param aOrigin + * The origin to restrict searches to. For example: "http://www.site.com". + * @param aActionURL + * For form logins, this argument should be the origin to which the + * form will be submitted. For HTTP auth. logins, specify null. + * @param aHttpRealm + * For protocol logins, this argument should be the HTTP Realm + * for which the login applies. This is obtained from the + * WWW-Authenticate header. See RFC2617. For form logins, + * specify null. + * @return An array of nsILoginInfo objects. + */ + Array findLogins(in AString aOrigin, in AString aActionOrigin, + in AString aHttpRealm); + + /** + * Search for logins matching the specified criteria, as with + * findLogins(). This interface only returns the number of matching + * logins (and not the logins themselves), which allows a caller to + * check for logins without causing the user to be prompted for a primary + * password to decrypt the logins. + * + * @param aOrigin + * The origin to restrict searches to. Specify an empty string + * to match all origins. A null value will not match any logins, and + * will thus always return a count of 0. + * @param aActionOrigin + * The origin to which a form login will be submitted. To match any + * form login, specify an empty string. To not match any form + * login, specify null. + * @param aHttpRealm + * The HTTP Realm for which the login applies. To match logins for + * any realm, specify an empty string. To not match logins for any + * realm, specify null. + */ + unsigned long countLogins(in AString aOrigin, in AString aActionOrigin, + in AString aHttpRealm); + + /** + * Returns the "sync id" used by Sync to know whether the store is current with + * respect to the sync servers. + * + * Returns null if the data doesn't exist or if the data can't be + * decrypted (including if the primary-password prompt is cancelled). This is + * OK for Sync as it can't even begin syncing if the primary-password is + * locked as the sync encrytion keys are stored in this login manager. + */ + Promise getSyncID(); + + /** + * Sets the "sync id" used by Sync to know whether the store is current with + * respect to the sync servers. May be set to null. + * + * Throws if the data can't be encrypted (including if the primary-password + * prompt is cancelled) + */ + Promise setSyncID(in AString syncID); + + /** + * Returns the timestamp of the last sync as a double (in seconds since Epoch + * rounded to two decimal places), or 0.0 if the data doesn't exist. + */ + Promise getLastSync(); + + /** + * Sets the timestamp of the last sync. + */ + Promise setLastSync(in double timestamp); + + /** + * True when a primary password prompt is being shown. + */ + readonly attribute boolean uiBusy; + + /** + * True when the primary password has already been entered, and so a caller + * can ask for decrypted logins without triggering a prompt. + */ + readonly attribute boolean isLoggedIn; +}; diff --git a/toolkit/components/passwordmgr/nsILoginMetaInfo.idl b/toolkit/components/passwordmgr/nsILoginMetaInfo.idl new file mode 100644 index 0000000000..0d0c3ec0ea --- /dev/null +++ b/toolkit/components/passwordmgr/nsILoginMetaInfo.idl @@ -0,0 +1,54 @@ +/* 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/. */ + + +#include "nsISupports.idl" + +/** + * An object containing metainfo for a login stored by the login manager. + * + * Code using login manager can generally ignore this interface. When adding + * logins, default value will be created. When modifying logins, these + * properties will be unchanged unless a change is explicitly requested [by + * using modifyLogin() with a nsIPropertyBag]. When deleting a login or + * comparing logins, these properties are ignored. + */ +[scriptable, uuid(20d8eb40-c494-497f-b2a6-aaa32f807ebd)] +interface nsILoginMetaInfo : nsISupports { + /** + * The GUID to uniquely identify the login. This can be any arbitrary + * string, but a format as created by nsIUUIDGenerator is recommended. + * For example, "{d4e1a1f6-5ea0-40ee-bff5-da57982f21cf}" + * + * addLogin will generate a random value unless a value is provided. + * + * addLogin and modifyLogin will throw if the GUID already exists. + */ + attribute AString guid; + + /** + * The time, in Unix Epoch milliseconds, when the login was first created. + */ + attribute unsigned long long timeCreated; + + /** + * The time, in Unix Epoch milliseconds, when the login was last submitted + * in a form or used to begin an HTTP auth session. + */ + attribute unsigned long long timeLastUsed; + + /** + * The time, in Unix Epoch milliseconds, when the login was last modified. + * + * Contrary to what the name may suggest, this attribute takes into account + * not only the password but also the username attribute. + */ + attribute unsigned long long timePasswordChanged; + + /** + * The number of times the login was submitted in a form or used to begin + * an HTTP auth session. + */ + attribute unsigned long timesUsed; +}; diff --git a/toolkit/components/passwordmgr/nsIPromptInstance.idl b/toolkit/components/passwordmgr/nsIPromptInstance.idl new file mode 100644 index 0000000000..04cf5c98cd --- /dev/null +++ b/toolkit/components/passwordmgr/nsIPromptInstance.idl @@ -0,0 +1,17 @@ +/* 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/. */ + + +#include "nsISupports.idl" + +/** + * An object representing a prompt or doorhanger. + */ +[scriptable, uuid(889842e9-052c-46c9-99f3-f4a426571e38)] +interface nsIPromptInstance : nsISupports { + /** + * Dismiss this prompt (e.g. because it's not relevant anymore). + */ + void dismiss(); +}; diff --git a/toolkit/components/passwordmgr/storage-geckoview.sys.mjs b/toolkit/components/passwordmgr/storage-geckoview.sys.mjs new file mode 100644 index 0000000000..6be2b3ed16 --- /dev/null +++ b/toolkit/components/passwordmgr/storage-geckoview.sys.mjs @@ -0,0 +1,256 @@ +/* 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/. */ + +/** + * nsILoginManagerStorage implementation for GeckoView + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { LoginManagerStorage_json } from "resource://gre/modules/storage-json.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm", + LoginEntry: "resource://gre/modules/GeckoViewAutocomplete.jsm", +}); + +export class LoginManagerStorage_geckoview extends LoginManagerStorage_json { + get classID() { + return Components.ID("{337f317f-f713-452a-962d-db831c785fec}"); + } + get QueryInterface() { + return ChromeUtils.generateQI(["nsILoginManagerStorage"]); + } + + get _crypto() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + initialize() { + try { + return Promise.resolve(); + } catch (e) { + this.log("Initialization failed:", e); + throw new Error("Initialization failed"); + } + } + + /** + * Internal method used by regression tests only. It is called before + * replacing this storage module with a new instance. + */ + terminate() {} + + addLogin( + login, + preEncrypted = false, + plaintextUsername = null, + plaintextPassword = null + ) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + removeLogin(login) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + modifyLogin(oldLogin, newLoginData) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + recordPasswordUse(login) { + lazy.GeckoViewAutocomplete.onLoginPasswordUsed( + lazy.LoginEntry.fromLoginInfo(login) + ); + } + + getAllLogins() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Returns an array of all saved logins that can be decrypted. + * + * @resolve {nsILoginInfo[]} + */ + async getAllLoginsAsync() { + return this._getLoginsAsync({}); + } + + async searchLoginsAsync(matchData) { + this.log( + `Searching for matching saved logins for origin: ${matchData.origin}` + ); + return this._getLoginsAsync(matchData); + } + + _baseHostnameFromOrigin(origin) { + if (!origin) { + return null; + } + + let originURI = Services.io.newURI(origin); + try { + return Services.eTLD.getBaseDomain(originURI); + } catch (ex) { + if (ex.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS) { + // `getBaseDomain` cannot handle IP addresses and `nsIURI` cannot return + // IPv6 hostnames with the square brackets so use `URL.hostname`. + return new URL(origin).hostname; + } else if (ex.result == Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS) { + return originURI.asciiHost; + } + throw ex; + } + } + + async _getLoginsAsync(matchData) { + let baseHostname = this._baseHostnameFromOrigin(matchData.origin); + + // Query all logins for the eTLD+1 and then filter the logins in _searchLogins + // so that we can handle the logic for scheme upgrades, subdomains, etc. + // Convert from the new shape to one which supports the legacy getters used + // by _searchLogins. + let candidateLogins = await lazy.GeckoViewAutocomplete.fetchLogins( + baseHostname + ).catch(_ => { + // No GV delegate is attached. + }); + + if (!candidateLogins) { + // May be undefined if there is no delegate attached to handle the request. + // Ignore the request. + return []; + } + + let realMatchData = {}; + let options = {}; + + if (matchData.guid) { + // Enforce GUID-based filtering when available, since the origin of the + // login may not match the origin of the form in the case of scheme + // upgrades. + realMatchData = { guid: matchData.guid }; + } else { + for (let [name, value] of Object.entries(matchData)) { + switch (name) { + // Some property names aren't field names but are special options to + // affect the search. + case "acceptDifferentSubdomains": + case "schemeUpgrades": { + options[name] = value; + break; + } + default: { + realMatchData[name] = value; + break; + } + } + } + } + + const [logins] = this._searchLogins( + realMatchData, + options, + candidateLogins.map(this._vanillaLoginToStorageLogin) + ); + return logins; + } + + /** + * Convert a modern decrypted vanilla login object to one expected from logins.json. + * + * The storage login is usually encrypted but not in this case, this aligns + * with the `_decryptLogins` method being a no-op. + * + * @param {object} vanillaLogin using `origin`/`formActionOrigin`/`username` properties. + * @returns {object} a vanilla login for logins.json using + * `hostname`/`formSubmitURL`/`encryptedUsername`. + */ + _vanillaLoginToStorageLogin(vanillaLogin) { + return { + ...vanillaLogin, + hostname: vanillaLogin.origin, + formSubmitURL: vanillaLogin.formActionOrigin, + encryptedUsername: vanillaLogin.username, + encryptedPassword: vanillaLogin.password, + }; + } + + /** + * Use `searchLoginsAsync` instead. + */ + searchLogins(matchData) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Removes all logins from storage. + */ + removeAllLogins() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + countLogins(origin, formActionOrigin, httpRealm) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + get uiBusy() { + return false; + } + + get isLoggedIn() { + return true; + } + + /** + * GeckoView will encrypt the login itself. + */ + _encryptLogin(login) { + return login; + } + + /** + * GeckoView logins are already decrypted before this component receives them + * so this method is a no-op for this backend. + * @see _vanillaLoginToStorageLogin + */ + _decryptLogins(logins) { + return logins; + } + + /** + * Sync metadata, which isn't supported by GeckoView. + */ + async getSyncID() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async setSyncID(syncID) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async getLastSync() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async setLastSync(timestamp) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } +} + +XPCOMUtils.defineLazyGetter( + LoginManagerStorage_geckoview.prototype, + "log", + () => { + let logger = lazy.LoginHelper.createLogger("Login storage"); + return logger.log.bind(logger); + } +); diff --git a/toolkit/components/passwordmgr/storage-json.sys.mjs b/toolkit/components/passwordmgr/storage-json.sys.mjs new file mode 100644 index 0000000000..2345ed8dc9 --- /dev/null +++ b/toolkit/components/passwordmgr/storage-json.sys.mjs @@ -0,0 +1,879 @@ +/* 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/. */ + +/** + * nsILoginManagerStorage implementation for the JSON back-end. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + LoginStore: "resource://gre/modules/LoginStore.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.js", + FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.js", +}); + +export class LoginManagerStorage_json { + constructor() { + this.__crypto = null; // nsILoginManagerCrypto service + this.__decryptedPotentiallyVulnerablePasswords = null; + } + + get classID() { + return Components.ID("{c00c432d-a0c9-46d7-bef6-9c45b4d07341}"); + } + + get QueryInterface() { + return ChromeUtils.generateQI(["nsILoginManagerStorage"]); + } + + get _crypto() { + if (!this.__crypto) { + this.__crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"].getService( + Ci.nsILoginManagerCrypto + ); + } + return this.__crypto; + } + + get _decryptedPotentiallyVulnerablePasswords() { + if (!this.__decryptedPotentiallyVulnerablePasswords) { + this._store.ensureDataReady(); + this.__decryptedPotentiallyVulnerablePasswords = []; + for (const potentiallyVulnerablePassword of this._store.data + .potentiallyVulnerablePasswords) { + const decryptedPotentiallyVulnerablePassword = this._crypto.decrypt( + potentiallyVulnerablePassword.encryptedPassword + ); + this.__decryptedPotentiallyVulnerablePasswords.push( + decryptedPotentiallyVulnerablePassword + ); + } + } + return this.__decryptedPotentiallyVulnerablePasswords; + } + + initialize() { + try { + // Force initialization of the crypto module. + // See bug 717490 comment 17. + this._crypto; + + let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path; + + // Set the reference to LoginStore synchronously. + let jsonPath = PathUtils.join(profileDir, "logins.json"); + let backupPath = ""; + let loginsBackupEnabled = Services.prefs.getBoolPref( + "signon.backup.enabled" + ); + if (loginsBackupEnabled) { + backupPath = PathUtils.join(profileDir, "logins-backup.json"); + } + this._store = new lazy.LoginStore(jsonPath, backupPath); + + return (async () => { + // Load the data asynchronously. + this.log(`Opening database at ${this._store.path}.`); + await this._store.load(); + })().catch(console.error); + } catch (e) { + this.log(`Initialization failed ${e.name}.`); + throw new Error("Initialization failed"); + } + } + + /** + * Internal method used by regression tests only. It is called before + * replacing this storage module with a new instance. + */ + terminate() { + this._store._saver.disarm(); + return this._store._save(); + } + + /** + * Returns the "sync id" used by Sync to know whether the store is current with + * respect to the sync servers. It is stored encrypted, but only so we + * can detect failure to decrypt (for example, a "reset" of the primary + * password will leave all logins alone, but they will fail to decrypt. We + * also want this metadata to be unavailable in that scenario) + * + * Returns null if the data doesn't exist or if the data can't be + * decrypted (including if the primary-password prompt is cancelled). This is + * OK for Sync as it can't even begin syncing if the primary-password is + * locked as the sync encrytion keys are stored in this login manager. + */ + async getSyncID() { + await this._store.load(); + if (!this._store.data.sync) { + return null; + } + let raw = this._store.data.sync.syncID; + try { + return raw ? this._crypto.decrypt(raw) : null; + } catch (e) { + if (e.result == Cr.NS_ERROR_FAILURE) { + this.log("Could not decrypt the syncID - returning null."); + return null; + } + // any other errors get re-thrown. + throw e; + } + } + + async setSyncID(syncID) { + await this._store.load(); + if (!this._store.data.sync) { + this._store.data.sync = {}; + } + this._store.data.sync.syncID = syncID ? this._crypto.encrypt(syncID) : null; + this._store.saveSoon(); + } + + async getLastSync() { + await this._store.load(); + if (!this._store.data.sync) { + return 0; + } + return this._store.data.sync.lastSync || 0.0; + } + + async setLastSync(timestamp) { + await this._store.load(); + if (!this._store.data.sync) { + this._store.data.sync = {}; + } + this._store.data.sync.lastSync = timestamp; + this._store.saveSoon(); + } + + addLogin( + login, + preEncrypted = false, + plaintextUsername = null, + plaintextPassword = null + ) { + if ( + preEncrypted && + (typeof plaintextUsername != "string" || + typeof plaintextPassword != "string") + ) { + throw new Error( + "plaintextUsername and plaintextPassword are required when preEncrypted is true" + ); + } + + this._store.ensureDataReady(); + + // Throws if there are bogus values. + lazy.LoginHelper.checkLoginValues(login); + + let [encUsername, encPassword, encType, encUnknownFields] = preEncrypted + ? [ + login.username, + login.password, + this._crypto.defaultEncType, + login.unknownFields, + ] + : this._encryptLogin(login); + + // Reset the username and password to keep the same guarantees for preEncrypted + if (preEncrypted) { + login.username = plaintextUsername; + login.password = plaintextPassword; + } + + // Clone the login, so we don't modify the caller's object. + let loginClone = login.clone(); + + // Initialize the nsILoginMetaInfo fields, unless the caller gave us values + loginClone.QueryInterface(Ci.nsILoginMetaInfo); + if (loginClone.guid) { + let guid = loginClone.guid; + if (!this._isGuidUnique(guid)) { + // We have an existing GUID, but it's possible that entry is unable + // to be decrypted - if that's the case we remove the existing one + // and allow this one to be added. + let existing = this._searchLogins({ guid })[0]; + if (this._decryptLogins(existing).length) { + // Existing item is good, so it's an error to try and re-add it. + throw new Error("specified GUID already exists"); + } + // find and remove the existing bad entry. + let foundIndex = this._store.data.logins.findIndex(l => l.guid == guid); + if (foundIndex == -1) { + throw new Error("can't find a matching GUID to remove"); + } + this._store.data.logins.splice(foundIndex, 1); + } + } else { + loginClone.guid = Services.uuid.generateUUID().toString(); + } + + // Set timestamps + let currentTime = Date.now(); + if (!loginClone.timeCreated) { + loginClone.timeCreated = currentTime; + } + if (!loginClone.timeLastUsed) { + loginClone.timeLastUsed = currentTime; + } + if (!loginClone.timePasswordChanged) { + loginClone.timePasswordChanged = currentTime; + } + if (!loginClone.timesUsed) { + loginClone.timesUsed = 1; + } + + this._store.data.logins.push({ + id: this._store.data.nextId++, + hostname: loginClone.origin, + httpRealm: loginClone.httpRealm, + formSubmitURL: loginClone.formActionOrigin, + usernameField: loginClone.usernameField, + passwordField: loginClone.passwordField, + encryptedUsername: encUsername, + encryptedPassword: encPassword, + guid: loginClone.guid, + encType, + timeCreated: loginClone.timeCreated, + timeLastUsed: loginClone.timeLastUsed, + timePasswordChanged: loginClone.timePasswordChanged, + timesUsed: loginClone.timesUsed, + encryptedUnknownFields: encUnknownFields, + }); + this._store.saveSoon(); + + // Send a notification that a login was added. + lazy.LoginHelper.notifyStorageChanged("addLogin", loginClone); + return loginClone; + } + + removeLogin(login) { + this._store.ensureDataReady(); + + let [idToDelete, storedLogin] = this._getIdForLogin(login); + if (!idToDelete) { + throw new Error("No matching logins"); + } + + let foundIndex = this._store.data.logins.findIndex(l => l.id == idToDelete); + if (foundIndex != -1) { + this._store.data.logins.splice(foundIndex, 1); + this._store.saveSoon(); + } + + lazy.LoginHelper.notifyStorageChanged("removeLogin", storedLogin); + } + + modifyLogin(oldLogin, newLoginData) { + this._store.ensureDataReady(); + + let [idToModify, oldStoredLogin] = this._getIdForLogin(oldLogin); + if (!idToModify) { + throw new Error("No matching logins"); + } + + let newLogin = lazy.LoginHelper.buildModifiedLogin( + oldStoredLogin, + newLoginData + ); + + // Check if the new GUID is duplicate. + if ( + newLogin.guid != oldStoredLogin.guid && + !this._isGuidUnique(newLogin.guid) + ) { + throw new Error("specified GUID already exists"); + } + + // Look for an existing entry in case key properties changed. + if (!newLogin.matches(oldLogin, true)) { + let logins = this.findLogins( + newLogin.origin, + newLogin.formActionOrigin, + newLogin.httpRealm + ); + + let matchingLogin = logins.find(login => newLogin.matches(login, true)); + if (matchingLogin) { + throw lazy.LoginHelper.createLoginAlreadyExistsError( + matchingLogin.guid + ); + } + } + + // Get the encrypted value of the username and password. + let [encUsername, encPassword, encType, encUnknownFields] = + this._encryptLogin(newLogin); + + for (let loginItem of this._store.data.logins) { + if (loginItem.id == idToModify) { + loginItem.hostname = newLogin.origin; + loginItem.httpRealm = newLogin.httpRealm; + loginItem.formSubmitURL = newLogin.formActionOrigin; + loginItem.usernameField = newLogin.usernameField; + loginItem.passwordField = newLogin.passwordField; + loginItem.encryptedUsername = encUsername; + loginItem.encryptedPassword = encPassword; + loginItem.guid = newLogin.guid; + loginItem.encType = encType; + loginItem.timeCreated = newLogin.timeCreated; + loginItem.timeLastUsed = newLogin.timeLastUsed; + loginItem.timePasswordChanged = newLogin.timePasswordChanged; + loginItem.timesUsed = newLogin.timesUsed; + loginItem.encryptedUnknownFields = encUnknownFields; + this._store.saveSoon(); + break; + } + } + + lazy.LoginHelper.notifyStorageChanged("modifyLogin", [ + oldStoredLogin, + newLogin, + ]); + } + + recordPasswordUse(login) { + // Update the lastUsed timestamp and increment the use count. + let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + propBag.setProperty("timeLastUsed", Date.now()); + propBag.setProperty("timesUsedIncrement", 1); + this.modifyLogin(login, propBag); + } + + async recordBreachAlertDismissal(loginGUID) { + this._store.ensureDataReady(); + const dismissedBreachAlertsByLoginGUID = + this._store._data.dismissedBreachAlertsByLoginGUID; + + dismissedBreachAlertsByLoginGUID[loginGUID] = { + timeBreachAlertDismissed: new Date().getTime(), + }; + + return this._store.saveSoon(); + } + + getBreachAlertDismissalsByLoginGUID() { + this._store.ensureDataReady(); + return this._store._data.dismissedBreachAlertsByLoginGUID; + } + + /** + * @return {nsILoginInfo[]} + */ + getAllLogins() { + this._store.ensureDataReady(); + + let [logins] = this._searchLogins({}); + + // decrypt entries for caller. + logins = this._decryptLogins(logins); + + this.log(`Returning ${logins.length} logins.`); + return logins; + } + + /** + * Returns an array of nsILoginInfo. If decryption of a login + * fails due to a corrupt entry, the login is not included in + * the resulting array. + * + * @resolve {nsILoginInfo[]} + */ + async getAllLoginsAsync() { + this._store.ensureDataReady(); + + let [logins] = this._searchLogins({}); + if (!logins.length) { + return []; + } + let ciphertexts = logins + .map(l => l.username) + .concat(logins.map(l => l.password)); + let plaintexts = await this._crypto.decryptMany(ciphertexts); + let usernames = plaintexts.slice(0, logins.length); + let passwords = plaintexts.slice(logins.length); + + let result = []; + for (let i = 0; i < logins.length; i++) { + if (!usernames[i] || !passwords[i]) { + // If the username or password is blank it means that decryption may have + // failed during decryptMany but we can't differentiate an empty string + // value from a failure so we attempt to decrypt again and check the + // result. + let login = logins[i]; + try { + this._crypto.decrypt(login.username); + this._crypto.decrypt(login.password); + } catch (e) { + // If decryption failed (corrupt entry?), just skip it. + // Rethrow other errors (like canceling entry of a primary pw) + if (e.result == Cr.NS_ERROR_FAILURE) { + this.log( + `Could not decrypt login: ${ + login.QueryInterface(Ci.nsILoginMetaInfo).guid + }.` + ); + continue; + } + throw e; + } + } + + logins[i].username = usernames[i]; + logins[i].password = passwords[i]; + result.push(logins[i]); + } + + return result; + } + + async searchLoginsAsync(matchData) { + this.log(`Searching for matching logins for origin ${matchData.origin}.`); + let result = this.searchLogins(lazy.LoginHelper.newPropertyBag(matchData)); + // Emulate being async: + return Promise.resolve(result); + } + + /** + * Public wrapper around _searchLogins to convert the nsIPropertyBag to a + * JavaScript object and decrypt the results. + * + * @return {nsILoginInfo[]} which are decrypted. + */ + searchLogins(matchData) { + this._store.ensureDataReady(); + + let realMatchData = {}; + let options = {}; + + matchData.QueryInterface(Ci.nsIPropertyBag2); + if (matchData.hasKey("guid")) { + // Enforce GUID-based filtering when available, since the origin of the + // login may not match the origin of the form in the case of scheme + // upgrades. + realMatchData = { guid: matchData.getProperty("guid") }; + } else { + // Convert nsIPropertyBag to normal JS object. + for (let prop of matchData.enumerator) { + switch (prop.name) { + // Some property names aren't field names but are special options to + // affect the search. + case "acceptDifferentSubdomains": + case "schemeUpgrades": + case "acceptRelatedRealms": + case "relatedRealms": { + options[prop.name] = prop.value; + break; + } + default: { + realMatchData[prop.name] = prop.value; + break; + } + } + } + } + + let [logins] = this._searchLogins(realMatchData, options); + + // Decrypt entries found for the caller. + logins = this._decryptLogins(logins); + + return logins; + } + + /** + * Private method to perform arbitrary searches on any field. Decryption is + * left to the caller. + * + * Returns [logins, ids] for logins that match the arguments, where logins + * is an array of encrypted nsLoginInfo and ids is an array of associated + * ids in the database. + */ + _searchLogins( + matchData, + aOptions = { + schemeUpgrades: false, + acceptDifferentSubdomains: false, + acceptRelatedRealms: false, + relatedRealms: [], + }, + candidateLogins = this._store.data.logins + ) { + if ( + "formActionOrigin" in matchData && + matchData.formActionOrigin === "" && + // Carve an exception out for a unit test in test_legacy_empty_formSubmitURL.js + Object.keys(matchData).length != 1 + ) { + throw new Error( + "Searching with an empty `formActionOrigin` doesn't do a wildcard search" + ); + } + + function match(aLoginItem) { + for (let field in matchData) { + let wantedValue = matchData[field]; + + // Override the storage field name for some fields due to backwards + // compatibility with Sync/storage. + let storageFieldName = field; + switch (field) { + case "formActionOrigin": { + storageFieldName = "formSubmitURL"; + break; + } + case "origin": { + storageFieldName = "hostname"; + break; + } + } + + switch (field) { + case "formActionOrigin": + if (wantedValue != null) { + // Historical compatibility requires this special case + if (aLoginItem.formSubmitURL == "") { + break; + } + if ( + !lazy.LoginHelper.isOriginMatching( + aLoginItem[storageFieldName], + wantedValue, + aOptions + ) + ) { + return false; + } + break; + } + // fall through + case "origin": + if (wantedValue != null) { + // needed for formActionOrigin fall through + if ( + !lazy.LoginHelper.isOriginMatching( + aLoginItem[storageFieldName], + wantedValue, + aOptions + ) + ) { + return false; + } + break; + } + // Normal cases. + // fall through + case "httpRealm": + case "id": + case "usernameField": + case "passwordField": + case "encryptedUsername": + case "encryptedPassword": + case "guid": + case "encType": + case "timeCreated": + case "timeLastUsed": + case "timePasswordChanged": + case "timesUsed": + if (wantedValue == null && aLoginItem[storageFieldName]) { + return false; + } else if (aLoginItem[storageFieldName] != wantedValue) { + return false; + } + break; + // Fail if caller requests an unknown property. + default: + throw new Error("Unexpected field: " + field); + } + } + return true; + } + + let foundLogins = [], + foundIds = []; + for (let loginItem of candidateLogins) { + if (match(loginItem)) { + // Create the new nsLoginInfo object, push to array + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + login.init( + loginItem.hostname, + loginItem.formSubmitURL, + loginItem.httpRealm, + loginItem.encryptedUsername, + loginItem.encryptedPassword, + loginItem.usernameField, + loginItem.passwordField + ); + // set nsILoginMetaInfo values + login.QueryInterface(Ci.nsILoginMetaInfo); + login.guid = loginItem.guid; + login.timeCreated = loginItem.timeCreated; + login.timeLastUsed = loginItem.timeLastUsed; + login.timePasswordChanged = loginItem.timePasswordChanged; + login.timesUsed = loginItem.timesUsed; + + // Any unknown fields along for the ride + login.unknownFields = loginItem.encryptedUnknownFields; + foundLogins.push(login); + foundIds.push(loginItem.id); + } + } + + this.log( + `Returning ${foundLogins.length} logins for specified origin with options ${aOptions}` + ); + return [foundLogins, foundIds]; + } + + /** + * Removes all logins from local storage, including FxA Sync key. + * + * NOTE: You probably want removeAllUserFacingLogins instead of this function. + * + */ + removeAllLogins() { + this._store.ensureDataReady(); + this._store.data.logins = []; + this._store.data.potentiallyVulnerablePasswords = []; + this.__decryptedPotentiallyVulnerablePasswords = null; + this._store.data.dismissedBreachAlertsByLoginGUID = {}; + this._store.saveSoon(); + + lazy.LoginHelper.notifyStorageChanged("removeAllLogins", []); + } + + /** + * Removes all user facing logins from storage. e.g. all logins except the FxA Sync key + * + * If you need to remove the FxA key, use `removeAllLogins` instead + */ + removeAllUserFacingLogins() { + this._store.ensureDataReady(); + this.log("Removing all logins."); + + let [allLogins] = this._searchLogins({}); + + let fxaKey = this._store.data.logins.find( + login => + login.hostname == lazy.FXA_PWDMGR_HOST && + login.httpRealm == lazy.FXA_PWDMGR_REALM + ); + if (fxaKey) { + this._store.data.logins = [fxaKey]; + allLogins = allLogins.filter(item => item != fxaKey); + } else { + this._store.data.logins = []; + } + + this._store.data.potentiallyVulnerablePasswords = []; + this.__decryptedPotentiallyVulnerablePasswords = null; + this._store.data.dismissedBreachAlertsByLoginGUID = {}; + this._store.saveSoon(); + + lazy.LoginHelper.notifyStorageChanged("removeAllLogins", allLogins); + } + + findLogins(origin, formActionOrigin, httpRealm) { + this._store.ensureDataReady(); + + let loginData = { + origin, + formActionOrigin, + httpRealm, + }; + let matchData = {}; + for (let field of ["origin", "formActionOrigin", "httpRealm"]) { + if (loginData[field] != "") { + matchData[field] = loginData[field]; + } + } + let [logins] = this._searchLogins(matchData); + + // Decrypt entries found for the caller. + logins = this._decryptLogins(logins); + + this.log(`Returning ${logins.length} logins.`); + return logins; + } + + countLogins(origin, formActionOrigin, httpRealm) { + this._store.ensureDataReady(); + + let loginData = { + origin, + formActionOrigin, + httpRealm, + }; + let matchData = {}; + for (let field of ["origin", "formActionOrigin", "httpRealm"]) { + if (loginData[field] != "") { + matchData[field] = loginData[field]; + } + } + let [logins] = this._searchLogins(matchData); + + this.log(`Counted ${logins.length} logins.`); + return logins.length; + } + + addPotentiallyVulnerablePassword(login) { + this._store.ensureDataReady(); + // this breached password is already stored + if (this.isPotentiallyVulnerablePassword(login)) { + return; + } + this.__decryptedPotentiallyVulnerablePasswords.push(login.password); + + this._store.data.potentiallyVulnerablePasswords.push({ + encryptedPassword: this._crypto.encrypt(login.password), + }); + this._store.saveSoon(); + } + + isPotentiallyVulnerablePassword(login) { + return this._decryptedPotentiallyVulnerablePasswords.includes( + login.password + ); + } + + clearAllPotentiallyVulnerablePasswords() { + this._store.ensureDataReady(); + if (!this._store.data.potentiallyVulnerablePasswords.length) { + // No need to write to disk + return; + } + this._store.data.potentiallyVulnerablePasswords = []; + this._store.saveSoon(); + this.__decryptedPotentiallyVulnerablePasswords = null; + } + + get uiBusy() { + return this._crypto.uiBusy; + } + + get isLoggedIn() { + return this._crypto.isLoggedIn; + } + + /** + * Returns an array with two items: [id, login]. If the login was not + * found, both items will be null. The returned login contains the actual + * stored login (useful for looking at the actual nsILoginMetaInfo values). + */ + _getIdForLogin(login) { + this._store.ensureDataReady(); + + let matchData = {}; + for (let field of ["origin", "formActionOrigin", "httpRealm"]) { + if (login[field] != "") { + matchData[field] = login[field]; + } + } + let [logins, ids] = this._searchLogins(matchData); + + let id = null; + let foundLogin = null; + + // The specified login isn't encrypted, so we need to ensure + // the logins we're comparing with are decrypted. We decrypt one entry + // at a time, lest _decryptLogins return fewer entries and screw up + // indices between the two. + for (let i = 0; i < logins.length; i++) { + let [decryptedLogin] = this._decryptLogins([logins[i]]); + + if (!decryptedLogin || !decryptedLogin.equals(login)) { + continue; + } + + // We've found a match, set id and break + foundLogin = decryptedLogin; + id = ids[i]; + break; + } + + return [id, foundLogin]; + } + + /** + * Checks to see if the specified GUID already exists. + */ + _isGuidUnique(guid) { + this._store.ensureDataReady(); + + return this._store.data.logins.every(l => l.guid != guid); + } + + /** + * Returns the encrypted username, password, and encrypton type for the specified + * login. Can throw if the user cancels a primary password entry. + */ + _encryptLogin(login) { + let encUsername = this._crypto.encrypt(login.username); + let encPassword = this._crypto.encrypt(login.password); + + // Unknown fields should be encrypted since we can't know whether new fields + // from other clients will contain sensitive data or not + let encUnknownFields = null; + if (login.unknownFields) { + encUnknownFields = this._crypto.encrypt(login.unknownFields); + } + let encType = this._crypto.defaultEncType; + + return [encUsername, encPassword, encType, encUnknownFields]; + } + + /** + * Decrypts username and password fields in the provided array of + * logins. + * + * The entries specified by the array will be decrypted, if possible. + * An array of successfully decrypted logins will be returned. The return + * value should be given to external callers (since still-encrypted + * entries are useless), whereas internal callers generally don't want + * to lose unencrypted entries (eg, because the user clicked Cancel + * instead of entering their primary password) + */ + _decryptLogins(logins) { + let result = []; + + for (let login of logins) { + try { + login.username = this._crypto.decrypt(login.username); + login.password = this._crypto.decrypt(login.password); + // Verify unknownFields actually has a value + if (login.unknownFields) { + login.unknownFields = this._crypto.decrypt(login.unknownFields); + } + } catch (e) { + // If decryption failed (corrupt entry?), just skip it. + // Rethrow other errors (like canceling entry of a primary pw) + if (e.result == Cr.NS_ERROR_FAILURE) { + continue; + } + throw e; + } + result.push(login); + } + + return result; + } +} + +XPCOMUtils.defineLazyGetter(LoginManagerStorage_json.prototype, "log", () => { + let logger = lazy.LoginHelper.createLogger("Login storage"); + return logger.log.bind(logger); +}); diff --git a/toolkit/components/passwordmgr/test/LoginTestUtils.sys.mjs b/toolkit/components/passwordmgr/test/LoginTestUtils.sys.mjs new file mode 100644 index 0000000000..7a7e3835f0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/LoginTestUtils.sys.mjs @@ -0,0 +1,654 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Shared functions generally available for testing login components. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", +}); + +import { Assert as AssertCls } from "resource://testing-common/Assert.sys.mjs"; + +let Assert = AssertCls; + +import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs"; +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { FileTestUtils } from "resource://testing-common/FileTestUtils.sys.mjs"; + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" +); + +export const LoginTestUtils = { + setAssertReporter(reporterFunc) { + Assert = new AssertCls(Cu.waiveXrays(reporterFunc)); + }, + + /** + * Forces the storage module to save all data, and the Login Manager service + * to replace the storage module with a newly initialized instance. + */ + async reloadData() { + Services.obs.notifyObservers(null, "passwordmgr-storage-replace"); + await TestUtils.topicObserved("passwordmgr-storage-replace-complete"); + }, + + /** + * Erases all the data stored by the Login Manager service. + */ + clearData() { + Services.logins.removeAllUserFacingLogins(); + for (let origin of Services.logins.getAllDisabledHosts()) { + Services.logins.setLoginSavingEnabled(origin, true); + } + }, + + /** + * Add a new login to the store + */ + async addLogin({ + username, + password, + origin = "https://example.com", + formActionOrigin, + }) { + const login = LoginTestUtils.testData.formLogin({ + origin, + formActionOrigin: formActionOrigin || origin, + username, + password, + }); + return Services.logins.addLoginAsync(login); + }, + + async modifyLogin(oldLogin, newLogin) { + const storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + Services.logins.modifyLogin(oldLogin, newLogin); + await storageChangedPromise; + }, + + resetGeneratedPasswordsCache() { + let { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" + ); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + }, + + /** + * Checks that the currently stored list of nsILoginInfo matches the provided + * array. If no `checkFn` is provided, the comparison uses the "equals" + * method of nsILoginInfo, that does not include nsILoginMetaInfo properties in the test. + */ + checkLogins(expectedLogins, msg = "checkLogins", checkFn = undefined) { + this.assertLoginListsEqual( + Services.logins.getAllLogins(), + expectedLogins, + msg, + checkFn + ); + }, + + /** + * Checks that the two provided arrays of nsILoginInfo have the same length, + * and every login in "expected" is also found in "actual". If no `checkFn` + * is provided, the comparison uses the "equals" method of nsILoginInfo, that + * does not include nsILoginMetaInfo properties in the test. + */ + assertLoginListsEqual( + actual, + expected, + msg = "assertLoginListsEqual", + checkFn = undefined + ) { + Assert.equal(expected.length, actual.length, msg); + Assert.ok( + expected.every(e => + actual.some(a => { + return checkFn ? checkFn(a, e) : a.equals(e); + }) + ), + msg + ); + }, + + /** + * Checks that the two provided arrays of strings contain the same values, + * maybe in a different order, case-sensitively. + */ + assertDisabledHostsEqual(actual, expected) { + Assert.deepEqual(actual.sort(), expected.sort()); + }, + + /** + * Checks whether the given time, expressed as the number of milliseconds + * since January 1, 1970, 00:00:00 UTC, falls within 30 seconds of now. + */ + assertTimeIsAboutNow(timeMs) { + Assert.ok(Math.abs(timeMs - Date.now()) < 30000); + }, +}; + +/** + * This object contains functions that return new instances of nsILoginInfo for + * every call. The returned instances can be compared using their "equals" or + * "matches" methods, or modified for the needs of the specific test being run. + * + * Any modification to the test data requires updating the tests accordingly, in + * particular the search tests. + */ +LoginTestUtils.testData = { + /** + * Returns a new nsILoginInfo for use with form submits. + * + * @param modifications + * Each property of this object replaces the property of the same name + * in the returned nsILoginInfo or nsILoginMetaInfo. + */ + formLogin(modifications) { + let loginInfo = new LoginInfo( + "http://www3.example.com", + "http://www.example.com", + null, + "the username", + "the password", + "form_field_username", + "form_field_password" + ); + loginInfo.QueryInterface(Ci.nsILoginMetaInfo); + if (modifications) { + for (let [name, value] of Object.entries(modifications)) { + if (name == "httpRealm" && value !== null) { + throw new Error("httpRealm not supported for form logins"); + } + loginInfo[name] = value; + } + } + return loginInfo; + }, + + /** + * Returns a new nsILoginInfo for use with HTTP authentication. + * + * @param modifications + * Each property of this object replaces the property of the same name + * in the returned nsILoginInfo or nsILoginMetaInfo. + */ + authLogin(modifications) { + let loginInfo = new LoginInfo( + "http://www.example.org", + null, + "The HTTP Realm", + "the username", + "the password" + ); + loginInfo.QueryInterface(Ci.nsILoginMetaInfo); + if (modifications) { + for (let [name, value] of Object.entries(modifications)) { + if (name == "formActionOrigin" && value !== null) { + throw new Error( + "formActionOrigin not supported for HTTP auth. logins" + ); + } + loginInfo[name] = value; + } + } + return loginInfo; + }, + + /** + * Returns an array of typical nsILoginInfo that could be stored in the + * database. + */ + loginList() { + return [ + // --- Examples of form logins (subdomains of example.com) --- + + // Simple form login with named fields for username and password. + new LoginInfo( + "http://www.example.com", + "http://www.example.com", + null, + "the username", + "the password for www.example.com", + "form_field_username", + "form_field_password" + ), + + // Different schemes are treated as completely different sites. + new LoginInfo( + "https://www.example.com", + "https://www.example.com", + null, + "the username", + "the password for https", + "form_field_username", + "form_field_password" + ), + + // Subdomains can be treated as completely different sites depending on the UI invoked. + new LoginInfo( + "https://example.com", + "https://example.com", + null, + "the username", + "the password for example.com", + "form_field_username", + "form_field_password" + ), + + // Forms found on the same origin, but with different origins in the + // "action" attribute, are handled independently. + new LoginInfo( + "http://www3.example.com", + "http://www.example.com", + null, + "the username", + "the password", + "form_field_username", + "form_field_password" + ), + new LoginInfo( + "http://www3.example.com", + "https://www.example.com", + null, + "the username", + "the password", + "form_field_username", + "form_field_password" + ), + new LoginInfo( + "http://www3.example.com", + "http://example.com", + null, + "the username", + "the password", + "form_field_username", + "form_field_password" + ), + + // It is not possible to store multiple passwords for the same username, + // however multiple passwords can be stored when the usernames differ. + // An empty username is a valid case and different from the others. + new LoginInfo( + "http://www4.example.com", + "http://www4.example.com", + null, + "username one", + "password one", + "form_field_username", + "form_field_password" + ), + new LoginInfo( + "http://www4.example.com", + "http://www4.example.com", + null, + "username two", + "password two", + "form_field_username", + "form_field_password" + ), + new LoginInfo( + "http://www4.example.com", + "http://www4.example.com", + null, + "", + "password three", + "form_field_username", + "form_field_password" + ), + + // Username and passwords fields in forms may have no "name" attribute. + new LoginInfo( + "http://www5.example.com", + "http://www5.example.com", + null, + "multi username", + "multi password", + "", + "" + ), + + // Forms with PIN-type authentication will typically have no username. + new LoginInfo( + "http://www6.example.com", + "http://www6.example.com", + null, + "", + "12345", + "", + "form_field_password" + ), + + // Logins can be saved on non-default ports + new LoginInfo( + "https://www7.example.com:8080", + "https://www7.example.com:8080", + null, + "8080_username", + "8080_pass" + ), + + new LoginInfo( + "https://www7.example.com:8080", + null, + "My dev server", + "8080_username2", + "8080_pass2" + ), + + // --- Examples of authentication logins (subdomains of example.org) --- + + // Simple HTTP authentication login. + new LoginInfo( + "http://www.example.org", + null, + "The HTTP Realm", + "the username", + "the password" + ), + + // Simple FTP authentication login. + new LoginInfo( + "ftp://ftp.example.org", + null, + "ftp://ftp.example.org", + "the username", + "the password" + ), + + // Multiple HTTP authentication logins can be stored for different realms. + new LoginInfo( + "http://www2.example.org", + null, + "The HTTP Realm", + "the username", + "the password" + ), + new LoginInfo( + "http://www2.example.org", + null, + "The HTTP Realm Other", + "the username other", + "the password other" + ), + + // --- Both form and authentication logins (example.net) --- + + new LoginInfo( + "http://example.net", + "http://example.net", + null, + "the username", + "the password", + "form_field_username", + "form_field_password" + ), + new LoginInfo( + "http://example.net", + "http://www.example.net", + null, + "the username", + "the password", + "form_field_username", + "form_field_password" + ), + new LoginInfo( + "http://example.net", + "http://www.example.net", + null, + "username two", + "the password", + "form_field_username", + "form_field_password" + ), + new LoginInfo( + "http://example.net", + null, + "The HTTP Realm", + "the username", + "the password" + ), + new LoginInfo( + "http://example.net", + null, + "The HTTP Realm Other", + "username two", + "the password" + ), + new LoginInfo( + "ftp://example.net", + null, + "ftp://example.net", + "the username", + "the password" + ), + + // --- Examples of logins added by extensions (chrome scheme) --- + + new LoginInfo( + "chrome://example_extension", + null, + "Example Login One", + "the username", + "the password one", + "", + "" + ), + new LoginInfo( + "chrome://example_extension", + null, + "Example Login Two", + "the username", + "the password two" + ), + + // -- file:// URIs throw accessing nsIURI.host + + new LoginInfo( + "file://", + "file://", + null, + "file: username", + "file: password" + ), + + // -- javascript: URIs throw accessing nsIURI.host. + // They should only be used for the formActionOrigin. + new LoginInfo( + "https://js.example.com", + "javascript:", + null, + "javascript: username", + "javascript: password" + ), + ]; + }, +}; + +LoginTestUtils.recipes = { + getRecipeParent() { + let { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" + ); + if (!LoginManagerParent.recipeParentPromise) { + return null; + } + return LoginManagerParent.recipeParentPromise.then(recipeParent => { + return recipeParent; + }); + }, +}; + +LoginTestUtils.primaryPassword = { + primaryPassword: "omgsecret!", + + _set(enable, stayLoggedIn) { + let oldPW, newPW; + if (enable) { + oldPW = ""; + newPW = this.primaryPassword; + } else { + oldPW = this.primaryPassword; + newPW = ""; + } + try { + let pk11db = Cc["@mozilla.org/security/pk11tokendb;1"].getService( + Ci.nsIPK11TokenDB + ); + let token = pk11db.getInternalKeyToken(); + if (token.needsUserInit) { + dump("MP initialized to " + newPW + "\n"); + token.initPassword(newPW); + } else { + token.checkPassword(oldPW); + dump("MP change from " + oldPW + " to " + newPW + "\n"); + token.changePassword(oldPW, newPW); + if (!stayLoggedIn) { + token.logoutSimple(); + } + } + } catch (e) { + dump( + "Tried to enable an already enabled primary password or disable an already disabled primary password!" + ); + } + }, + + enable(stayLoggedIn = false) { + this._set(true, stayLoggedIn); + }, + + disable() { + this._set(false); + }, +}; + +/** + * Utilities related to interacting with login fields in content. + */ +LoginTestUtils.loginField = { + checkPasswordMasked(field, expected, msg) { + let { editor } = field; + let valueLength = field.value.length; + Assert.equal( + editor.autoMaskingEnabled, + expected, + `Check autoMaskingEnabled: ${msg}` + ); + Assert.equal(editor.unmaskedStart, 0, `unmaskedStart is 0: ${msg}`); + if (expected) { + Assert.equal(editor.unmaskedEnd, 0, `Password is masked: ${msg}`); + } else { + Assert.equal( + editor.unmaskedEnd, + valueLength, + `Unmasked to the end: ${msg}` + ); + } + }, +}; + +LoginTestUtils.generation = { + LENGTH: 15, + REGEX: /^[a-km-np-zA-HJ-NP-Z2-9-~!@#$%^&*_+=)}:;"'>,.?\]]{15}$/, +}; + +LoginTestUtils.telemetry = { + async waitForEventCount( + count, + process = "content", + category = "pwmgr", + method = undefined + ) { + // The test is already unreliable (see bug 1627419 and 1605494) and relied on + // the implicit 100ms initial timer of waitForCondition that bug 1596165 removed. + await new Promise(resolve => setTimeout(resolve, 100)); + let events = await TestUtils.waitForCondition(() => { + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + )[process]; + + if (!events) { + return null; + } + + events = events.filter( + e => e[1] == category && (!method || e[2] == method) + ); + dump(`Waiting for ${count} events, got ${events.length}\n`); + return events.length == count ? events : null; + }, "waiting for telemetry event count of: " + count); + Assert.equal(events.length, count, "waiting for telemetry event count"); + return events; + }, +}; + +LoginTestUtils.file = { + /** + * Given an array of strings it creates a temporary CSV file that has them as content. + * + * @param {string[]} csvLines + * The lines that make up the CSV file. + * @param {string} extension + * Optional parameter. Either 'csv' or 'tsv'. Default is 'csv'. + * @returns {window.File} The File to the CSV file that was created. + */ + async setupCsvFileWithLines(csvLines, extension = "csv") { + let tmpFile = FileTestUtils.getTempFile(`firefox_logins.${extension}`); + await IOUtils.writeUTF8(tmpFile.path, csvLines.join("\r\n")); + return tmpFile; + }, +}; + +LoginTestUtils.remoteSettings = { + relatedRealmsCollection: "websites-with-shared-credential-backends", + async setupWebsitesWithSharedCredentials( + relatedRealms = [["other-example.com", "example.com", "example.co.uk"]] + ) { + let db = lazy.RemoteSettings(this.relatedRealmsCollection).db; + await db.clear(); + await db.create({ + id: "some-fake-ID-abc", + relatedRealms, + }); + await db.importChanges({}, Date.now()); + }, + async cleanWebsitesWithSharedCredentials() { + let db = lazy.RemoteSettings(this.relatedRealmsCollection).db; + await db.importChanges({}, Date.now(), [], { clear: true }); + }, + improvedPasswordRulesCollection: "password-rules", + + async setupImprovedPasswordRules( + origin = "example.com", + rules = "minlength: 6; maxlength: 16; required: lower, upper; required: digit; required: [&<>'\"!#$%(),:;=?[^`{|}~]]; max-consecutive: 2;" + ) { + let db = lazy.RemoteSettings(this.improvedPasswordRulesCollection).db; + await db.clear(); + await db.create({ + id: "some-fake-ID", + Domain: origin, + "password-rules": rules, + }); + await db.create({ + id: "some-fake-ID-2", + Domain: origin, + "password-rules": rules, + }); + await db.importChanges({}, Date.now()); + }, + async cleanImprovedPasswordRules() { + let db = lazy.RemoteSettings(this.improvedPasswordRulesCollection).db; + await db.importChanges({}, Date.now(), [], { clear: true }); + }, +}; diff --git a/toolkit/components/passwordmgr/test/authenticate.sjs b/toolkit/components/passwordmgr/test/authenticate.sjs new file mode 100644 index 0000000000..5d73dfef4c --- /dev/null +++ b/toolkit/components/passwordmgr/test/authenticate.sjs @@ -0,0 +1,229 @@ +"use strict"; + +function handleRequest(request, response) { + try { + reallyHandleRequest(request, response); + } catch (e) { + response.setStatusLine("1.0", 200, "AlmostOK"); + response.write("Error handling request: " + e); + } +} + +function reallyHandleRequest(request, response) { + let match; + let requestAuth = true, + requestProxyAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + let query = "?" + request.queryString; + + let expected_user = "", + expected_pass = "", + realm = "mochitest"; + let proxy_expected_user = "", + proxy_expected_pass = "", + proxy_realm = "mochi-proxy"; + let huge = false, + plugin = false, + anonymous = false, + formauth = false; + let authHeaderCount = 1; + // user=xxx + match = /[^_]user=([^&]*)/.exec(query); + if (match) { + expected_user = match[1]; + } + + // pass=xxx + match = /[^_]pass=([^&]*)/.exec(query); + if (match) { + expected_pass = match[1]; + } + + // realm=xxx + match = /[^_]realm=([^&]*)/.exec(query); + if (match) { + realm = match[1]; + } + + // proxy_user=xxx + match = /proxy_user=([^&]*)/.exec(query); + if (match) { + proxy_expected_user = match[1]; + } + + // proxy_pass=xxx + match = /proxy_pass=([^&]*)/.exec(query); + if (match) { + proxy_expected_pass = match[1]; + } + + // proxy_realm=xxx + match = /proxy_realm=([^&]*)/.exec(query); + if (match) { + proxy_realm = match[1]; + } + + // huge=1 + match = /huge=1/.exec(query); + if (match) { + huge = true; + } + + // plugin=1 + match = /plugin=1/.exec(query); + if (match) { + plugin = true; + } + + // multiple=1 + match = /multiple=([^&]*)/.exec(query); + if (match) { + authHeaderCount = match[1] + 0; + } + + // anonymous=1 + match = /anonymous=1/.exec(query); + if (match) { + anonymous = true; + } + + // formauth=1 + match = /formauth=1/.exec(query); + if (match) { + formauth = true; + } + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + let actual_user = "", + actual_pass = "", + authHeader, + authPresent = false; + if (request.hasHeader("Authorization")) { + authPresent = true; + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + + let userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + actual_user = match[1]; + actual_pass = match[2]; + } + + let proxy_actual_user = "", + proxy_actual_pass = ""; + if (request.hasHeader("Proxy-Authorization")) { + authHeader = request.getHeader("Proxy-Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + + let userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + proxy_actual_user = match[1]; + proxy_actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && expected_pass == actual_pass) { + requestAuth = false; + } + if ( + proxy_expected_user == proxy_actual_user && + proxy_expected_pass == proxy_actual_pass + ) { + requestProxyAuth = false; + } + + if (anonymous) { + if (authPresent) { + response.setStatusLine( + "1.0", + 400, + "Unexpected authorization header found" + ); + } else { + response.setStatusLine("1.0", 200, "Authorization header not found"); + } + } else if (requestProxyAuth) { + response.setStatusLine("1.0", 407, "Proxy authentication required"); + for (let i = 0; i < authHeaderCount; ++i) { + response.setHeader( + "Proxy-Authenticate", + 'basic realm="' + proxy_realm + '"', + true + ); + } + } else if (requestAuth) { + if (formauth && authPresent) { + response.setStatusLine("1.0", 403, "Form authentication required"); + } else { + response.setStatusLine("1.0", 401, "Authentication required"); + } + for (let i = 0; i < authHeaderCount; ++i) { + response.setHeader( + "WWW-Authenticate", + 'basic realm="' + realm + '"', + true + ); + } + } else { + response.setStatusLine("1.0", 200, "OK"); + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write(""); + response.write( + "

Login: " + + (requestAuth ? "FAIL" : "PASS") + + "

\n" + ); + response.write( + "

Proxy: " + + (requestProxyAuth ? "FAIL" : "PASS") + + "

\n" + ); + response.write("

Auth: " + authHeader + "

\n"); + response.write("

User: " + actual_user + "

\n"); + response.write("

Pass: " + actual_pass + "

\n"); + + if (huge) { + response.write("
"); + for (let i = 0; i < 100000; i++) { + response.write("123456789\n"); + } + response.write("
"); + response.write( + "This is a footnote after the huge content fill" + ); + } + + if (plugin) { + response.write( + "\n" + ); + } + + response.write(""); +} diff --git a/toolkit/components/passwordmgr/test/blank.html b/toolkit/components/passwordmgr/test/blank.html new file mode 100644 index 0000000000..81ddc2235b --- /dev/null +++ b/toolkit/components/passwordmgr/test/blank.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/browser/.eslintrc.js b/toolkit/components/passwordmgr/test/browser/.eslintrc.js new file mode 100644 index 0000000000..000a981cbe --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + rules: { + "no-var": "off", + }, +}; diff --git a/toolkit/components/passwordmgr/test/browser/authenticate.sjs b/toolkit/components/passwordmgr/test/browser/authenticate.sjs new file mode 100644 index 0000000000..8f463dda6f --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/authenticate.sjs @@ -0,0 +1,84 @@ +function handleRequest(request, response) { + var match; + var requestAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + var query = "?" + request.queryString; + + var expected_user = "test", + expected_pass = "testpass", + realm = "mochitest"; + + // user=xxx + match = /[^_]user=([^&]*)/.exec(query); + if (match) { + expected_user = match[1]; + } + + // pass=xxx + match = /[^_]pass=([^&]*)/.exec(query); + if (match) { + expected_pass = match[1]; + } + + // realm=xxx + match = /[^_]realm=([^&]*)/.exec(query); + if (match) { + realm = match[1]; + } + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + var actual_user = "", + actual_pass = "", + authHeader; + + if (request.hasHeader("Authorization")) { + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + + var userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + actual_user = match[1]; + actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && expected_pass == actual_pass) { + requestAuth = false; + } + + if (requestAuth) { + response.setStatusLine("1.0", 401, "Authentication required"); + response.setHeader("WWW-Authenticate", 'basic realm="' + realm + '"', true); + } else { + response.setStatusLine("1.0", 200, "OK"); + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write(""); + response.write( + "

Login: " + + (requestAuth ? "FAIL" : "PASS") + + "

\n" + ); + response.write("

Auth: " + authHeader + "

\n"); + response.write("

User: " + actual_user + "

\n"); + response.write("

Pass: " + actual_pass + "

\n"); + response.write(""); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser.ini b/toolkit/components/passwordmgr/test/browser/browser.ini new file mode 100644 index 0000000000..ae41ef2c47 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser.ini @@ -0,0 +1,162 @@ +[DEFAULT] +support-files = + ../formsubmit.sjs + authenticate.sjs + empty.html + form_basic.html + form_basic_iframe.html + form_basic_login.html + form_basic_signup.html + form_basic_no_username.html + formless_basic.html + form_multipage.html + form_same_origin_action.html + form_cross_origin_secure_action.html + form_cross_origin_insecure_action.html + form_expanded.html + insecure_test_subframe.html + head.js + multiple_forms.html + ../../../../../browser/components/aboutlogins/tests/browser/head.js + + +[browser_DOMFormHasPassword.js] +[browser_DOMFormHasPossibleUsername.js] +[browser_DOMInputPasswordAdded.js] +skip-if = (os == "linux") || (os == "mac") # Bug 1337606 +[browser_autocomplete_autofocus_with_frame.js] +support-files = + form_autofocus_frame.html +[browser_autocomplete_disabled_readonly_passwordField.js] +support-files = + form_disabled_readonly_passwordField.html +[browser_autocomplete_footer.js] +skip-if = + !debug && os == "linux" && bits == 64 && os_version == "18.04" # Bug 1591126 + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_autocomplete_generated_password_private_window.js] +[browser_autocomplete_import.js] +https_first_disabled = true +skip-if = + os == "mac" # Bug 1775902 + os == "win" && !debug # Bug 1775902 +[browser_autocomplete_insecure_warning.js] +[browser_autocomplete_primary_password.js] +[browser_autofill_hidden_document.js] +skip-if = + (os == "win" && os_version == "10.0" && debug) # bug 1530935 + apple_catalina && fission && !debug # high frequency intermittent, Bug 1716486 + +[browser_autofill_http.js] +https_first_disabled = true +skip-if = verify +[browser_autofill_track_filled_logins.js] +[browser_basicAuth_multiTab.js] +skip-if = os == "android" +[browser_basicAuth_rateLimit.js] +[browser_basicAuth_switchTab.js] +skip-if = (debug && os == "mac") # Bug 1530566 +[browser_context_menu.js] +[browser_context_menu_autocomplete_interaction.js] +skip-if = + verify +[browser_context_menu_generated_password.js] +[browser_context_menu_iframe.js] +[browser_crossOriginSubmissionUsesCorrectOrigin.js] +support-files = + form_cross_origin_secure_action.html +[browser_deleteLoginsBackup.js] +skip-if = os == "android" +[browser_doorhanger_autocomplete_values.js] +[browser_doorhanger_autofill_then_save_password.js] +[browser_doorhanger_crossframe.js] +support-files = + form_crossframe.html + form_crossframe_inner.html +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_doorhanger_dismissed_for_ccnumber.js] +[browser_doorhanger_empty_password.js] +[browser_doorhanger_form_password_edit.js] +[browser_doorhanger_generated_password.js] +support-files = + form_basic_with_confirm_field.html + form_password_change.html +[browser_doorhanger_httpsUpgrade.js] +support-files = + subtst_notifications_1.html + subtst_notifications_8.html +[browser_doorhanger_multipage_form.js] +[browser_doorhanger_password_edits.js] +[browser_doorhanger_promptToChangePassword.js] +[browser_doorhanger_remembering.js] +[browser_doorhanger_replace_dismissed_with_visible_while_opening.js] +[browser_doorhanger_save_password.js] +[browser_doorhanger_submit_telemetry.js] +skip-if = + tsan # Bug 1661305 + os == "linux" && (debug || asan) # Bug 1658056, asan: 1695395 +[browser_doorhanger_target_blank.js] +support-files = + subtst_notifications_12_target_blank.html +[browser_doorhanger_toggles.js] +[browser_doorhanger_username_edits.js] +[browser_doorhanger_window_open.js] +support-files = + subtst_notifications_11.html + subtst_notifications_11_popup.html +skip-if = os == "linux" # Bug 1312981, bug 1313136 +[browser_entry_point_telemetry.js] +[browser_exceptions_dialog.js] +[browser_fileURIOrigin.js] +[browser_focus_before_first_DOMContentLoaded.js] +support-files = + file_focus_before_DOMContentLoaded.sjs +[browser_form_history_fallback.js] +https_first_disabled = true # TODO remove that line and move test to HTTPS, see Bug 1776350 +skip-if = os == "linux" && debug # Bug 1334336 +support-files = + subtst_notifications_1.html + subtst_notifications_2.html + subtst_notifications_2pw_0un.html + subtst_notifications_2pw_1un_1text.html + subtst_notifications_3.html + subtst_notifications_4.html + subtst_notifications_5.html + subtst_notifications_6.html + subtst_notifications_8.html + subtst_notifications_9.html + subtst_notifications_10.html + subtst_notifications_change_p.html +[browser_formless_submit_chrome.js] +skip-if = tsan # Bug 1683730 +[browser_insecurePasswordConsoleWarning.js] +https_first_disabled = true +skip-if = verify +[browser_isProbablyASignUpForm.js] +support-files = + form_signup_detection.html +[browser_localip_frame.js] +skip-if = + os == 'mac' && bits == 64 # Bug 1683848 + os == 'linux' && !debug && bits == 64 # Bug 1683848 + win10_2004 && !fission # Bug 1723573 +[browser_message_onFormSubmit.js] +[browser_openPasswordManager.js] +[browser_preselect_login.js] +[browser_private_window.js] +support-files = + subtst_privbrowsing_1.html + form_password_change.html +skip-if = + os == 'linux' && bits == 64 && os_version == '18.04' && !debug # Bug 1744976 + os == 'win' && os_version == '10.0' && debug # Bug 1782656 +[browser_proxyAuth_prompt.js] +skip-if = os == "android" +[browser_relay_telemetry.js] +[browser_telemetry_SignUpFormRuleset.js] +[browser_test_changeContentInputValue.js] +[browser_username_only_form_telemetry.js] +[browser_username_select_dialog.js] +support-files = + subtst_notifications_change_p.html \ No newline at end of file diff --git a/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js new file mode 100644 index 0000000000..6d4d333369 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js @@ -0,0 +1,152 @@ +const ids = { + INPUT_ID: "input1", + FORM1_ID: "form1", + FORM2_ID: "form2", + CHANGE_INPUT_ID: "input2", +}; + +function task(contentIds) { + let resolve; + let promise = new Promise(r => { + resolve = r; + }); + + function unexpectedContentEvent(evt) { + Assert.ok(false, "Received a " + evt.type + " event on content"); + } + + var gDoc = null; + + addEventListener("load", tabLoad, true); + + function tabLoad() { + if (content.location.href == "about:blank") { + return; + } + removeEventListener("load", tabLoad, true); + + gDoc = content.document; + gDoc.addEventListener("DOMFormHasPassword", unexpectedContentEvent); + addEventListener("DOMFormHasPassword", unexpectedContentEvent); + gDoc.defaultView.setTimeout(test_inputAdd, 0); + } + + function test_inputAdd() { + addEventListener("DOMFormHasPassword", test_inputAddHandler, { + once: true, + capture: true, + }); + let input = gDoc.createElementNS("http://www.w3.org/1999/xhtml", "input"); + input.setAttribute("type", "password"); + input.setAttribute("id", contentIds.INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.getElementById(contentIds.FORM1_ID).appendChild(input); + } + + function test_inputAddHandler(evt) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM1_ID, + evt.type + " event targets correct form element (added password element)" + ); + gDoc.defaultView.setTimeout(test_inputChangeForm, 0); + } + + function test_inputChangeForm() { + addEventListener("DOMFormHasPassword", test_inputChangeFormHandler, { + once: true, + capture: true, + }); + let input = gDoc.getElementById(contentIds.INPUT_ID); + input.setAttribute("form", contentIds.FORM2_ID); + } + + function test_inputChangeFormHandler(evt) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM2_ID, + evt.type + " event targets correct form element (changed form)" + ); + gDoc.defaultView.setTimeout(test_inputChangesType, 0); + } + + function test_inputChangesType() { + addEventListener("DOMFormHasPassword", test_inputChangesTypeHandler, { + once: true, + capture: true, + }); + let input = gDoc.getElementById(contentIds.CHANGE_INPUT_ID); + input.setAttribute("type", "password"); + } + + function test_inputChangesTypeHandler(evt) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM1_ID, + evt.type + " event targets correct form element (changed type)" + ); + gDoc.defaultView.setTimeout(finish, 0); + } + + function finish() { + removeEventListener("DOMFormHasPassword", unexpectedContentEvent); + gDoc.removeEventListener("DOMFormHasPassword", unexpectedContentEvent); + resolve(); + } + + return promise; +} + +add_task(async function test_disconnectedInputs() { + const tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + const unexpectedEvent = evt => { + Assert.ok( + false, + `${evt.type} should not be fired for disconnected forms.` + ); + }; + + addEventListener("DOMFormHasPassword", unexpectedEvent); + + const form = content.document.createElement("form"); + const passwordInput = content.document.createElement("input"); + passwordInput.setAttribute("type", "password"); + form.appendChild(passwordInput); + + // Delay the execution for a bit to allow time for any asynchronously + // dispatched 'DOMFormHasPassword' events to be processed. + // This is necessary because such events might not be triggered immediately, + // and we want to ensure that if they are dispatched, they are captured + // before we remove the event listener. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 50)); + removeEventListener("DOMFormHasPassword", unexpectedEvent); + }); + + Assert.ok(true, "Test completed"); + gBrowser.removeCurrentTab(); +}); + +add_task(async function () { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + + let promise = ContentTask.spawn(tab.linkedBrowser, ids, task); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + `data:text/html;charset=utf-8, + + + + +
+ ` + ); + await promise; + + Assert.ok(true, "Test completed"); + gBrowser.removeCurrentTab(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPossibleUsername.js b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPossibleUsername.js new file mode 100644 index 0000000000..7f39395587 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPossibleUsername.js @@ -0,0 +1,254 @@ +const ids = { + INPUT_ID: "input1", + FORM1_ID: "form1", + FORM2_ID: "form2", + CHANGE_INPUT_ID: "input2", + INPUT_TYPE: "", +}; + +function task({ contentIds, expected }) { + let resolve; + let promise = new Promise(r => { + resolve = r; + }); + + function unexpectedContentEvent(evt) { + Assert.ok(false, "Received a " + evt.type + " event on content"); + } + + var gDoc = null; + + addEventListener("load", tabLoad, true); + + function tabLoad() { + if (content.location.href == "about:blank") { + return; + } + removeEventListener("load", tabLoad, true); + + gDoc = content.document; + gDoc.addEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); + addEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); + gDoc.defaultView.setTimeout(test_inputAdd, 0); + } + + function test_inputAdd() { + if (expected) { + addEventListener("DOMFormHasPossibleUsername", test_inputAddHandler, { + once: true, + capture: true, + }); + } else { + gDoc.defaultView.setTimeout(test_inputAddHandler, 0); + } + let input = gDoc.createElementNS("http://www.w3.org/1999/xhtml", "input"); + input.setAttribute("type", contentIds.INPUT_TYPE); + input.setAttribute("id", contentIds.INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.getElementById(contentIds.FORM1_ID).appendChild(input); + } + + function test_inputAddHandler(evt) { + if (expected) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM1_ID, + evt.type + + " event targets correct form element (added possible username element)" + ); + } + gDoc.defaultView.setTimeout(test_inputChangeForm, 0); + } + + function test_inputChangeForm() { + if (expected) { + addEventListener( + "DOMFormHasPossibleUsername", + test_inputChangeFormHandler, + { once: true, capture: true } + ); + } else { + gDoc.defaultView.setTimeout(test_inputChangeFormHandler, 0); + } + let input = gDoc.getElementById(contentIds.INPUT_ID); + input.setAttribute("form", contentIds.FORM2_ID); + } + + function test_inputChangeFormHandler(evt) { + if (expected) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM2_ID, + evt.type + " event targets correct form element (changed form)" + ); + } + // TODO(Bug 1864405): Refactor this test to not expect a DOM event + // when the type is set to the same value + const nextTask = + expected && contentIds.INPUT_TYPE === "text" + ? finish + : test_inputChangesType; + gDoc.defaultView.setTimeout(nextTask, 0); + } + + function test_inputChangesType() { + if (expected) { + addEventListener( + "DOMFormHasPossibleUsername", + test_inputChangesTypeHandler, + { once: true, capture: true } + ); + } else { + gDoc.defaultView.setTimeout(test_inputChangesTypeHandler, 0); + } + let input = gDoc.getElementById(contentIds.CHANGE_INPUT_ID); + input.setAttribute("type", contentIds.INPUT_TYPE); + } + + function test_inputChangesTypeHandler(evt) { + if (expected) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM1_ID, + evt.type + " event targets correct form element (changed type)" + ); + } + gDoc.defaultView.setTimeout(finish, 0); + } + + function finish() { + removeEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); + gDoc.removeEventListener( + "DOMFormHasPossibleUsername", + unexpectedContentEvent + ); + resolve(); + } + + return promise; +} + +add_setup(async function () { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); +}); + +add_task(async function test_disconnectedInputs() { + const tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + const unexpectedEvent = evt => { + Assert.ok( + false, + `${evt.type} should not be fired for disconnected forms.` + ); + }; + + addEventListener("DOMFormHasPossibleUsername", unexpectedEvent); + const form = content.document.createElement("form"); + const textInput = content.document.createElement("input"); + textInput.setAttribute("type", "text"); + form.appendChild(textInput); + + // Delay the execution for a bit to allow time for any asynchronously + // dispatched 'DOMFormHasPossibleUsername' events to be processed. + // This is necessary because such events might not be triggered immediately, + // and we want to ensure that if they are dispatched, they are captured + // before we remove the event listener. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 50)); + removeEventListener("DOMFormHasPossibleUsername", unexpectedEvent); + }); + + Assert.ok(true, "Test completed"); + gBrowser.removeCurrentTab(); +}); + +add_task(async function test_usernameOnlyForm() { + for (let type of ["text", "email"]) { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + + ids.INPUT_TYPE = type; + let promise = ContentTask.spawn( + tab.linkedBrowser, + { contentIds: ids, expected: true }, + task + ); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + `data:text/html;charset=utf-8, + +
+ +
+
+ ` + ); + await promise; + + Assert.ok(true, "Test completed"); + gBrowser.removeCurrentTab(); + } +}); + +add_task(async function test_nonSupportedInputType() { + for (let type of ["url", "tel", "number"]) { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + + ids.INPUT_TYPE = type; + let promise = ContentTask.spawn( + tab.linkedBrowser, + { contentIds: ids, expected: false }, + task + ); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + `data:text/html;charset=utf-8, + +
+ +
+
+ ` + ); + await promise; + + Assert.ok(true, "Test completed"); + gBrowser.removeCurrentTab(); + } +}); + +add_task(async function test_usernameOnlyFormPrefOff() { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", false); + + for (let type of ["text", "email"]) { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + + ids.INPUT_TYPE = type; + let promise = ContentTask.spawn( + tab.linkedBrowser, + { contentIds: ids, expected: false }, + task + ); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + `data:text/html;charset=utf-8, + +
+ +
+
+ ` + ); + await promise; + + Assert.ok(true, "Test completed"); + gBrowser.removeCurrentTab(); + } + + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js b/toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js new file mode 100644 index 0000000000..11ca2ac1cd --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js @@ -0,0 +1,101 @@ +const consts = { + HTML_NS: "http://www.w3.org/1999/xhtml", + + INPUT_ID: "input1", + FORM1_ID: "form1", + FORM2_ID: "form2", + CHANGE_INPUT_ID: "input2", + BODY_INPUT_ID: "input3", +}; + +function task(contentConsts) { + let resolve; + let promise = new Promise(r => { + resolve = r; + }); + + function unexpectedContentEvent(evt) { + Assert.ok(false, "Received a " + evt.type + " event on content"); + } + + var gDoc = null; + + addEventListener("load", tabLoad, true); + + function tabLoad() { + removeEventListener("load", tabLoad, true); + gDoc = content.document; + // These events shouldn't escape to content. + gDoc.addEventListener("DOMInputPasswordAdded", unexpectedContentEvent); + gDoc.defaultView.setTimeout(test_inputAddOutsideForm, 0); + } + + function test_inputAddOutsideForm() { + addEventListener( + "DOMInputPasswordAdded", + test_inputAddOutsideFormHandler, + false + ); + let input = gDoc.createElementNS(contentConsts.HTML_NS, "input"); + input.setAttribute("type", "password"); + input.setAttribute("id", contentConsts.BODY_INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.body.appendChild(input); + info("Done appending the input element to the body"); + } + + function test_inputAddOutsideFormHandler(evt) { + removeEventListener(evt.type, test_inputAddOutsideFormHandler, false); + Assert.equal( + evt.target.id, + contentConsts.BODY_INPUT_ID, + evt.type + + " event targets correct input element (added password element outside form)" + ); + gDoc.defaultView.setTimeout(test_inputChangesType, 0); + } + + function test_inputChangesType() { + addEventListener( + "DOMInputPasswordAdded", + test_inputChangesTypeHandler, + false + ); + let input = gDoc.getElementById(contentConsts.CHANGE_INPUT_ID); + input.setAttribute("type", "password"); + } + + function test_inputChangesTypeHandler(evt) { + removeEventListener(evt.type, test_inputChangesTypeHandler, false); + Assert.equal( + evt.target.id, + contentConsts.CHANGE_INPUT_ID, + evt.type + " event targets correct input element (changed type)" + ); + gDoc.defaultView.setTimeout(completeTest, 0); + } + + function completeTest() { + Assert.ok(true, "Test completed"); + gDoc.removeEventListener("DOMInputPasswordAdded", unexpectedContentEvent); + resolve(); + } + + return promise; +} + +add_task(async function () { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + let promise = ContentTask.spawn(tab.linkedBrowser, consts, task); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + `data:text/html;charset=utf-8, + + + + + ` + ); + await promise; + gBrowser.removeCurrentTab(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_autofocus_with_frame.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_autofocus_with_frame.js new file mode 100644 index 0000000000..551e1b939a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_autofocus_with_frame.js @@ -0,0 +1,48 @@ +const TEST_URL_PATH = "https://example.org" + DIRECTORY_PATH; + +add_setup(async function () { + let login = LoginTestUtils.testData.formLogin({ + origin: "https://example.org", + formActionOrigin: "https://example.org", + username: "username1", + password: "password1", + }); + await Services.logins.addLoginAsync(login); + login = LoginTestUtils.testData.formLogin({ + origin: "https://example.org", + formActionOrigin: "https://example.org", + username: "username2", + password: "password2", + }); + await Services.logins.addLoginAsync(login); +}); + +// Verify that the autocomplete popup opens when the username field in autofocused. +add_task(async function test_autofocus_autocomplete() { + let popup = document.getElementById("PopupAutoComplete"); + let popupShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + + let formFilled = listenForTestNotification("FormProcessed"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_URL_PATH + "form_autofocus_frame.html" + ); + + await formFilled; + await popupShown; + + Assert.ok(true, "popup opened"); + + let promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.firstChild.getItemAtIndex(0).click(); + await promiseHidden; + + Assert.ok(true, "popup closed"); + + let password = await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + return content.document.getElementById("form-basic-password").value; + }); + Assert.equal(password, "password1", "password filled in"); + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_disabled_readonly_passwordField.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_disabled_readonly_passwordField.js new file mode 100644 index 0000000000..f6e0a62678 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_disabled_readonly_passwordField.js @@ -0,0 +1,138 @@ +const TEST_URL_PATH = + "https://example.org" + + DIRECTORY_PATH + + "form_disabled_readonly_passwordField.html"; +const FIRST_ITEM = 0; + +/** + * Add two logins to prevent autofilling, but the AutocompletePopup will be displayed + */ +add_setup(async () => { + let login1 = LoginTestUtils.testData.formLogin({ + origin: "https://example.org", + formActionOrigin: "https://example.org", + username: "username1", + password: "password1", + }); + + let login2 = LoginTestUtils.testData.formLogin({ + origin: "https://example.org", + formActionOrigin: "https://example.org", + username: "username2", + password: "password2", + }); + + await Services.logins.addLogins([login1, login2]); +}); + +add_task( + async function test_autocomplete_for_usernameField_with_disabled_passwordField() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + let popup = document.getElementById("PopupAutoComplete"); + + Assert.ok(popup, "Got Popup"); + + await openACPopup( + popup, + browser, + "#login_form_disabled_password input[name=username]" + ); + + info("Popup opened"); + + let promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.firstChild.getItemAtIndex(FIRST_ITEM).click(); + await promiseHidden; + + info("Popup closed"); + + let [username, password] = await SpecialPowers.spawn( + browser, + [], + async () => { + let doc = content.document; + let contentUsername = doc.querySelector( + "#login_form_disabled_password input[name=username]" + ).value; + let contentPassword = doc.querySelector( + "#login_form_disabled_password input[name=password]" + ).value; + return [contentUsername, contentPassword]; + } + ); + Assert.equal( + username, + "username1", + "Username was autocompleted with correct value." + ); + Assert.equal( + password, + "", + "Password was not autocompleted, because field is disabled." + ); + } + ); + } +); + +add_task( + async function test_autocomplete_for_usernameField_with_readonly_passwordField() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + let popup = document.getElementById("PopupAutoComplete"); + + Assert.ok(popup, "Got Popup"); + + await openACPopup( + popup, + browser, + "#login_form_readonly_password input[name=username]" + ); + + info("Popup opened"); + + let promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.firstChild.getItemAtIndex(FIRST_ITEM).click(); + await promiseHidden; + + info("Popup closed"); + + let [username, password] = await SpecialPowers.spawn( + browser, + [], + async () => { + let doc = content.document; + let contentUsername = doc.querySelector( + "#login_form_readonly_password input[name=username]" + ).value; + info(contentUsername); + let contentPassword = doc.querySelector( + "#login_form_readonly_password input[name=password]" + ).value; + info(contentPassword); + return [contentUsername, contentPassword]; + } + ); + Assert.equal( + username, + "username1", + "Username was autocompleted with correct value." + ); + Assert.equal( + password, + "", + "Password was not autocompleted, because field is readonly." + ); + } + ); + } +); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_footer.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_footer.js new file mode 100644 index 0000000000..6660daec99 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_footer.js @@ -0,0 +1,125 @@ +"use strict"; + +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; + +function loginList() { + return [ + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }), + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username2", + password: "password2", + }), + ]; +} + +/** + * Initialize logins and set prefs needed for the test. + */ +add_task(async function test_initialize() { + Services.prefs.setBoolPref("signon.showAutoCompleteFooter", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.showAutoCompleteFooter"); + }); + + await Services.logins.addLogins(loginList()); +}); + +add_task(async function test_autocomplete_footer_onclick() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function footer_onclick(browser) { + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + + await openACPopup(popup, browser, "#form-basic-username"); + + let footer = popup.querySelector(`[originaltype="loginsFooter"]`); + Assert.ok(footer, "Got footer richlistitem"); + + await TestUtils.waitForCondition(() => { + return !EventUtils.isHidden(footer); + }, "Waiting for footer to become visible"); + + let openingFunc = () => EventUtils.synthesizeMouseAtCenter(footer, {}); + let passwordManager = await openPasswordManager(openingFunc, false); + + info("Password Manager was opened"); + + Assert.ok( + !passwordManager.filterValue, + "Search string should not be set to filter logins" + ); + + // open_management + await LoginTestUtils.telemetry.waitForEventCount(1); + + // Check event telemetry recorded when opening management UI + TelemetryTestUtils.assertEvents( + [["pwmgr", "open_management", "autocomplete"]], + { category: "pwmgr", method: "open_management" }, + { clear: true, process: "content" } + ); + + await passwordManager.close(); + await closePopup(popup); + } + ); +}); + +add_task(async function test_autocomplete_footer_keydown() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function footer_enter_keydown(browser) { + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + + await openACPopup(popup, browser, "#form-basic-username"); + + let footer = popup.querySelector(`[originaltype="loginsFooter"]`); + Assert.ok(footer, "Got footer richlistitem"); + + await TestUtils.waitForCondition(() => { + return !EventUtils.isHidden(footer); + }, "Waiting for footer to become visible"); + + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + let openingFunc = () => EventUtils.synthesizeKey("KEY_Enter"); + + let passwordManager = await openPasswordManager(openingFunc, false); + info("Login dialog was opened"); + + Assert.ok( + !passwordManager.filterValue, + "Search string should not be set to filter logins" + ); + + // Check event telemetry recorded when opening management UI + TelemetryTestUtils.assertEvents( + [["pwmgr", "open_management", "autocomplete"]], + { category: "pwmgr", method: "open_management" }, + { clear: true, process: "content" } + ); + + await passwordManager.close(); + await closePopup(popup); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_generated_password_private_window.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_generated_password_private_window.js new file mode 100644 index 0000000000..f28a7a1d52 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_generated_password_private_window.js @@ -0,0 +1,111 @@ +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const FORM_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"; +const passwordInputSelector = "#form-basic-password"; + +add_setup(async function () { + Services.telemetry.clearEvents(); + TelemetryTestUtils.assertEvents([], { + category: "pwmgr", + method: "autocomplete_shown", + }); +}); + +add_task(async function test_autocomplete_new_password_popup_item_visible() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN, + }, + async function (browser) { + info("Generate and cache a password for a non-private context"); + let lmp = + browser.browsingContext.currentWindowGlobal.getActor("LoginManager"); + await lmp.getGeneratedPassword(); + Assert.equal( + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size, + 1, + "Non-Private password should be cached" + ); + } + ); + + await LoginTestUtils.addLogin({ username: "username", password: "pass1" }); + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + const doc = win.document; + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await ContentTask.spawn( + browser, + [passwordInputSelector], + function openAutocomplete(sel) { + content.document.querySelector(sel).autocomplete = "new-password"; + } + ); + + let popup = doc.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, passwordInputSelector); + + let item = popup.querySelector(`[originaltype="generatedPassword"]`); + Assert.ok(item, "Should get 'Generate password' richlistitem"); + + let onPopupClosed = BrowserTestUtils.waitForCondition( + () => !popup.popupOpen, + "Popup should get closed" + ); + + await TestUtils.waitForTick(); + + TelemetryTestUtils.assertEvents( + [["pwmgr", "autocomplete_shown", "generatedpassword"]], + { category: "pwmgr", method: "autocomplete_shown" } + ); + + await closePopup(popup); + await onPopupClosed; + } + ); + + let lastPBContextExitedPromise = TestUtils.topicObserved( + "last-pb-context-exited" + ).then(() => TestUtils.waitForTick()); + await BrowserTestUtils.closeWindow(win); + await lastPBContextExitedPromise; + Assert.equal( + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size, + 1, + "Only private-context passwords should be cleared" + ); +}); + +add_task(async function test_autocomplete_menu_item_enabled() { + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + const doc = win.document; + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser); + await openPasswordContextMenu(browser, passwordInputSelector); + let generatedPasswordItem = doc.getElementById( + "fill-login-generated-password" + ); + Assert.equal( + generatedPasswordItem.disabled, + false, + "Generate password context menu item should be enabled in PB mode" + ); + await closePopup(document.getElementById("contentAreaContextMenu")); + } + ); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_import.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_import.js new file mode 100644 index 0000000000..0be137d88d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_import.js @@ -0,0 +1,259 @@ +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); +const { ExperimentAPI } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// Dummy migrator to change and detect importable behavior. +const gTestMigrator = { + profiles: [], + + getSourceProfiles() { + return this.profiles; + }, + + migrate: sinon + .stub() + .callsFake(() => + LoginTestUtils.addLogin({ username: "import", password: "pass" }) + ), +}; + +// Showing importables updates counts delayed, so adjust and cleanup. +add_setup(async function setup() { + const debounce = sinon + .stub(LoginManagerParent, "SUGGEST_IMPORT_DEBOUNCE_MS") + .value(0); + const importable = sinon + .stub(ChromeMigrationUtils, "getImportableLogins") + .resolves(["chrome"]); + const migrator = sinon + .stub(MigrationUtils, "getMigrator") + .resolves(gTestMigrator); + + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "password-autocomplete", + value: { directMigrateSingleProfile: true }, + }); + + // This makes the last autocomplete test *not* show import suggestions. + Services.prefs.setIntPref("signon.suggestImportCount", 3); + + registerCleanupFunction(async () => { + await doExperimentCleanup(); + debounce.restore(); + importable.restore(); + migrator.restore(); + Services.prefs.clearUserPref("signon.suggestImportCount"); + }); +}); + +add_task(async function check_fluent_ids() { + await document.l10n.ready; + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + + const host = "testhost.com"; + for (const browser of ChromeMigrationUtils.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) { + const id = `autocomplete-import-logins-${browser}`; + const message = await document.l10n.formatValue(id, { host }); + Assert.ok( + message.includes(`data-l10n-name="line1"`), + `${id} included line1` + ); + Assert.ok( + message.includes(`data-l10n-name="line2"`), + `${id} included line2` + ); + Assert.ok(message.includes(host), `${id} replaced host`); + } +}); + +/** + * Tests that if the user selects the password import suggestion from + * the autocomplete popup, and there is more than one profile available + * to import from, that the migration wizard opens to guide the user + * through importing those logins. + */ +add_task(async function import_suggestion_wizard() { + let wizard; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "form_basic.html", + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, "#form-basic-username"); + + const importableItem = popup.querySelector( + `[originaltype="importableLogins"]` + ); + Assert.ok(importableItem, "Got importable suggestion richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !importableItem.collapsed, + "Wait for importable suggestion to show" + ); + + // Pretend there's 2+ profiles to trigger the wizard. + gTestMigrator.profiles.length = 2; + + info("Clicking on importable suggestion"); + const wizardPromise = BrowserTestUtils.waitForMigrationWizard(window); + + // The modal window blocks execution, so avoid calling directly. + executeSoon(() => EventUtils.synthesizeMouseAtCenter(importableItem, {})); + + wizard = await wizardPromise; + Assert.ok(wizard, "Wizard opened"); + Assert.equal( + gTestMigrator.migrate.callCount, + 0, + "Direct migrate not used" + ); + + await closePopup(popup); + } + ); + + // Close the wizard in the end of the test. If we close the wizard when the tab + // is still opened, the username field will be focused again, which triggers another + // importable suggestion. + await BrowserTestUtils.closeMigrationWizard(wizard); +}); + +add_task(async function import_suggestion_learn_more() { + let supportTab; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "form_basic.html", + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, "#form-basic-username"); + + const learnMoreItem = popup.querySelector(`[type="importableLearnMore"]`); + Assert.ok(learnMoreItem, "Got importable learn more richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !learnMoreItem.collapsed, + "Wait for importable learn more to show" + ); + + info("Clicking on importable learn more"); + const supportTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-import" + ); + EventUtils.synthesizeMouseAtCenter(learnMoreItem, {}); + supportTab = await supportTabPromise; + Assert.ok(supportTab, "Support tab opened"); + + await closePopup(popup); + } + ); + + // Close the tab in the end of the test to avoid the username field being + // focused again. + await BrowserTestUtils.removeTab(supportTab); +}); + +add_task(async function import_suggestion_migrate() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "form_basic.html", + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, "#form-basic-username"); + + const importableItem = popup.querySelector( + `[originaltype="importableLogins"]` + ); + Assert.ok(importableItem, "Got importable suggestion richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !importableItem.collapsed, + "Wait for importable suggestion to show" + ); + + // Pretend there's 1 profile to trigger migrate. + gTestMigrator.profiles.length = 1; + + info("Clicking on importable suggestion"); + const migratePromise = BrowserTestUtils.waitForCondition( + () => gTestMigrator.migrate.callCount, + "Wait for direct migration attempt" + ); + EventUtils.synthesizeMouseAtCenter(importableItem, {}); + + const callCount = await migratePromise; + Assert.equal(callCount, 1, "Direct migrate used once"); + + const importedItem = await BrowserTestUtils.waitForCondition( + () => popup.querySelector(`[originaltype="loginWithOrigin"]`), + "Wait for imported login to show" + ); + EventUtils.synthesizeMouseAtCenter(importedItem, {}); + + const username = await SpecialPowers.spawn( + browser, + [], + () => content.document.getElementById("form-basic-username").value + ); + Assert.equal(username, "import", "username from import filled in"); + + LoginTestUtils.clearData(); + } + ); +}); + +add_task(async function import_suggestion_not_shown() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "form_basic.html", + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + let opened = false; + openACPopup(popup, browser, "#form-basic-password").then( + () => (opened = true) + ); + + await TestUtils.waitForCondition(() => { + EventUtils.synthesizeKey("KEY_ArrowDown"); + return opened; + }); + + const footer = popup.querySelector(`[originaltype="loginsFooter"]`); + Assert.ok(footer, "Got footer richlistitem"); + + await TestUtils.waitForCondition(() => { + return !EventUtils.isHidden(footer); + }, "Waiting for footer to become visible"); + + Assert.ok( + !popup.querySelector(`[originaltype="importableLogins"]`), + "No importable suggestion shown" + ); + + await closePopup(popup); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js new file mode 100644 index 0000000000..f00ea80937 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js @@ -0,0 +1,44 @@ +"use strict"; + +const EXPECTED_SUPPORT_URL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "insecure-password"; + +add_task(async function test_clickInsecureFieldWarning() { + let url = + "https://example.com" + + DIRECTORY_PATH + + "form_cross_origin_insecure_action.html"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, "#form-basic-username"); + await new Promise(requestAnimationFrame); + + let warningItem = popup.querySelector(`[type="insecureWarning"]`); + Assert.ok(warningItem, "Got warning richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !warningItem.collapsed, + "Wait for warning to show" + ); + + info("Clicking on warning"); + let supportTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + EXPECTED_SUPPORT_URL + ); + EventUtils.synthesizeMouseAtCenter(warningItem, {}); + let supportTab = await supportTabPromise; + Assert.ok(supportTab, "Support tab opened"); + await closePopup(popup); + BrowserTestUtils.removeTab(supportTab); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_primary_password.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_primary_password.js new file mode 100644 index 0000000000..c3152740cd --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_primary_password.js @@ -0,0 +1,121 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +const HOST = "https://example.com"; +const URL = + HOST + "/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"; +const TIMEOUT_PREF = "signon.masterPasswordReprompt.timeout_ms"; + +const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" +); +const BRAND_FULL_NAME = BRAND_BUNDLE.GetStringFromName("brandFullName"); + +// Waits for the primary password prompt and cancels it when close() is called on the return value. +async function waitForDialog() { + let [subject] = await TestUtils.topicObserved("common-dialog-loaded"); + let dialog = subject.Dialog; + let expected = "Password Required - " + BRAND_FULL_NAME; + Assert.equal(dialog.args.title, expected, "Check common dialog title"); + return { + async close(win = window) { + dialog.ui.button1.click(); + return BrowserTestUtils.waitForEvent(win, "DOMModalDialogClosed"); + }, + }; +} + +add_setup(async function () { + let login = LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }); + await Services.logins.addLoginAsync(login); + LoginTestUtils.primaryPassword.enable(); + + registerCleanupFunction(function () { + LoginTestUtils.primaryPassword.disable(); + }); + + // Set primary password prompt timeout to 3s. + // If this test goes intermittent, you likely have to increase this value. + await SpecialPowers.pushPrefEnv({ set: [[TIMEOUT_PREF, 3000]] }); +}); + +// Test that autocomplete does not trigger a primary password prompt +// for a certain time after it was cancelled. +add_task(async function test_mpAutocompleteTimeout() { + // Wait for initial primary password dialog after opening the tab. + let dialogShown = waitForDialog(); + + await BrowserTestUtils.withNewTab(URL, async function (browser) { + (await dialogShown).close(); + + await SpecialPowers.spawn(browser, [], async function () { + // Focus the password field to trigger autocompletion. + content.document.getElementById("form-basic-password").focus(); + }); + + // Wait 4s, dialog should not have been shown + // (otherwise the code below will not work). + await new Promise(c => setTimeout(c, 4000)); + + dialogShown = waitForDialog(); + await SpecialPowers.spawn(browser, [], async function () { + // Re-focus the password field to trigger autocompletion. + content.document.getElementById("form-basic-username").focus(); + content.document.getElementById("form-basic-password").focus(); + }); + (await dialogShown).close(); + closePopup(document.getElementById("PopupAutoComplete")); + }); + + // Wait 4s for the timer to pass again and not interfere with the next test. + await new Promise(c => setTimeout(c, 4000)); +}); + +// Test that autocomplete does not trigger a primary password prompt +// if one is already showing. +add_task(async function test_mpAutocompleteUIBusy() { + // Wait for initial primary password dialog after adding the login. + let dialogShown = waitForDialog(); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + Services.tm.dispatchToMainThread(async () => { + try { + // Trigger a MP prompt in the new window by saving a login + await Services.logins.addLoginAsync(LoginTestUtils.testData.formLogin()); + } catch (e) { + // Handle throwing from MP cancellation + } + }); + let { close } = await dialogShown; + + let windowGlobal = + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal; + let loginManagerParent = windowGlobal.getActor("LoginManager"); + let origin = "https://www.example.com"; + let data = { + actionOrigin: "", + searchString: "", + previousResult: null, + hasBeenTypePassword: true, + isSecure: false, + isProbablyANewPasswordField: true, + }; + + function dialogObserver(subject, topic, data) { + Assert.ok(false, "A second dialog shouldn't have been shown"); + Services.obs.removeObserver(dialogObserver, topic); + } + Services.obs.addObserver(dialogObserver, "common-dialog-loaded"); + + let results = await loginManagerParent.doAutocompleteSearch(origin, data); + Assert.equal(results.logins.length, 0, "No results since uiBusy is true"); + await close(win); + + await BrowserTestUtils.closeWindow(win); + + Services.obs.removeObserver(dialogObserver, "common-dialog-loaded"); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autofill_hidden_document.js b/toolkit/components/passwordmgr/test/browser/browser_autofill_hidden_document.js new file mode 100644 index 0000000000..e7af2f8b84 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autofill_hidden_document.js @@ -0,0 +1,205 @@ +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/aboutlogins/tests/browser/head.js", + this +); + +const TEST_URL_PATH = "/browser/toolkit/components/passwordmgr/test/browser/"; +const INITIAL_URL = `about:blank`; +const FORM_URL = `https://example.org${TEST_URL_PATH}form_basic.html`; +const FORMLESS_URL = `https://example.org${TEST_URL_PATH}formless_basic.html`; +const FORM_MULTIPAGE_URL = `https://example.org${TEST_URL_PATH}form_multipage.html`; +const testUrls = [FORM_URL, FORMLESS_URL, FORM_MULTIPAGE_URL]; +const testUrlsWithForm = [FORM_URL, FORM_MULTIPAGE_URL]; +const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" +); +const BRAND_FULL_NAME = BRAND_BUNDLE.GetStringFromName("brandFullName"); + +async function getDocumentVisibilityState(browser) { + let visibility = await SpecialPowers.spawn(browser, [], async function () { + return content.document.visibilityState; + }); + return visibility; +} + +add_setup(async function () { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); + + Services.logins.removeAllUserFacingLogins(); + let login = LoginTestUtils.testData.formLogin({ + origin: "https://example.org", + formActionOrigin: "https://example.org", + username: "user1", + password: "pass1", + }); + await Services.logins.addLoginAsync(login); +}); + +testUrlsWithForm.forEach(testUrl => { + add_task(async function test_processed_form_fired() { + // Sanity check. If this doesnt work any results for the subsequent tasks are suspect + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + let tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser); + Assert.equal( + tab1Visibility, + "visible", + "The first tab should be foreground" + ); + + let formProcessedPromise = listenForTestNotification("FormProcessed"); + BrowserTestUtils.loadURIString(tab1.linkedBrowser, testUrl); + await formProcessedPromise; + gBrowser.removeTab(tab1); + }); +}); + +testUrls.forEach(testUrl => { + add_task(async function test_defer_autofill_until_visible() { + let result, tab1Visibility; + // open 2 tabs + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + const tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + + // confirm document is hidden + tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser); + Assert.equal( + tab1Visibility, + "hidden", + "The first tab should be backgrounded" + ); + + // we shouldn't even try to autofill while hidden, so wait for the document to be in the + // non-visible pending queue instead. + let formFilled = false; + listenForTestNotification("FormProcessed").then(() => { + formFilled = true; + }); + BrowserTestUtils.loadURIString(tab1.linkedBrowser, testUrl); + + await TestUtils.waitForCondition(() => { + let windowGlobal = tab1.linkedBrowser.browsingContext.currentWindowGlobal; + if (!windowGlobal || windowGlobal.documentURI.spec == "about:blank") { + return false; + } + + let actor = windowGlobal.getActor("LoginManager"); + return actor.sendQuery("PasswordManager:formIsPending"); + }); + + Assert.ok( + !formFilled, + "Observer should not be notified when form is loaded into a hidden document" + ); + + // Add the observer before switching tab + let formProcessedPromise = listenForTestNotification("FormProcessed"); + await BrowserTestUtils.switchTab(gBrowser, tab1); + result = await formProcessedPromise; + tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser); + Assert.equal( + tab1Visibility, + "visible", + "The first tab should be foreground" + ); + Assert.ok( + result, + "Observer should be notified when input's document becomes visible" + ); + + // the form should have been autofilled with the login + let fieldValues = await SpecialPowers.spawn( + tab1.linkedBrowser, + [], + function () { + let doc = content.document; + return { + username: doc.getElementById("form-basic-username").value, + password: doc.getElementById("form-basic-password")?.value, + }; + } + ); + Assert.equal(fieldValues.username, "user1", "Checking filled username"); + + // skip password test for a username-only form + if (![FORM_MULTIPAGE_URL].includes(testUrl)) { + Assert.equal(fieldValues.password, "pass1", "Checking filled password"); + } + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + }); +}); + +testUrlsWithForm.forEach(testUrl => { + add_task(async function test_immediate_autofill_with_primarypassword() { + LoginTestUtils.primaryPassword.enable(); + await LoginTestUtils.reloadData(); + info( + `Have enabled primaryPassword, now isLoggedIn? ${Services.logins.isLoggedIn}` + ); + + registerCleanupFunction(async function () { + LoginTestUtils.primaryPassword.disable(); + await LoginTestUtils.reloadData(); + }); + + // open 2 tabs + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + const tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + + info( + "load a background login form tab with a matching saved login " + + "and wait to see if the primary password dialog is shown" + ); + Assert.equal( + await getDocumentVisibilityState(tab2.linkedBrowser), + "visible", + "The second tab should be visible" + ); + + const tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser); + Assert.equal( + tab1Visibility, + "hidden", + "The first tab should be backgrounded" + ); + + const dialogObserved = waitForMPDialog("authenticate", tab1.ownerGlobal); + + // In this case we will try to autofill while hidden, so look for the passwordmgr-processed-form + // to be observed + let formProcessedPromise = listenForTestNotification("FormProcessed"); + BrowserTestUtils.loadURIString(tab1.linkedBrowser, testUrl); + await Promise.all([formProcessedPromise, dialogObserved]); + + Assert.ok( + formProcessedPromise, + "Observer should be notified when form is loaded into a hidden document" + ); + Assert.ok( + dialogObserved, + "MP Dialog should be shown when form is loaded into a hidden document" + ); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + }); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autofill_http.js b/toolkit/components/passwordmgr/test/browser/browser_autofill_http.js new file mode 100644 index 0000000000..df80693673 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autofill_http.js @@ -0,0 +1,135 @@ +const TEST_URL_PATH = + "://example.org/browser/toolkit/components/passwordmgr/test/browser/"; + +add_setup(async function () { + const login1 = LoginTestUtils.testData.formLogin({ + origin: "http://example.org", + formActionOrigin: "http://example.org", + username: "username", + password: "password", + }); + const login2 = LoginTestUtils.testData.formLogin({ + origin: "http://example.org", + formActionOrigin: "http://example.com", + username: "username", + password: "password", + }); + await Services.logins.addLogins([login1, login2]); + await SpecialPowers.pushPrefEnv({ + set: [["signon.autofillForms.http", false]], + }); +}); + +add_task(async function test_http_autofill() { + for (let scheme of ["http", "https"]) { + let formFilled = listenForTestNotification("FormProcessed"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `${scheme}${TEST_URL_PATH}form_basic.html` + ); + + await formFilled; + + let [username, password] = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + let doc = content.document; + let contentUsername = doc.getElementById("form-basic-username").value; + let contentPassword = doc.getElementById("form-basic-password").value; + return [contentUsername, contentPassword]; + } + ); + + Assert.equal( + username, + scheme == "http" ? "" : "username", + "Username filled correctly" + ); + Assert.equal( + password, + scheme == "http" ? "" : "password", + "Password filled correctly" + ); + + gBrowser.removeTab(tab); + } +}); + +add_task(async function test_iframe_in_http_autofill() { + for (let scheme of ["http", "https"]) { + // Wait for parent and child iframe to be processed. + let formFilled = listenForTestNotification("FormProcessed", 2); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `${scheme}${TEST_URL_PATH}form_basic_iframe.html` + ); + + await formFilled; + + let [username, password] = await SpecialPowers.spawn( + gBrowser.selectedBrowser.browsingContext.children[0], + [], + async function () { + let doc = this.content.document; + return [ + doc.getElementById("form-basic-username").value, + doc.getElementById("form-basic-password").value, + ]; + } + ); + + Assert.equal( + username, + scheme == "http" ? "" : "username", + "Username filled correctly" + ); + Assert.equal( + password, + scheme == "http" ? "" : "password", + "Password filled correctly" + ); + + gBrowser.removeTab(tab); + } +}); + +add_task(async function test_http_action_autofill() { + for (let type of ["insecure", "secure"]) { + let formFilled = listenForTestNotification("FormProcessed"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `https${TEST_URL_PATH}form_cross_origin_${type}_action.html` + ); + + await formFilled; + + let [username, password] = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + let doc = this.content.document; + return [ + doc.getElementById("form-basic-username").value, + doc.getElementById("form-basic-password").value, + ]; + } + ); + + Assert.equal( + username, + type == "insecure" ? "" : "username", + "Username filled correctly" + ); + Assert.equal( + password, + type == "insecure" ? "" : "password", + "Password filled correctly" + ); + + gBrowser.removeTab(tab); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autofill_track_filled_logins.js b/toolkit/components/passwordmgr/test/browser/browser_autofill_track_filled_logins.js new file mode 100644 index 0000000000..df9580828d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autofill_track_filled_logins.js @@ -0,0 +1,111 @@ +"use strict"; + +const TEST_HOSTNAME = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; +const BASIC_FORM_NO_USERNAME_PAGE_PATH = + DIRECTORY_PATH + "form_basic_no_username.html"; + +add_task(async function test() { + let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + for (let usernameRequested of [true, false]) { + info( + "Testing page with " + + (usernameRequested ? "" : "no ") + + "username requested" + ); + let url = usernameRequested + ? TEST_HOSTNAME + BASIC_FORM_PAGE_PATH + : TEST_HOSTNAME + BASIC_FORM_NO_USERNAME_PAGE_PATH; + + // The login here must be a different domain than the page for this testcase. + let login = new nsLoginInfo( + "https://example.org", + "https://example.org", + null, + "bob", + "mypassword", + "form-basic-username", + "form-basic-password" + ); + login = await Services.logins.addLoginAsync(login); + Assert.equal( + login.timesUsed, + 1, + "The timesUsed should be 1 after creation" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url, + }); + + // Convert the login object to a plain JS object for passing across process boundaries. + login = LoginHelper.loginToVanillaObject(login); + await SpecialPowers.spawn( + tab.linkedBrowser, + [{ login, usernameRequested }], + async ({ login: addedLogin, usernameRequested: aUsernameRequested }) => { + const { LoginFormFactory } = ChromeUtils.importESModule( + "resource://gre/modules/LoginFormFactory.sys.mjs" + ); + const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" + ); + const { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" + ); + + let password = content.document.querySelector("#form-basic-password"); + let formLike = LoginFormFactory.createFromField(password); + info("Calling _fillForm with FormLike"); + addedLogin = LoginHelper.vanillaObjectToLogin(addedLogin); + LoginManagerChild.forWindow(content)._fillForm( + formLike, + [addedLogin], + null, + { + autofillForm: true, + clobberUsername: true, + clobberPassword: true, + userTriggered: true, + } + ); + + if (aUsernameRequested) { + let username = content.document.querySelector("#form-basic-username"); + Assert.equal(username.value, "bob", "Filled username should match"); + } + Assert.equal( + password.value, + "mypassword", + "Filled password should match" + ); + } + ); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.getElementById("form-basic").submit(); + }); + await processedPromise; + + let logins = Services.logins.getAllLogins(); + + Assert.equal(logins.length, 1, "There should only be one login saved"); + Assert.equal( + logins[0].guid, + login.guid, + "The saved login should match the one added and used above" + ); + checkOnlyLoginWasUsedTwice({ justChanged: false }); + + BrowserTestUtils.removeTab(tab); + + // Reset all passwords before next iteration. + Services.logins.removeAllUserFacingLogins(); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_basicAuth_multiTab.js b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_multiTab.js new file mode 100644 index 0000000000..68f21d0ea4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_multiTab.js @@ -0,0 +1,158 @@ +/* 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"; + +/** + * Tests that can show multiple auth prompts in different tabs and handle them + * correctly. + */ + +const ORIGIN1 = "https://example.com"; +const ORIGIN2 = "https://example.org"; + +const AUTH_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"; + +/** + * Opens a tab and navigates to the auth test path. + * @param {String} origin - Origin to open with test path. + * @param {Object} authOptions - Authentication options to pass to server and + * test for. + * @param {String} authOptions.user - Expected username. + * @param {String} authOptions.pass - Expected password. + * @param {String} authOptions.realm - Realm to return on auth request. + * @returns {Object} - An object containing passed origin and authOptions, + * opened tab, a promise which resolves once the tab loads, a promise which + * resolves once the prompt has been opened. + */ +async function openTabWithAuthPrompt(origin, authOptions) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + + let promptPromise = PromptTestUtils.waitForPrompt(tab.linkedBrowser, { + modalType: Services.prompt.MODAL_TYPE_TAB, + promptType: "promptUserAndPass", + }); + let url = new URL(origin + AUTH_PATH); + Object.entries(authOptions).forEach(([key, value]) => + url.searchParams.append(key, value) + ); + let loadPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + url.toString() + ); + info("Loading " + url.toString()); + BrowserTestUtils.loadURIString(tab.linkedBrowser, url.toString()); + return { origin, tab, authOptions, loadPromise, promptPromise }; +} + +/** + * Waits for tab to load and tests for expected auth state. + * @param {boolean} expectAuthed - true: auth success, false otherwise. + * @param {Object} tabInfo - Information about the tab as generated by + * openTabWithAuthPrompt. + */ +async function testTabAuthed(expectAuthed, { tab, loadPromise, authOptions }) { + // Wait for tab to load after auth. + await loadPromise; + Assert.ok(true, "Tab loads after auth"); + + // Fetch auth results from body (set by authenticate.sjs). + let { loginResult, user, pass } = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + let result = {}; + result.loginResult = content.document.getElementById("ok").innerText; + result.user = content.document.getElementById("user").innerText; + result.pass = content.document.getElementById("pass").innerText; + return result; + } + ); + + Assert.equal( + loginResult == "PASS", + expectAuthed, + "Site has expected auth state" + ); + Assert.equal(user, expectAuthed ? authOptions.user : "", "Sent correct user"); + Assert.equal( + pass, + expectAuthed ? authOptions.pass : "", + "Sent correct password" + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + // This test relies on tab auth prompts. + set: [["prompts.modalType.httpAuth", Services.prompt.MODAL_TYPE_TAB]], + }); +}); + +add_task(async function test() { + let tabA = await openTabWithAuthPrompt(ORIGIN1, { + user: "userA", + pass: "passA", + realm: "realmA", + }); + // Tab B and C share realm and credentials. + // However, since the auth happens in separate tabs we should get two prompts. + let tabB = await openTabWithAuthPrompt(ORIGIN2, { + user: "userB", + pass: "passB", + realm: "realmB", + }); + let tabC = await openTabWithAuthPrompt(ORIGIN2, { + user: "userB", + pass: "passB", + realm: "realmB", + }); + let tabs = [tabA, tabB, tabC]; + + info(`Opening ${tabs.length} tabs with auth prompts`); + let prompts = await Promise.all(tabs.map(tab => tab.promptPromise)); + + Assert.equal(prompts.length, tabs.length, "Should have one prompt per tab"); + + for (let i = 0; i < prompts.length; i++) { + let titleEl = prompts[i].ui.prompt.document.querySelector("#titleText"); + Assert.equal( + titleEl.textContent, + new URL(tabs[i].origin).host, + "Prompt matches the tab's host" + ); + } + + // Interact with the prompts. This is deliberately done out of order + // (no FIFO, LIFO). + let [promptA, promptB, promptC] = prompts; + + // Accept prompt B with correct login details. + await PromptTestUtils.handlePrompt(promptB, { + loginInput: tabB.authOptions.user, + passwordInput: tabB.authOptions.pass, + }); + await testTabAuthed(true, tabB); + + // Accept prompt A with correct login details + await PromptTestUtils.handlePrompt(promptA, { + loginInput: tabA.authOptions.user, + passwordInput: tabA.authOptions.pass, + }); + await testTabAuthed(true, tabA); + + // Cancel prompt C + await PromptTestUtils.handlePrompt(promptC, { + buttonNumClick: 1, + }); + await testTabAuthed(false, tabC); + + // Cleanup tabs + tabs.forEach(({ tab }) => BrowserTestUtils.removeTab(tab)); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_basicAuth_rateLimit.js b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_rateLimit.js new file mode 100644 index 0000000000..1da16090c9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_rateLimit.js @@ -0,0 +1,146 @@ +/* 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/. */ + +// This tests that the basic auth dialog can not be used for DOS attacks +// and that the protections are reset on user-initiated navigation/reload. + +let promptModalType = Services.prefs.getIntPref("prompts.modalType.httpAuth"); + +function promiseAuthWindowShown() { + return PromptTestUtils.handleNextPrompt( + window, + { modalType: promptModalType, promptType: "promptUserAndPass" }, + { buttonNumClick: 1 } + ); +} + +add_task(async function test() { + await BrowserTestUtils.withNewTab( + "https://example.com", + async function (browser) { + let cancelDialogLimit = Services.prefs.getIntPref( + "prompts.authentication_dialog_abuse_limit" + ); + + let authShown = promiseAuthWindowShown(); + let browserLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString( + browser, + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs" + ); + await authShown; + Assert.ok(true, "Seen dialog number 1"); + await browserLoaded; + Assert.ok(true, "Loaded document number 1"); + + // Reload the document a bit more often than should be allowed. + // As long as we're in the acceptable range we should receive + // auth prompts, otherwise we should not receive them and the + // page should just load. + // We've already seen the dialog once, hence we start the loop at 1. + for (let i = 1; i < cancelDialogLimit + 2; i++) { + if (i < cancelDialogLimit) { + authShown = promiseAuthWindowShown(); + } + browserLoaded = BrowserTestUtils.browserLoaded(browser); + SpecialPowers.spawn(browser, [], function () { + content.document.location.reload(); + }); + if (i < cancelDialogLimit) { + await authShown; + Assert.ok(true, `Seen dialog number ${i + 1}`); + } + await browserLoaded; + Assert.ok(true, `Loaded document number ${i + 1}`); + } + + let reloadButton = document.getElementById("reload-button"); + await TestUtils.waitForCondition( + () => !reloadButton.hasAttribute("disabled") + ); + + // Verify that we can click the reload button to reset the counter. + authShown = promiseAuthWindowShown(); + browserLoaded = BrowserTestUtils.browserLoaded(browser); + reloadButton.click(); + await authShown; + Assert.ok(true, "Seen dialog number 1"); + await browserLoaded; + Assert.ok(true, "Loaded document number 1"); + + // Now check loading subresources with auth on the page. + browserLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, "https://example.com"); + await browserLoaded; + + // We've already seen the dialog once, hence we start the loop at 1. + for (let i = 1; i < cancelDialogLimit + 2; i++) { + if (i < cancelDialogLimit) { + authShown = promiseAuthWindowShown(); + } + + let iframeLoaded = SpecialPowers.spawn(browser, [], async function () { + let doc = content.document; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + let loaded = new Promise(resolve => { + iframe.addEventListener( + "load", + function (e) { + resolve(); + }, + { once: true } + ); + }); + iframe.src = + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"; + await loaded; + }); + + if (i < cancelDialogLimit) { + await authShown; + Assert.ok(true, `Seen dialog number ${i + 1}`); + } + + await iframeLoaded; + Assert.ok(true, `Loaded iframe number ${i + 1}`); + } + + // Verify that third party subresources can not spawn new auth dialogs. + let iframeLoaded = SpecialPowers.spawn(browser, [], async function () { + let doc = content.document; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + let loaded = new Promise(resolve => { + iframe.addEventListener( + "load", + function (e) { + resolve(); + }, + { once: true } + ); + }); + iframe.src = + "https://example.org/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"; + await loaded; + }); + + await iframeLoaded; + Assert.ok( + true, + "Loaded a third party iframe without showing the auth dialog" + ); + + // Verify that pressing enter in the urlbar also resets the counter. + authShown = promiseAuthWindowShown(); + browserLoaded = BrowserTestUtils.browserLoaded(browser); + gURLBar.value = + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + await authShown; + await browserLoaded; + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_basicAuth_switchTab.js b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_switchTab.js new file mode 100644 index 0000000000..32bcd13ae4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_switchTab.js @@ -0,0 +1,34 @@ +/* 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/. */ + +let modalType = Services.prefs.getIntPref("prompts.modalType.httpAuth"); + +add_task(async function test() { + let tab = BrowserTestUtils.addTab(gBrowser); + isnot(tab, gBrowser.selectedTab, "New tab shouldn't be selected"); + + let authPromptShown = PromptTestUtils.waitForPrompt(tab.linkedBrowser, { + modalType, + promptType: "promptUserAndPass", + }); + + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs" + ); + + // Wait for the basic auth prompt + let dialog = await authPromptShown; + + Assert.equal(gBrowser.selectedTab, tab, "Should have selected the new tab"); + + // Cancel the auth prompt + PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 1 }); + + // After closing the prompt the load should finish + await loadPromise; + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu.js new file mode 100644 index 0000000000..63e161c003 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu.js @@ -0,0 +1,678 @@ +/** + * Test the password manager context menu. + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const MULTIPLE_FORMS_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/multiple_forms.html"; + +const CONTEXT_MENU = document.getElementById("contentAreaContextMenu"); +const POPUP_HEADER = document.getElementById("fill-login"); + +/** + * Initialize logins needed for the tests and disable autofill + * for login forms for easier testing of manual fill. + */ +add_task(async function test_initialize() { + Services.prefs.setBoolPref("signon.autofillForms", false); + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.autofillForms"); + Services.prefs.clearUserPref("signon.schemeUpgrades"); + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); + await Services.logins.addLogins(loginList()); +}); + +/** + * Check if the context menu is populated with the right + * menuitems for the target password input field. + */ +add_task(async function test_context_menu_populate_password_noSchemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-password-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 2); + + await closePopup(CONTEXT_MENU); + } + ); +}); + +/** + * Check if the context menu is populated with the right + * menuitems for the target password input field. + */ +add_task(async function test_context_menu_populate_password_schemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-password-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 3); + + await closePopup(CONTEXT_MENU); + } + ); +}); + +/** + * Check if the context menu is populated with the right menuitems + * for the target username field with a password field present. + */ +add_task( + async function test_context_menu_populate_username_with_password_noSchemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + TEST_ORIGIN + + "/browser/toolkit/components/" + + "passwordmgr/test/browser/multiple_forms.html", + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-username-3"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 2); + + await closePopup(CONTEXT_MENU); + } + ); + } +); +/** + * Check if the context menu is populated with the right menuitems + * for the target username field with a password field present. + */ +add_task( + async function test_context_menu_populate_username_with_password_schemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + TEST_ORIGIN + + "/browser/toolkit/components/" + + "passwordmgr/test/browser/multiple_forms.html", + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-username-3"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 3); + + await closePopup(CONTEXT_MENU); + } + ); + } +); + +/** + * Check if the context menu is populated with the right menuitems + * for the target username field without a password field present. + */ +add_task( + async function test_context_menu_populate_username_with_password_noSchemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + TEST_ORIGIN + + "/browser/toolkit/components/" + + "passwordmgr/test/browser/multiple_forms.html", + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-username-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 2); + + await closePopup(CONTEXT_MENU); + } + ); + } +); +/** + * Check if the context menu is populated with the right menuitems + * for the target username field without a password field present. + */ +add_task( + async function test_context_menu_populate_username_with_password_schemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + TEST_ORIGIN + + "/browser/toolkit/components/" + + "passwordmgr/test/browser/multiple_forms.html", + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-username-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 3); + + await closePopup(CONTEXT_MENU); + } + ); + } +); + +/** + * Check if the password field is correctly filled when one + * login menuitem is clicked. + */ +add_task(async function test_context_menu_password_fill() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + let formDescriptions = await SpecialPowers.spawn( + browser, + [], + async function () { + let forms = Array.from( + content.document.getElementsByClassName("test-form") + ); + return forms.map(f => f.getAttribute("description")); + } + ); + + for (let description of formDescriptions) { + info("Testing form: " + description); + + let passwordInputIds = await SpecialPowers.spawn( + browser, + [{ description }], + async function ({ description }) { + let formElement = content.document.querySelector( + `[description="${description}"]` + ); + let passwords = Array.from( + formElement.querySelectorAll( + "input[type='password'], input[data-type='password']" + ) + ); + return passwords.map(p => p.id); + } + ); + + for (let inputId of passwordInputIds) { + info("Testing password field: " + inputId); + + // Synthesize a right mouse click over the password input element. + await openPasswordContextMenu( + browser, + "#" + inputId, + async function () { + let inputDisabled = await SpecialPowers.spawn( + browser, + [{ inputId }], + async function ({ inputId }) { + let input = content.document.getElementById(inputId); + return input.disabled || input.readOnly; + } + ); + + // If the password field is disabled or read-only, we want to see + // the disabled Fill Password popup header. + if (inputDisabled) { + Assert.ok(!POPUP_HEADER.hidden, "Popup menu is not hidden."); + Assert.ok(POPUP_HEADER.disabled, "Popup menu is disabled."); + await closePopup(CONTEXT_MENU); + } + Assert.equal( + POPUP_HEADER.getAttribute("data-l10n-id"), + "main-context-menu-use-saved-password", + "top-level label is correct" + ); + + return !inputDisabled; + } + ); + + if (CONTEXT_MENU.state != "open") { + continue; + } + + // The only field affected by the password fill + // should be the target password field itself. + await assertContextMenuFill(browser, description, null, inputId, 1); + await SpecialPowers.spawn( + browser, + [{ inputId }], + async function ({ inputId }) { + let passwordField = content.document.getElementById(inputId); + Assert.equal( + passwordField.value, + "password1", + "Check upgraded login was actually used" + ); + } + ); + + await closePopup(CONTEXT_MENU); + } + } + } + ); +}); + +/** + * Check if the form is correctly filled when one + * username context menu login menuitem is clicked. + */ +add_task(async function test_context_menu_username_login_fill() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + let formDescriptions = await SpecialPowers.spawn( + browser, + [], + async function () { + let forms = Array.from( + content.document.getElementsByClassName("test-form") + ); + return forms.map(f => f.getAttribute("description")); + } + ); + + for (let description of formDescriptions) { + info("Testing form: " + description); + let usernameInputIds = await SpecialPowers.spawn( + browser, + [{ description }], + async function ({ description }) { + let formElement = content.document.querySelector( + `[description="${description}"]` + ); + let inputs = Array.from( + formElement.querySelectorAll( + "input[type='text']:not([data-type='password'])" + ) + ); + return inputs.map(p => p.id); + } + ); + + for (let inputId of usernameInputIds) { + info("Testing username field: " + inputId); + + // Synthesize a right mouse click over the username input element. + await openPasswordContextMenu( + browser, + "#" + inputId, + async function () { + let headerHidden = POPUP_HEADER.hidden; + let headerDisabled = POPUP_HEADER.disabled; + let headerLabelID = POPUP_HEADER.getAttribute("data-l10n-id"); + + let data = { + description, + inputId, + headerHidden, + headerDisabled, + headerLabelID, + }; + let shouldContinue = await SpecialPowers.spawn( + browser, + [data], + async function (data) { + let { + description, + inputId, + headerHidden, + headerDisabled, + headerLabelID, + } = data; + let formElement = content.document.querySelector( + `[description="${description}"]` + ); + let usernameField = content.document.getElementById(inputId); + // We always want to check if the first password field is filled, + // since this is the current behavior from the _fillForm function. + let passwordField = formElement.querySelector( + "input[type='password'], input[data-type='password']" + ); + + // If we don't want to see the actual popup menu, + // check if the popup is hidden or disabled. + if ( + !passwordField || + usernameField.disabled || + usernameField.readOnly || + passwordField.disabled || + passwordField.readOnly + ) { + if (!passwordField) { + // Should show popup for a username-only form. + if (usernameField.autocomplete == "username") { + Assert.ok(!headerHidden, "Popup menu is not hidden."); + } else { + Assert.ok(headerHidden, "Popup menu is hidden."); + } + } else { + Assert.ok(!headerHidden, "Popup menu is not hidden."); + Assert.ok(headerDisabled, "Popup menu is disabled."); + } + return false; + } + Assert.equal( + headerLabelID, + "main-context-menu-use-saved-login", + "top-level label is correct" + ); + return true; + } + ); + + if (!shouldContinue) { + await closePopup(CONTEXT_MENU); + } + + return shouldContinue; + } + ); + + if (CONTEXT_MENU.state != "open") { + continue; + } + + let passwordFieldId = await SpecialPowers.spawn( + browser, + [{ description }], + async function ({ description }) { + let formElement = content.document.querySelector( + `[description="${description}"]` + ); + return formElement.querySelector( + "input[type='password'], input[data-type='password']" + ).id; + } + ); + + // We shouldn't change any field that's not the target username field or the first password field + await assertContextMenuFill( + browser, + description, + inputId, + passwordFieldId, + 1 + ); + + await SpecialPowers.spawn( + browser, + [{ passwordFieldId }], + async function ({ passwordFieldId }) { + let passwordField = + content.document.getElementById(passwordFieldId); + if (!passwordField.hasAttribute("expectedFail")) { + Assert.equal( + passwordField.value, + "password1", + "Check upgraded login was actually used" + ); + } + } + ); + + await closePopup(CONTEXT_MENU); + } + } + } + ); +}); + +/** + * Check event telemetry is correctly recorded when opening the saved logins / management UI + * from the context menu + */ +add_task(async function test_context_menu_open_management() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-password-1"); + + let openingFunc = () => gContextMenu.openPasswordManager(); + // wait until the management UI opens + let passwordManager = await openPasswordManager(openingFunc); + info("Management UI dialog was opened"); + + TelemetryTestUtils.assertEvents( + [["pwmgr", "open_management", "contextmenu"]], + { category: "pwmgr", method: "open_management" }, + { clear: true, process: "content" } + ); + + await passwordManager.close(); + await closePopup(CONTEXT_MENU); + } + ); +}); + +/** + * Verify that only the expected form fields are filled. + */ +async function assertContextMenuFill( + browser, + formId, + usernameFieldId, + passwordFieldId, + loginIndex +) { + let popupMenu = document.getElementById("fill-login-popup"); + let unchangedSelector = `[description="${formId}"] input:not(#${passwordFieldId})`; + + if (usernameFieldId) { + unchangedSelector += `:not(#${usernameFieldId})`; + } + + await SpecialPowers.spawn( + browser, + [{ unchangedSelector }], + async function ({ unchangedSelector }) { + let unchangedFields = + content.document.querySelectorAll(unchangedSelector); + + // Store the value of fields that should remain unchanged. + if (unchangedFields.length) { + for (let field of unchangedFields) { + field.setAttribute("original-value", field.value); + } + } + } + ); + + // Execute the default command of the specified login menuitem found in the context menu. + let loginItem = + popupMenu.getElementsByClassName("context-login-item")[loginIndex]; + + // Find the used login by it's username (Use only unique usernames in this test). + let { username, password } = getLoginFromUsername(loginItem.label); + + let data = { + username, + password, + usernameFieldId, + passwordFieldId, + formId, + unchangedSelector, + }; + let continuePromise = ContentTask.spawn(browser, data, async function (data) { + let { + username, + password, + usernameFieldId, + passwordFieldId, + formId, + unchangedSelector, + } = data; + let form = content.document.querySelector(`[description="${formId}"]`); + await ContentTaskUtils.waitForEvent( + form, + "input", + "Username input value changed" + ); + + if (usernameFieldId) { + let usernameField = content.document.getElementById(usernameFieldId); + + // If we have an username field, check if it's correctly filled + if (usernameField.getAttribute("expectedFail") == null) { + Assert.equal( + username, + usernameField.value, + "Username filled and correct." + ); + } + } + + if (passwordFieldId) { + let passwordField = content.document.getElementById(passwordFieldId); + + // If we have a password field, check if it's correctly filled + if (passwordField && passwordField.getAttribute("expectedFail") == null) { + Assert.equal( + password, + passwordField.value, + "Password filled and correct." + ); + } + } + + let unchangedFields = content.document.querySelectorAll(unchangedSelector); + + // Check that all fields that should not change have the same value as before. + if (unchangedFields.length) { + Assert.ok(() => { + for (let field of unchangedFields) { + if (field.value != field.getAttribute("original-value")) { + return false; + } + } + return true; + }, "Other fields were not changed."); + } + }); + + loginItem.doCommand(); + + return continuePromise; +} + +/** + * Check if every login that matches the page origin are available at the context menu. + * @param {Element} contextMenu + * @param {Number} expectedCount - Number of logins expected in the context menu. Used to ensure + * we continue testing something useful. + */ +function checkMenu(contextMenu, expectedCount) { + let logins = loginList().filter(login => { + return LoginHelper.isOriginMatching(login.origin, TEST_ORIGIN, { + schemeUpgrades: Services.prefs.getBoolPref("signon.schemeUpgrades"), + }); + }); + // Make an array of menuitems for easier comparison. + let menuitems = [ + ...CONTEXT_MENU.getElementsByClassName("context-login-item"), + ]; + Assert.equal( + menuitems.length, + expectedCount, + "Expected number of menu items" + ); + Assert.ok( + logins.every(l => menuitems.some(m => l.username == m.label)), + "Every login have an item at the menu." + ); +} + +/** + * Search for a login by it's username. + * + * Only unique login/origin combinations should be used at this test. + */ +function getLoginFromUsername(username) { + return loginList().find(login => login.username == username); +} + +/** + * List of logins used for the test. + * + * We should only use unique usernames in this test, + * because we need to search logins by username. There is one duplicate u+p combo + * in order to test de-duping in the menu. + */ +function loginList() { + return [ + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }), + // Same as above but HTTP in order to test de-duping. + LoginTestUtils.testData.formLogin({ + origin: "http://example.com", + formActionOrigin: "http://example.com", + username: "username", + password: "password", + }), + LoginTestUtils.testData.formLogin({ + origin: "http://example.com", + formActionOrigin: "http://example.com", + username: "username1", + password: "password1", + }), + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username2", + password: "password2", + }), + LoginTestUtils.testData.formLogin({ + origin: "http://example.org", + formActionOrigin: "http://example.org", + username: "username-cross-origin", + password: "password-cross-origin", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js new file mode 100644 index 0000000000..8ffe07a673 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js @@ -0,0 +1,120 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* + * Test the password manager context menu interaction with autocomplete. + */ + +"use strict"; + +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; + +/** + * Initialize logins needed for the tests and disable autofill + * for login forms for easier testing of manual fill. + */ +add_task(async function test_initialize() { + let autocompletePopup = document.getElementById("PopupAutoComplete"); + Services.prefs.setBoolPref("signon.autofillForms", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.autofillForms"); + autocompletePopup.removeEventListener( + "popupshowing", + autocompleteUnexpectedPopupShowing + ); + }); + await Services.logins.addLogins(loginList()); + autocompletePopup.addEventListener( + "popupshowing", + autocompleteUnexpectedPopupShowing + ); +}); + +add_task(async function test_context_menu_username() { + let formFilled = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + BASIC_FORM_PAGE_PATH, + }, + async function (browser) { + await formFilled; + await openContextMenu(browser, "#form-basic-username"); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + Assert.equal(contextMenu.state, "open", "Context menu opened"); + contextMenu.hidePopup(); + } + ); +}); + +add_task(async function test_context_menu_password() { + let formFilled = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + BASIC_FORM_PAGE_PATH, + }, + async function (browser) { + await formFilled; + await openContextMenu(browser, "#form-basic-password"); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + Assert.equal(contextMenu.state, "open", "Context menu opened"); + contextMenu.hidePopup(); + } + ); +}); + +function autocompleteUnexpectedPopupShowing(event) { + Assert.ok(false, "Autocomplete shouldn't appear"); + event.target.hidePopup(); +} + +/** + * Synthesize mouse clicks to open the context menu popup + * for a target login input element. + */ +async function openContextMenu(browser, loginInput) { + // First synthesize a mousedown. We need this to get the focus event with the "contextmenu" event. + let eventDetails1 = { type: "mousedown", button: 2 }; + await BrowserTestUtils.synthesizeMouseAtCenter( + loginInput, + eventDetails1, + browser + ); + + // Then synthesize the contextmenu click over the input element. + let contextMenuShownPromise = BrowserTestUtils.waitForEvent( + window, + "popupshown" + ); + let eventDetails = { type: "contextmenu", button: 2 }; + await BrowserTestUtils.synthesizeMouseAtCenter( + loginInput, + eventDetails, + browser + ); + await contextMenuShownPromise; + + // Wait to see which popups are shown. + await new Promise(resolve => setTimeout(resolve, 1000)); +} + +function loginList() { + return [ + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }), + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username2", + password: "password2", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js new file mode 100644 index 0000000000..4e4edb7b14 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js @@ -0,0 +1,482 @@ +/** + * Test the password manager context menu item can fill password fields with a generated password. + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const FORM_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic_login.html"; +const CONTEXT_MENU = document.getElementById("contentAreaContextMenu"); + +const passwordInputSelector = "#form-basic-password"; + +registerCleanupFunction(async function cleanup_resetPrefs() { + await SpecialPowers.popPrefEnv(); +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.generation.available", true], + ["signon.generation.enabled", true], + ], + }); + // assert that there are no logins + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins"); +}); + +add_task(async function test_hidden_by_prefs() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.generation.available", true], + ["signon.generation.enabled", false], + ], + }); + + // test that the generated password option is not present when the feature is not enabled + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await openPasswordContextMenu(browser, passwordInputSelector); + let generatedPasswordItem = document.getElementById( + "fill-login-generated-password" + ); + Assert.ok( + !BrowserTestUtils.is_visible(generatedPasswordItem), + "generated password item is hidden" + ); + + CONTEXT_MENU.hidePopup(); + } + ); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_fill_hidden_by_login_saving_disabled() { + // test that the generated password option is not present when the user + // disabled password saving for the site. + Services.logins.setLoginSavingEnabled(TEST_ORIGIN, false); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await openPasswordContextMenu(browser, passwordInputSelector); + let generatedPasswordItem = document.getElementById( + "fill-login-generated-password" + ); + Assert.ok( + !BrowserTestUtils.is_visible(generatedPasswordItem), + "generated password item is hidden" + ); + + CONTEXT_MENU.hidePopup(); + } + ); + + Services.logins.setLoginSavingEnabled(TEST_ORIGIN, true); +}); + +add_task(async function test_fill_hidden_by_locked_primary_password() { + // test that the generated password option is not present when the user + // didn't unlock the primary password. + LoginTestUtils.primaryPassword.enable(); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await openPasswordContextMenu( + browser, + passwordInputSelector, + () => false + ); + let generatedPasswordItem = document.getElementById( + "fill-login-generated-password" + ); + Assert.ok( + BrowserTestUtils.is_visible(generatedPasswordItem), + "generated password item is visible" + ); + Assert.ok( + generatedPasswordItem.disabled, + "generated password item is disabled" + ); + + CONTEXT_MENU.hidePopup(); + } + ); + + LoginTestUtils.primaryPassword.disable(); +}); + +add_task(async function fill_generated_password_empty_field() { + // test that we can fill with generated password into an empty password field + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkInitialFieldValue(inputSelector) { + const input = content.document.querySelector(inputSelector); + Assert.equal(input.value.length, 0, "Password field is empty"); + Assert.ok( + !input.matches(":autofill"), + "Password field should not be highlighted" + ); + } + ); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFinalFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + Assert.ok( + input.matches(":autofill"), + "Password field should be highlighted" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + + info("cleaing the field"); + input.setUserInput(""); + } + ); + + let acPopup = document.getElementById("PopupAutoComplete"); + await openACPopup(acPopup, browser, passwordInputSelector); + + let pwgenItem = acPopup.querySelector( + `[originaltype="generatedPassword"]` + ); + Assert.ok( + !pwgenItem || EventUtils.isHidden(pwgenItem), + "pwgen item should no longer be shown" + ); + + await closePopup(acPopup); + } + ); +}); + +add_task(async function fill_generated_password_nonempty_field() { + // test that we can fill with generated password into an non-empty password field + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await changeContentFormValues(browser, { + [passwordInputSelector]: "aa", + }); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkInitialFieldValue(inputSelector) { + const input = content.document.querySelector(inputSelector); + Assert.ok( + !input.matches(":autofill"), + "Password field should not be highlighted" + ); + } + ); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFinalFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + Assert.ok( + input.matches(":autofill"), + "Password field should be highlighted" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + } + ); + } + ); + LoginTestUtils.clearData(); + LoginTestUtils.resetGeneratedPasswordsCache(); +}); + +add_task(async function fill_generated_password_with_matching_logins() { + // test that we can fill a generated password when there are matching logins + let login = LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "pass1", + }); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + await Services.logins.addLoginAsync(login); + await storageChangedPromised; + + let formFilled = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await formFilled; + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkInitialFieldValue(inputSelector) { + Assert.equal( + content.document.querySelector(inputSelector).value, + "pass1", + "Password field has initial value" + ); + } + ); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFinalFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + Assert.ok( + input.matches(":autofill"), + "Password field should be highlighted" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + } + ); + + await openPasswordContextMenu(browser, passwordInputSelector); + + // Execute the command of the first login menuitem found at the context menu. + let passwordChangedPromise = ContentTask.spawn( + browser, + null, + async function () { + let passwordInput = content.document.getElementById( + "form-basic-password" + ); + await ContentTaskUtils.waitForEvent(passwordInput, "input"); + } + ); + + let popupMenu = document.getElementById("fill-login-popup"); + let firstLoginItem = + popupMenu.getElementsByClassName("context-login-item")[0]; + firstLoginItem.doCommand(); + + await passwordChangedPromise; + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + + // Blur the field to trigger a 'change' event. + await BrowserTestUtils.synthesizeKey("KEY_Tab", undefined, browser); + await BrowserTestUtils.synthesizeKey( + "KEY_Tab", + { shiftKey: true }, + browser + ); + + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFieldNotGeneratedPassword(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value, + "pass1", + "Password field was filled with the saved password" + ); + LTU.loginField.checkPasswordMasked( + input, + true, + "after fill of a saved login" + ); + } + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Check 2 logins"); + isnot( + logins[0].password, + logins[1].password, + "Generated password shouldn't have changed to match the filled password" + ); + + Services.logins.removeAllUserFacingLogins(); + LoginTestUtils.resetGeneratedPasswordsCache(); +}); + +add_task(async function test_edited_generated_password_in_new_tab() { + // test that we can fill the generated password into an empty password field, + // edit it, and then fill the edited password. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkInitialFieldValue(inputSelector) { + const input = content.document.querySelector(inputSelector); + Assert.equal(input.value.length, 0, "Password field is empty"); + Assert.ok( + !input.matches(":autofill"), + "Password field should not be highlighted" + ); + } + ); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkAndEditFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + Assert.ok( + input.matches(":autofill"), + "Password field should be highlighted" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + } + ); + + await BrowserTestUtils.sendChar("!", browser); + await BrowserTestUtils.sendChar("@", browser); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await BrowserTestUtils.synthesizeKey("KEY_Tab", undefined, browser); + info("Waiting for storage update"); + await storageChangedPromised; + } + ); + + info("Now fill again in a new tab and ensure the edited password is used"); + + // Disable autofill in the new tab + await SpecialPowers.pushPrefEnv({ + set: [["signon.autofillForms", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkAndEditFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH + 2, + "Password field was filled with edited generated password" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + } + ); + } + ); + + LoginTestUtils.clearData(); + LoginTestUtils.resetGeneratedPasswordsCache(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js new file mode 100644 index 0000000000..2545cdfebe --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js @@ -0,0 +1,223 @@ +/* + * Test the password manager context menu. + */ + +"use strict"; + +const TEST_ORIGIN = "https://example.com"; + +// Test with a page that only has a form within an iframe, not in the top-level document +const IFRAME_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html"; + +/** + * Initialize logins needed for the tests and disable autofill + * for login forms for easier testing of manual fill. + */ +add_task(async function test_initialize() { + Services.prefs.setBoolPref("signon.autofillForms", false); + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.autofillForms"); + Services.prefs.clearUserPref("signon.schemeUpgrades"); + }); + await Services.logins.addLogins(loginList()); +}); + +/** + * Check if the password field is correctly filled when it's in an iframe. + */ +add_task(async function test_context_menu_iframe_fill() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + IFRAME_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu( + browser, + "#form-basic-password", + () => true, + browser.browsingContext.children[0], + true + ); + + let popupMenu = document.getElementById("fill-login-popup"); + + // Stores the original value of username + function promiseFrameInputValue(name) { + return SpecialPowers.spawn( + browser.browsingContext.children[0], + [name], + function (inputname) { + return content.document.getElementById(inputname).value; + } + ); + } + let usernameOriginalValue = await promiseFrameInputValue( + "form-basic-username" + ); + + // Execute the command of the first login menuitem found at the context menu. + let firstLoginItem = + popupMenu.getElementsByClassName("context-login-item")[0]; + Assert.ok(firstLoginItem, "Found the first login item"); + + await TestUtils.waitForTick(); + + Assert.ok( + BrowserTestUtils.is_visible(firstLoginItem), + "First login menuitem is visible" + ); + + info("Clicking on the firstLoginItem"); + // click on the login item to fill the password field, triggering an "input" event + popupMenu.activateItem(firstLoginItem); + + let passwordValue = await TestUtils.waitForCondition(async () => { + let value = await promiseFrameInputValue("form-basic-password"); + return value; + }); + + // Find the used login by it's username. + let login = getLoginFromUsername(firstLoginItem.label); + Assert.equal( + login.password, + passwordValue, + "Password filled and correct." + ); + + let usernameNewValue = await promiseFrameInputValue( + "form-basic-username" + ); + Assert.equal( + usernameOriginalValue, + usernameNewValue, + "Username value was not changed." + ); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + } + ); +}); + +/** + * Check that the login context menu items don't appear on an opaque origin. + */ +add_task(async function test_context_menu_iframe_sandbox() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + IFRAME_PAGE_PATH, + }, + async function (browser) { + info("Opening context menu for test_context_menu_iframe_sandbox"); + await openPasswordContextMenu( + browser, + "#form-basic-password", + function checkDisabled() { + info("checkDisabled for test_context_menu_iframe_sandbox"); + let popupHeader = document.getElementById("fill-login"); + Assert.ok( + popupHeader.hidden, + "Check that the Fill Login menu item is hidden" + ); + return false; + }, + browser.browsingContext.children[1] + ); + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + } + ); +}); + +/** + * Check that the login context menu item appears for sandbox="allow-same-origin" + */ +add_task(async function test_context_menu_iframe_sandbox_same_origin() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + IFRAME_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu( + browser, + "#form-basic-password", + function checkDisabled() { + let popupHeader = document.getElementById("fill-login"); + Assert.ok( + !popupHeader.hidden, + "Check that the Fill Login menu item is visible" + ); + Assert.ok( + !popupHeader.disabled, + "Check that the Fill Login menu item is disabled" + ); + return false; + }, + browser.browsingContext.children[2] + ); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + } + ); +}); + +/** + * Search for a login by it's username. + * + * Only unique login/origin combinations should be used at this test. + */ +function getLoginFromUsername(username) { + return loginList().find(login => login.username == username); +} + +/** + * List of logins used for the test. + * + * We should only use unique usernames in this test, + * because we need to search logins by username. There is one duplicate u+p combo + * in order to test de-duping in the menu. + */ +function loginList() { + return [ + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }), + // Same as above but HTTP in order to test de-duping. + LoginTestUtils.testData.formLogin({ + origin: "http://example.com", + formActionOrigin: "http://example.com", + username: "username", + password: "password", + }), + LoginTestUtils.testData.formLogin({ + origin: "http://example.com", + formActionOrigin: "http://example.com", + username: "username1", + password: "password1", + }), + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username2", + password: "password2", + }), + LoginTestUtils.testData.formLogin({ + origin: "http://example.org", + formActionOrigin: "http://example.org", + username: "username-cross-origin", + password: "password-cross-origin", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_crossOriginSubmissionUsesCorrectOrigin.js b/toolkit/components/passwordmgr/test/browser/browser_crossOriginSubmissionUsesCorrectOrigin.js new file mode 100644 index 0000000000..0aecba63a6 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_crossOriginSubmissionUsesCorrectOrigin.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getDataFromNextSubmitMessage() { + return new Promise(resolve => { + LoginManagerParent.setListenerForTests((msg, data) => { + if (msg == "ShowDoorhanger") { + resolve(data); + } + }); + }); +} + +add_task(async function testCrossOriginFormUsesCorrectOrigin() { + let dataPromise = getDataFromNextSubmitMessage(); + + let url = + "https://example.com" + + DIRECTORY_PATH + + "form_cross_origin_secure_action.html"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + await SpecialPowers.spawn(browser.browsingContext, [], () => { + let doc = content.document; + doc.getElementById("form-basic-username").setUserInput("username"); + doc.getElementById("form-basic-password").setUserInput("password"); + doc.getElementById("form-basic").submit(); + info("Submitting form"); + }); + } + ); + + let data = await dataPromise; + info("Origin retrieved from message listener"); + + Assert.equal( + data.origin, + "https://example.com", + "Message origin should match form origin" + ); + isnot( + data.origin, + data.data.actionOrigin, + "If origin and actionOrigin match, this test will false positive" + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_deleteLoginsBackup.js b/toolkit/components/passwordmgr/test/browser/browser_deleteLoginsBackup.js new file mode 100644 index 0000000000..608329f482 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_deleteLoginsBackup.js @@ -0,0 +1,282 @@ +/** + * Test that logins backup is deleted as expected when logins are deleted. + */ + +XPCOMUtils.defineLazyModuleGetters(this, { + FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.js", + FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.js", +}); + +const nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +const login1 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +const login2 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "", + "notifyp1", + "", + "pass" +); + +const fxaKey = new nsLoginInfo( + FXA_PWDMGR_HOST, + null, + FXA_PWDMGR_REALM, + "foo@bar.com", + "pass2", + "", + "" +); + +const loginStorePath = PathUtils.join(PathUtils.profileDir, "logins.json"); +const loginBackupPath = PathUtils.join( + PathUtils.profileDir, + "logins-backup.json" +); + +async function waitForBackupUpdate() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, topic) { + Services.obs.removeObserver(observer, "logins-backup-updated"); + resolve(); + }, "logins-backup-updated"); + }); +} + +async function loginStoreExists() { + return TestUtils.waitForCondition(() => IOUtils.exists(loginStorePath)); +} + +async function loginBackupExists() { + return TestUtils.waitForCondition(() => IOUtils.exists(loginBackupPath)); +} + +async function loginBackupDeleted() { + return TestUtils.waitForCondition( + async () => !(await IOUtils.exists(loginBackupPath)) + ); +} + +// If a fxa key is stored as a login, test that logins backup is updated to only store +// the fxa key when the last user facing login is deleted. +add_task( + async function test_deleteLoginsBackup_removeAllUserFacingLogins_fxaKey() { + info( + "Testing removeAllUserFacingLogins() case when there is a saved fxa key" + ); + info("Adding two logins: fxa key and one user facing login"); + let storageUpdatePromise = TestUtils.topicObserved( + "password-storage-updated" + ); + await Services.logins.addLoginAsync(login1); + Assert.ok(true, "added login1"); + await loginStoreExists(); + await Services.logins.addLoginAsync(fxaKey); + Assert.ok(true, "added fxaKey"); + await loginBackupExists(); + Assert.ok(true, "logins-backup.json now exists"); + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing all user facing logins"); + Services.logins.removeAllUserFacingLogins(); + await storageUpdatePromise; + info("Writes to storage are complete after removeAllUserFacingLogins call"); + await waitForBackupUpdate(); + Assert.ok( + true, + "logins-backup.json was updated to only store the fxa key, as expected" + ); + + // Clean up. + // Since there is a fxa key left, we need to call removeAllLogins() or removeLogin(fxaKey) + // to remove the fxa key. Otherwise the test will fail in verify mode when trying to add login1 + Services.logins.removeAllLogins(); + await IOUtils.remove(loginStorePath); + } +); + +// Test that logins backup is deleted when Services.logins.removeAllUserFacingLogins() is called. +add_task(async function test_deleteLoginsBackup_removeAllUserFacingLogins() { + // Remove logins.json and logins-backup.json before starting. + info("Testing the removeAllUserFacingLogins() case"); + + await IOUtils.remove(loginStorePath, { ignoreAbsent: true }); + await IOUtils.remove(loginBackupPath, { ignoreAbsent: true }); + + let storageUpdatePromise = TestUtils.topicObserved( + "password-storage-updated" + ); + info("Add a login to create logins.json"); + await Services.logins.addLoginAsync(login1); + await loginStoreExists(); + Assert.ok(true, "logins.json now exists"); + + info("Add a second login to create logins-backup.json"); + await Services.logins.addLoginAsync(login2); + await loginBackupExists(); + info("logins-backup.json now exists"); + + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing all user facing logins"); + Services.logins.removeAllUserFacingLogins(); + + await storageUpdatePromise; + info( + "Writes to storage are complete when removeAllUserFacingLogins() is called" + ); + await loginBackupDeleted(); + info( + "logins-backup.json was deleted as expected when all logins were removed" + ); + + // Clean up. + await IOUtils.remove(loginStorePath); +}); + +// 1. Test that logins backup is deleted when Services.logins.removeAllLogins() is called +// 2. If a FxA key is stored as a login, test that logins backup is deleted when +// Services.logins.removeAllLogins() is called +add_task(async function test_deleteLoginsBackup_removeAllLogins() { + // Remove logins.json and logins-backup.json before starting. + info("Testing the removeAllLogins() case"); + + await IOUtils.remove(loginStorePath, { ignoreAbsent: true }); + await IOUtils.remove(loginBackupPath, { ignoreAbsent: true }); + + let storageUpdatePromise = TestUtils.topicObserved( + "password-storage-updated" + ); + info("Add a login to create logins.json"); + await Services.logins.addLoginAsync(login1); + Assert.ok(true, "added login1"); + await loginStoreExists(); + Assert.ok(true, "logins.json now exists"); + await Services.logins.addLoginAsync(login2); + Assert.ok(true, "added login2"); + await loginBackupExists(); + info("logins-backup.json now exists"); + + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing all logins"); + Services.logins.removeAllLogins(); + + await storageUpdatePromise; + info("Writes to storage are complete when removeAllLogins() is called"); + + await loginBackupDeleted(); + info( + "logins-backup.json was deleted as expected when all logins were removed" + ); + await IOUtils.remove(loginStorePath); + + info("Testing the removeAllLogins() case when FxA key is present"); + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + await Services.logins.addLoginAsync(login1); + await loginStoreExists(); + await Services.logins.addLoginAsync(fxaKey); + await loginBackupExists(); + info("logins-backup.json now exists"); + await storageUpdatePromise; + info("Write to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing all logins, including FxA key"); + Services.logins.removeAllLogins(); + await storageUpdatePromise; + info("Writes to storage are complete after the last removeAllLogins call"); + await loginBackupDeleted(); + info( + "logins-backup.json was deleted when the last logins were removed, as expected" + ); + + // Clean up. + await IOUtils.remove(loginStorePath); +}); + +// 1. Test that logins backup is deleted when the last saved login is removed using +// Services.logins.removeLogin() when no fxa key is saved. +// 2. Test that logins backup is updated when the last saved login is removed using +// Services.logins.removeLogin() when a fxa key is present. +add_task(async function test_deleteLoginsBackup_removeLogin() { + info("Testing the removeLogin() case when there is no saved fxa key"); + info("Adding two logins"); + let storageUpdatePromise = TestUtils.topicObserved( + "password-storage-updated" + ); + await Services.logins.addLoginAsync(login1); + await loginStoreExists(); + await Services.logins.addLoginAsync(login2); + await loginBackupExists(); + info("logins-backup.json now exists"); + + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing one login"); + Services.logins.removeLogin(login1); + await storageUpdatePromise; + info("Writes to storage are complete after one removeLogin call"); + await loginBackupExists(); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing the last login"); + Services.logins.removeLogin(login2); + await storageUpdatePromise; + info("Writes to storage are complete after the last removeLogin call"); + await loginBackupDeleted(); + info( + "logins-backup.json was deleted as expected when the last saved login was removed" + ); + await IOUtils.remove(loginStorePath); + + info("Testing the removeLogin() case when there is a saved fxa key"); + info("Adding two logins: one user facing and the fxa key"); + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + await Services.logins.addLoginAsync(login1); + await loginStoreExists(); + await Services.logins.addLoginAsync(fxaKey); + await loginBackupExists(); + info("logins-backup.json now exists"); + + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + let backupUpdate = waitForBackupUpdate(); + Services.logins.removeLogin(login1); + await storageUpdatePromise; + info("Writes to storage are complete after one removeLogin call"); + await backupUpdate; + + await loginBackupExists(); + info("logins-backup.json was updated to contain only the fxa key"); + + // Clean up. + // Since there is a fxa key left, we need to call removeAllLogins() or removeLogin(fxaKey) + // to remove the fxa key. Otherwise the test will fail in verify mode when trying to add login1 + Services.logins.removeAllLogins(); + await IOUtils.remove(loginStorePath); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autocomplete_values.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autocomplete_values.js new file mode 100644 index 0000000000..3f8bfddaf4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autocomplete_values.js @@ -0,0 +1,274 @@ +/** + * Modify page elements and verify that they are found as options in the save/update doorhanger. + */ + +const USERNAME_SELECTOR = "#form-expanded-username"; +const PASSWORD_SELECTOR = "#form-expanded-password"; +const SEARCH_SELECTOR = "#form-expanded-search"; +const CAPTCHA_SELECTOR = "#form-expanded-captcha"; +const NON_FORM_SELECTOR = "#form-expanded-non-form-input"; + +const AUTOCOMPLETE_POPUP_SELECTOR = "#PopupAutoComplete"; +const USERNAME_DROPMARKER_SELECTOR = + "#password-notification-username-dropmarker"; + +const TEST_CASES = [ + { + description: "a modified username should be included in the popup", + modifiedFields: [ + { [USERNAME_SELECTOR]: "new_username" }, + { [PASSWORD_SELECTOR]: "myPassword" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username"], + }, + { + description: + "if no non-password fields are modified, no popup should be available", + modifiedFields: [{ [PASSWORD_SELECTOR]: "myPassword" }], + expectUsernameDropmarker: false, + expectedValues: [], + }, + { + description: "all modified username fields should be included in the popup", + modifiedFields: [ + { [USERNAME_SELECTOR]: "new_username" }, + { [SEARCH_SELECTOR]: "unrelated search query" }, + { [CAPTCHA_SELECTOR]: "someCaptcha" }, + { [PASSWORD_SELECTOR]: "myPassword" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username", "unrelated search query", "someCaptcha"], + }, + { + description: + "any modified fields that don't look like usernames or passwords should not be included in the popup", + modifiedFields: [ + { [PASSWORD_SELECTOR]: "myPassword" }, + { [NON_FORM_SELECTOR]: "I dont even know what this one is" }, + ], + expectUsernameDropmarker: false, + expectedValues: [], + }, + { + description: + "when a field is modified multiple times, all CHANGE event values should be included in the popup", + modifiedFields: [ + { [USERNAME_SELECTOR]: "new_username1" }, + { [USERNAME_SELECTOR]: "new_username2" }, + { [USERNAME_SELECTOR]: "new_username3" }, + { [PASSWORD_SELECTOR]: "myPassword" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username1", "new_username2", "new_username3"], + }, + { + description: "empty strings should not be displayed in popup", + modifiedFields: [ + { [PASSWORD_SELECTOR]: "myPassword" }, + { [USERNAME_SELECTOR]: "new_username" }, + { [USERNAME_SELECTOR]: "" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username"], + }, + { + description: "saved logins should be displayed in popup", + modifiedFields: [ + { [USERNAME_SELECTOR]: "new_username" }, + { [PASSWORD_SELECTOR]: "myPassword" }, + ], + savedLogins: [ + { + username: "savedUn1", + password: "somePass", + }, + { + username: "savedUn2", + password: "otherPass", + }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username", "savedUn1", "savedUn2"], + }, + { + description: "duplicated page usernames should only be displayed once", + modifiedFields: [ + { [PASSWORD_SELECTOR]: "myPassword" }, + { [USERNAME_SELECTOR]: "new_username1" }, + { [USERNAME_SELECTOR]: "new_username2" }, + { [USERNAME_SELECTOR]: "new_username1" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username1", "new_username2"], + }, + { + description: "non-un/pw fields also prompt doorhanger updates", + modifiedFields: [ + { [PASSWORD_SELECTOR]: "myPassword" }, + { [USERNAME_SELECTOR]: "new_username1" }, + { [SEARCH_SELECTOR]: "search" }, + { [CAPTCHA_SELECTOR]: "captcha" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username1", "search", "captcha"], + }, + // { + // description: "duplicated saved/page usernames should TODO https://mozilla.invisionapp.com/share/XGXL6WZVKFJ#/screens/420547613/comments", + // }, +]; + +function _validateTestCase(tc) { + if (tc.expectUsernameDropmarker) { + Assert.ok( + !!tc.expectedValues.length, + "Validate test case. A visible dropmarker implies expected values" + ); + } else { + Assert.ok( + !tc.expectedValues.length, + "Validate test case. A hidden dropmarker implies no expected values" + ); + } +} + +async function _setPrefs() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.capture.inputChanges.enabled", true]], + }); +} + +async function _addSavedLogins(logins) { + let loginsData = logins.map(({ username }) => + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username, + password: "Saved login passwords not used in this test", + }) + ); + await Services.logins.addLogins(loginsData); +} + +async function _clickDropmarker(document, notificationElement) { + let acPopup = document.querySelector(AUTOCOMPLETE_POPUP_SELECTOR); + let acPopupShown = BrowserTestUtils.waitForEvent(acPopup, "popupshown"); + + notificationElement.querySelector(USERNAME_DROPMARKER_SELECTOR).click(); + await acPopupShown; +} + +function _getSuggestedValues(document) { + let suggestedValues = []; + let autocompletePopup = document.querySelector(AUTOCOMPLETE_POPUP_SELECTOR); + let numRows = autocompletePopup.view.matchCount; + for (let i = 0; i < numRows; i++) { + suggestedValues.push(autocompletePopup.view.getValueAt(i)); + } + return suggestedValues; +} + +add_task(async function test_edit_password() { + await _setPrefs(); + for (let testCase of TEST_CASES) { + info("Test case: " + JSON.stringify(testCase)); + _validateTestCase(testCase); + + // Clean state before the test case is executed. + await LoginTestUtils.clearData(); + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + Services.logins.removeAllUserFacingLogins(); + + // Create the pre-existing logins when needed. + if (testCase.savedLogins) { + info("Adding logins " + JSON.stringify(testCase.savedLogins)); + await _addSavedLogins(testCase.savedLogins); + } + + info("Opening tab"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_expanded.html", + }, + async function (browser) { + info("Editing the form"); + for (const change of testCase.modifiedFields) { + for (const selector in change) { + let newValue = change[selector]; + info(`Setting field '${selector}' to '${newValue}'`); + await changeContentFormValues(browser, change); + } + } + + let notif = getCaptureDoorhanger("any"); + + let { panel } = PopupNotifications; + + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + + await promiseShown; + + let notificationElement = panel.childNodes[0]; + + let usernameDropmarker = notificationElement.querySelector( + USERNAME_DROPMARKER_SELECTOR + ); + Assert.ok( + BrowserTestUtils.is_visible(usernameDropmarker) == + testCase.expectUsernameDropmarker, + "Confirm dropmarker visibility" + ); + + if (testCase.expectUsernameDropmarker) { + info("Opening autocomplete popup"); + await _clickDropmarker(document, notificationElement); + } + + let suggestedValues = _getSuggestedValues(document); + + let expectedNotFound = testCase.expectedValues.filter( + expected => !suggestedValues.includes(expected) + ); + let foundNotExpected = suggestedValues.filter( + actual => !testCase.expectedValues.includes(actual) + ); + + // Log expected/actual inconsistencies + Assert.ok( + !expectedNotFound.length, + `All expected values should be found\nCase: "${ + testCase.description + }"\nExpected: ${JSON.stringify( + testCase.expectedValues + )}\nActual: ${JSON.stringify( + suggestedValues + )}\nExpected not found: ${JSON.stringify(expectedNotFound)} + ` + ); + Assert.ok( + !foundNotExpected.length, + `All actual values should be expected\nCase: "${ + testCase.description + }"\nExpected: ${JSON.stringify( + testCase.expectedValues + )}\nActual: ${JSON.stringify( + suggestedValues + )}\nFound not expected: ${JSON.stringify(foundNotExpected)} + ` + ); + + // Clean up state + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + await clearMessageCache(browser); + Services.logins.removeAllUserFacingLogins(); + } + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autofill_then_save_password.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autofill_then_save_password.js new file mode 100644 index 0000000000..1e62dacc13 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autofill_then_save_password.js @@ -0,0 +1,181 @@ +/** + * Test that after we autofill, the site makes changes to the login, and then the + * user modifies their login, a save/update doorhanger is shown. + * + * This is a regression test for Bug 1632405. + */ + +const testCases = [ + { + name: "autofill, then delete u/p, then fill new u/p should show 'save'", + oldUsername: "oldUsername", + oldPassword: "oldPassword", + actions: [ + { + setUsername: "newUsername", + }, + { + setPassword: "newPassword", + }, + ], + expectedNotification: "addLogin", + expectedDoorhanger: "password-save", + }, + { + name: "autofill, then delete password, then fill new password should show 'update'", + oldUsername: "oldUsername", + oldPassword: "oldPassword", + actions: [ + { + setPassword: "newPassword", + }, + ], + expectedNotification: "modifyLogin", + expectedDoorhanger: "password-change", + }, +]; + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + info("testing with: " + JSON.stringify(testData)); + await test_save_change(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function test_save_change({ + name, + oldUsername, + oldPassword, + actions, + expectedNotification, + expectedDoorhanger, +}) { + let originalPrefValue = await Services.prefs.getBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue" + ); + Services.prefs.setBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue", + false + ); + + info("Starting test: " + name); + + await LoginTestUtils.addLogin({ + username: oldUsername, + password: oldPassword, + origin: "https://example.com", + formActionOrigin: "https://example.com", + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await ContentTask.spawn( + browser, + { oldUsername, oldPassword }, + async function awaitAutofill({ oldUsername, oldPassword }) { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector("#form-basic-username").value == + oldUsername && + content.document.querySelector("#form-basic-password").value == + oldPassword, + "Await and verify autofill" + ); + + info( + "Triggering a page navigation that is not initiated by the user" + ); + content.history.replaceState({}, "", ""); + } + ); + + Services.prefs.setBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue", + true + ); + + for (let action of actions) { + info(`As the user, update form with action: ${JSON.stringify(action)}`); + if (typeof action.setUsername !== "undefined") { + await changeContentFormValues(browser, { + "#form-basic-username": action.setUsername, + }); + } + if (typeof action.setPassword !== "undefined") { + await changeContentFormValues(browser, { + "#form-basic-password": action.setPassword, + }); + } + } + + let expectedUsername = + [...actions] + .reverse() + .map(action => action.setUsername) + .find(username => !!username) ?? oldUsername; + let expectedPassword = + [...actions] + .reverse() + .map(action => action.setPassword) + .find(username => !!username) ?? oldPassword; + + await ContentTask.spawn( + browser, + { expectedUsername, expectedPassword }, + async function awaitAutofill({ expectedUsername, expectedPassword }) { + info("Validating updated fields"); + Assert.equal( + expectedUsername, + content.document.querySelector("#form-basic-username").value, + "Verify username field updated" + ); + Assert.equal( + expectedPassword, + content.document.querySelector("#form-basic-password").value, + "Verify password field updated" + ); + } + ); + + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + await formSubmittedPromise; + + info("Waiting for doorhanger of type: " + expectedDoorhanger); + let notif = await waitForDoorhanger(browser, expectedDoorhanger); + + await checkDoorhangerUsernamePassword(expectedUsername, expectedPassword); + + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseLogin; + await cleanupDoorhanger(notif); // clean slate for the next test + + Services.prefs.setBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue", + originalPrefValue + ); + } + ); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllUserFacingLogins(); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_crossframe.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_crossframe.js new file mode 100644 index 0000000000..8c4770d510 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_crossframe.js @@ -0,0 +1,236 @@ +const OUTER_URL = + "https://test1.example.com:443" + DIRECTORY_PATH + "form_crossframe.html"; + +requestLongerTimeout(2); + +async function acceptPasswordSave() { + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + let promiseNewSavedPassword = TestUtils.topicObserved( + "LoginStats:NewSavedPassword", + (subject, data) => subject == gBrowser.selectedBrowser + ); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseNewSavedPassword; +} + +function checkFormFields(browsingContext, prefix, username, password) { + return SpecialPowers.spawn( + browsingContext, + [prefix, username, password], + (formPrefix, expectedUsername, expectedPassword) => { + let doc = content.document; + Assert.equal( + doc.getElementById(formPrefix + "-username").value, + expectedUsername, + "username matches" + ); + Assert.equal( + doc.getElementById(formPrefix + "-password").value, + expectedPassword, + "password matches" + ); + } + ); +} + +function listenForNotifications(count, expectedFormOrigin) { + return new Promise(resolve => { + let notifications = []; + LoginManagerParent.setListenerForTests((msg, data) => { + if (msg == "FormProcessed") { + notifications.push("FormProcessed: " + data.browsingContext.id); + } else if (msg == "ShowDoorhanger") { + Assert.equal( + data.origin, + expectedFormOrigin, + "Message origin should match expected" + ); + notifications.push("FormSubmit: " + data.data.usernameField.name); + } + if (notifications.length == count) { + resolve(notifications); + } + }); + }); +} + +async function verifyNotifications(notifyPromise, expected) { + let actual = await notifyPromise; + + Assert.equal(actual.length, expected.length, "Extra notification(s) sent"); + let expectedItem; + while ((expectedItem = expected.pop())) { + let index = actual.indexOf(expectedItem); + if (index >= 0) { + actual.splice(index, 1); + } else { + Assert.ok(false, "Expected notification '" + expectedItem + "' not sent"); + } + } +} + +// Make sure there is an autocomplete result for the frame's saved login and select it. +async function autocompleteLoginInIFrame( + browser, + iframeBrowsingContext, + selector +) { + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + + await openACPopup(popup, browser, selector, iframeBrowsingContext); + + let autocompleteLoginResult = popup.querySelector( + `[originaltype="loginWithOrigin"]` + ); + Assert.ok(autocompleteLoginResult, "Got login richlistitem"); + + let promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await EventUtils.synthesizeKey("KEY_Enter"); + + await promiseHidden; +} + +/* + * In this test, a frame is loaded with a document that contains a username + * and password field. This frame also contains another child iframe that + * itself contains a username and password field. This inner frame is loaded + * from a different domain than the first. + * + * locationMode should be false to submit forms, or true to click a button + * which changes the location instead. The latter should still save the + * username and password. + */ +async function submitSomeCrossSiteFrames(locationMode) { + info("Check with location mode " + locationMode); + let notifyPromise = listenForNotifications(2); + + let firsttab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + OUTER_URL + ); + + let outerFrameBC = firsttab.linkedBrowser.browsingContext; + let innerFrameBC = outerFrameBC.children[0]; + + await verifyNotifications(notifyPromise, [ + "FormProcessed: " + outerFrameBC.id, + "FormProcessed: " + innerFrameBC.id, + ]); + + // Fill in the username and password for both the outer and inner frame + // and submit the inner frame. + notifyPromise = listenForNotifications(1, "https://test2.example.org"); + info("submit page after changing inner form"); + + await SpecialPowers.spawn(outerFrameBC, [], () => { + let doc = content.document; + doc.getElementById("outer-username").setUserInput("outer"); + doc.getElementById("outer-password").setUserInput("outerpass"); + }); + + await SpecialPowers.spawn(innerFrameBC, [locationMode], doClick => { + let doc = content.document; + doc.getElementById("inner-username").setUserInput("inner"); + doc.getElementById("inner-password").setUserInput("innerpass"); + if (doClick) { + doc.getElementById("inner-gobutton").click(); + } else { + doc.getElementById("inner-form").submit(); + } + }); + + await acceptPasswordSave(); + + await verifyNotifications(notifyPromise, ["FormSubmit: username"]); + + // Next, open a second tab with the same page in it to verify that the data gets filled properly. + notifyPromise = listenForNotifications(2); + let secondtab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + OUTER_URL + ); + + let outerFrameBC2 = secondtab.linkedBrowser.browsingContext; + let innerFrameBC2 = outerFrameBC2.children[0]; + await verifyNotifications(notifyPromise, [ + "FormProcessed: " + outerFrameBC2.id, + "FormProcessed: " + innerFrameBC2.id, + ]); + + // We don't expect the innerFrame to be autofilled with the saved login, since + // it is cross-origin with the top level frame, so we autocomplete instead. + info("Autocompleting saved login into inner form"); + await autocompleteLoginInIFrame( + secondtab.linkedBrowser, + innerFrameBC2, + "#inner-username" + ); + + await checkFormFields(outerFrameBC2, "outer", "", ""); + await checkFormFields(innerFrameBC2, "inner", "inner", "innerpass"); + + // Next, change the username and password fields in the outer frame and submit. + notifyPromise = listenForNotifications(1, "https://test1.example.com"); + info("submit page after changing outer form"); + + await SpecialPowers.spawn(outerFrameBC2, [locationMode], doClick => { + let doc = content.document; + doc.getElementById("outer-username").setUserInput("outer2"); + doc.getElementById("outer-password").setUserInput("outerpass2"); + if (doClick) { + doc.getElementById("outer-gobutton").click(); + } else { + doc.getElementById("outer-form").submit(); + } + + doc.getElementById("outer-form").submit(); + }); + + await acceptPasswordSave(); + await verifyNotifications(notifyPromise, ["FormSubmit: outer-username"]); + + // Finally, open a third tab with the same page in it to verify that the data gets filled properly. + notifyPromise = listenForNotifications(2); + let thirdtab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + OUTER_URL + ); + + let outerFrameBC3 = thirdtab.linkedBrowser.browsingContext; + let innerFrameBC3 = outerFrameBC3.children[0]; + await verifyNotifications(notifyPromise, [ + "FormProcessed: " + outerFrameBC3.id, + "FormProcessed: " + innerFrameBC3.id, + ]); + + // We don't expect the innerFrame to be autofilled with the saved login, since + // it is cross-origin with the top level frame, so we autocomplete instead. + info("Autocompleting saved login into inner form"); + await autocompleteLoginInIFrame( + thirdtab.linkedBrowser, + innerFrameBC3, + "#inner-username" + ); + + await checkFormFields(outerFrameBC3, "outer", "outer2", "outerpass2"); + await checkFormFields(innerFrameBC3, "inner", "inner", "innerpass"); + + LoginManagerParent.setListenerForTests(null); + + await BrowserTestUtils.removeTab(firsttab); + await BrowserTestUtils.removeTab(secondtab); + await BrowserTestUtils.removeTab(thirdtab); + + LoginTestUtils.clearData(); +} + +add_task(async function cross_site_frames_submit() { + await submitSomeCrossSiteFrames(false); +}); + +add_task(async function cross_site_frames_changelocation() { + await submitSomeCrossSiteFrames(true); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_dismissed_for_ccnumber.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_dismissed_for_ccnumber.js new file mode 100644 index 0000000000..eeaa211da8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_dismissed_for_ccnumber.js @@ -0,0 +1,202 @@ +"use strict"; + +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; + +add_task(async function test_doorhanger_dismissal_un() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function test_un_value_as_ccnumber(browser) { + // If the username field has a credit card number and if + // the password field is a three digit numberic value, + // we automatically dismiss the save logins prompt on submission. + + let passwordFilledPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + await changeContentFormValues(browser, { + "#form-basic-password": "123", + // We are interested in the state of the doorhanger created and don't want a + // false positive from the password-edited handling + "#form-basic-username": "4111111111111111", + }); + info("Waiting for passwordFilledPromise"); + await passwordFilledPromise; + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache so we can disambiguate between dismissed doorhanger from + // password edited vs form submitted w. cc number as username + await clearMessageCache(browser); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async () => { + content.document.getElementById("form-basic-submit").click(); + }); + info("Waiting for FormSubmit"); + await processedPromise; + + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok( + notif.dismissed, + "notification popup was automatically dismissed" + ); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_doorhanger_dismissal_pw() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function test_pw_value_as_ccnumber(browser) { + // If the password field has a credit card number and if + // the password field is also tagged autocomplete="cc-number", + // we automatically dismiss the save logins prompt on submission. + + let passwordFilledPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + await changeContentFormValues(browser, { + "#form-basic-password": "4111111111111111", + "#form-basic-username": "aaa", + }); + await SpecialPowers.spawn(browser, [], async () => { + content.document + .getElementById("form-basic-password") + .setAttribute("autocomplete", "cc-number"); + }); + await passwordFilledPromise; + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache so we can disambiguate between dismissed doorhanger from + // password edited vs form submitted w. cc number as password + await clearMessageCache(browser); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async () => { + content.document.getElementById("form-basic-submit").click(); + }); + await processedPromise; + + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok( + notif.dismissed, + "notification popup was automatically dismissed" + ); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_doorhanger_shown_on_un_with_invalid_ccnumber() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function test_un_with_invalid_cc_number(browser) { + // If the username field has a CC number that is invalid, + // we show the doorhanger to save logins like we usually do. + + let passwordFilledPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + await changeContentFormValues(browser, { + "#form-basic-password": "411", + "#form-basic-username": "1234123412341234", + }); + + await passwordFilledPromise; + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache so we can disambiguate between dismissed doorhanger from + // password edited vs form submitted w. cc number as password + await clearMessageCache(browser); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async () => { + content.document.getElementById("form-basic-submit").click(); + }); + await processedPromise; + + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok( + !notif.dismissed, + "notification popup was not automatically dismissed" + ); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_doorhanger_dismissal_on_change() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function test_change_in_pw(browser) { + let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + let login = new nsLoginInfo( + TEST_ORIGIN, + TEST_ORIGIN, + null, + "4111111111111111", + "111", // password looks like a card security code + "form-basic-username", + "form-basic-password" + ); + await Services.logins.addLoginAsync(login); + + let passwordFilledPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + + await changeContentFormValues(browser, { + "#form-basic-password": "222", // password looks like a card security code + "#form-basic-username": "4111111111111111", + }); + await passwordFilledPromise; + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache so we can disambiguate between dismissed doorhanger from + // password edited vs form submitted w. cc number as username + await clearMessageCache(browser); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async () => { + content.document.getElementById("form-basic-submit").click(); + }); + await processedPromise; + + let notif = getCaptureDoorhanger("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok( + notif.dismissed, + "notification popup was automatically dismissed" + ); + await cleanupDoorhanger(notif); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_empty_password.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_empty_password.js new file mode 100644 index 0000000000..15c3d52263 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_empty_password.js @@ -0,0 +1,42 @@ +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons.visibilityToggle", true]], + }); +}); + +/** + * Test that the doorhanger main action button is disabled + * when the password field is empty. + * + * Also checks that submiting an empty password throws an error. + */ +add_task(async function test_empty_password() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com/browser/toolkit/components/passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + await SpecialPowers.spawn(browser, [], async function () { + let doc = content.document; + doc.getElementById("form-basic-username").setUserInput("username"); + doc.getElementById("form-basic-password").setUserInput("pw"); + doc.getElementById("form-basic").submit(); + }); + + await waitForDoorhanger(browser, "password-save"); + // Synthesize input to empty the field + await updateDoorhangerInputValues({ + password: "", + }); + + let notificationElement = PopupNotifications.panel.childNodes[0]; + let mainActionButton = notificationElement.button; + + Assert.ok(mainActionButton.disabled, "Main action button is disabled"); + await hideDoorhangerPopup(); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_form_password_edit.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_form_password_edit.js new file mode 100644 index 0000000000..75690a25f2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_form_password_edit.js @@ -0,0 +1,562 @@ +/** + * Test changed (not submitted) passwords produce the right doorhangers/notifications + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; +const passwordInputSelector = "#form-basic-password"; +const usernameInputSelector = "#form-basic-username"; + +let testCases = [ + { + name: "Enter password", + prefEnabled: true, + isLoggedIn: true, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "abcXYZ", + }, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "abcXYZ", + toggle: "visible", + }, + }, + }, + { + name: "Change password", + prefEnabled: true, + isLoggedIn: true, + logins: [], + formDefaults: { + [passwordInputSelector]: "pass1", + }, + formChanges: { + [passwordInputSelector]: "pass-changed", + }, + expected: { + initialForm: { + username: "", + password: "pass1", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "pass-changed", + toggle: "visible", + }, + }, + }, + { + name: "Change autofilled password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user1", password: "autopass" }], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "autopass-changed", + }, + expected: { + initialForm: { + username: "user1", + password: "autopass", + }, + doorhanger: { + type: "password-change", + dismissed: true, + anchorExtraAttr: "", + username: "user1", + password: "autopass-changed", + }, + }, + }, + { + name: "Change autofilled username and password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user1", password: "pass1" }], + formDefaults: {}, + formChanges: { + [usernameInputSelector]: "user2", + [passwordInputSelector]: "pass2", + }, + expected: { + initialForm: { + username: "user1", + password: "pass1", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "user2", + password: "pass2", + toggle: "visible", + }, + }, + }, + { + name: "Change password pref disabled", + prefEnabled: false, + isLoggedIn: true, + logins: [], + formDefaults: { + [passwordInputSelector]: "pass1", + }, + formChanges: { + [passwordInputSelector]: "pass-changed", + }, + expected: { + initialForm: { + username: "", + password: "pass1", + }, + doorhanger: null, + }, + }, + { + name: "Change to new username", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user1", password: "pass1" }], + formDefaults: {}, + formChanges: { + [usernameInputSelector]: "user2", + }, + expected: { + initialForm: { + username: "user1", + password: "pass1", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "user2", + password: "pass1", + toggle: "visible", + }, + }, + }, + { + name: "Change to existing username, different password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user-saved", password: "pass1" }], + formDefaults: { + [usernameInputSelector]: "user-prefilled", + [passwordInputSelector]: "pass2", + }, + formChanges: { + [usernameInputSelector]: "user-saved", + }, + expected: { + initialForm: { + username: "user-prefilled", + password: "pass2", + }, + doorhanger: { + type: "password-change", + dismissed: true, + anchorExtraAttr: "", + username: "user-saved", + password: "pass2", + toggle: "visible", + }, + }, + }, + { + name: "Add username to existing password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "", password: "pass1" }], + formDefaults: {}, + formChanges: { + [usernameInputSelector]: "user1", + }, + expected: { + initialForm: { + username: "", + password: "pass1", + }, + doorhanger: { + type: "password-change", + dismissed: true, + anchorExtraAttr: "", + username: "user1", + password: "pass1", + toggle: "visible", + }, + }, + }, + { + name: "Change to existing username, password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user1", password: "pass1" }], + formDefaults: { + [usernameInputSelector]: "user", + [passwordInputSelector]: "pass", + }, + formChanges: { + [passwordInputSelector]: "pass1", + [usernameInputSelector]: "user1", + }, + expected: { + initialForm: { + username: "user", + password: "pass", + }, + doorhanger: null, + }, + }, + { + name: "Ensure a dismissed password-save doorhanger appears on an input event when editing an unsaved password", + prefEnabled: true, + isLoggedIn: true, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "a", + }, + shouldBlur: false, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "a", + toggle: "visible", + }, + }, + }, + { + name: "Ensure a dismissed password-save doorhanger appears with the latest input value upon editing an unsaved password", + prefEnabled: true, + isLoggedIn: true, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "a", + [passwordInputSelector]: "ab", + [passwordInputSelector]: "abc", + }, + shouldBlur: false, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "abc", + toggle: "visible", + }, + }, + }, + { + name: "Ensure a dismissed password-change doorhanger appears on an input event when editing a saved password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "", password: "pass1" }], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pass", + }, + shouldBlur: false, + expected: { + initialForm: { + username: "", + password: "pass1", + }, + doorhanger: { + type: "password-change", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "pass", + toggle: "visible", + }, + }, + }, + { + name: "Ensure no dismissed doorhanger is shown on 'input' when Primary Password is locked", + prefEnabled: true, + isLoggedIn: false, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pass", + }, + shouldBlur: false, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: null, + }, + }, + { + name: "Ensure no dismissed doorhanger is shown on 'change' when Primary Password is locked", + prefEnabled: true, + isLoggedIn: false, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pass", + }, + shouldBlur: true, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: null, + }, + }, +]; + +requestLongerTimeout(2); +SimpleTest.requestCompleteLog(); + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.passwordEditCapture.enabled", testData.prefEnabled]], + }); + if (!testData.isLoggedIn) { + // Enable Primary Password + LoginTestUtils.primaryPassword.enable(); + } + for (let passwordFieldType of ["password", "text"]) { + info( + "testing with type=" + + passwordFieldType + + ": " + + JSON.stringify(testData) + ); + await testPasswordChange(testData, { passwordFieldType }); + } + if (!testData.isLoggedIn) { + LoginTestUtils.primaryPassword.disable(); + } + await SpecialPowers.popPrefEnv(); + }, + }; + add_task(tmp[testData.name]); +} + +async function testPasswordChange( + { + logins = [], + formDefaults = {}, + formChanges = {}, + expected, + isLoggedIn, + shouldBlur = true, + }, + { passwordFieldType } +) { + await LoginTestUtils.clearData(); + await cleanupDoorhanger(); + + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + for (let login of logins) { + await LoginTestUtils.addLogin(login); + } + + for (let login of Services.logins.getAllLogins()) { + info(`Saved login: ${login.username}, ${login.password}, ${login.origin}`); + } + + let formProcessedPromise = listenForTestNotification("FormProcessed"); + info("Opening tab with url: " + url); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + info(`Opened tab with url: ${url}, waiting for focus`); + await SimpleTest.promiseFocus(browser.ownerGlobal); + info("Waiting for form-processed message"); + await formProcessedPromise; + await initForm(browser, formDefaults, { passwordFieldType }); + await checkForm(browser, expected.initialForm); + info("form checked"); + + // A message is still sent to the parent process when Primary Password is enabled + let notificationMessage = + expected.doorhanger || !isLoggedIn + ? "PasswordEditedOrGenerated" + : "PasswordIgnoreEdit"; + let passwordTestNotification = + listenForTestNotification(notificationMessage); + + await changeContentFormValues(browser, formChanges, shouldBlur); + + info( + `form edited, waiting for test notification of ${notificationMessage}` + ); + + await passwordTestNotification; + info("Resolved passwordTestNotification promise"); + + if (!expected.doorhanger) { + let notif; + try { + await TestUtils.waitForCondition( + () => { + return (notif = PopupNotifications.getNotification( + "password", + browser + )); + }, + `Waiting to ensure no notification`, + undefined, + 25 + ); + } catch (ex) {} + Assert.ok(!notif, "No doorhanger expected"); + // the remainder of the test is for doorhanger-expected cases + return; + } + + let notificationType = expected.doorhanger.type; + Assert.ok( + /^password-save|password-change$/.test(notificationType), + "test provided an expected notification type: " + notificationType + ); + info("waiting for doorhanger"); + await waitForDoorhanger(browser, notificationType); + + info("verifying doorhanger"); + let notif = await openAndVerifyDoorhanger( + browser, + notificationType, + expected.doorhanger + ); + Assert.ok(notif, "Doorhanger was shown"); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + info("cleanup doorhanger"); + await cleanupDoorhanger(notif); + } + ); +} + +async function initForm(browser, formDefaults, passwordFieldType) { + await ContentTask.spawn( + browser, + { passwordInputSelector, passwordFieldType }, + async function ({ passwordInputSelector, passwordFieldType }) { + content.document.querySelector(passwordInputSelector).type = + passwordFieldType; + } + ); + await ContentTask.spawn( + browser, + formDefaults, + async function (selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + content.document.querySelector(sel).value = value; + } + } + ); +} + +async function checkForm(browser, expected) { + await ContentTask.spawn( + browser, + { + [passwordInputSelector]: expected.password, + [usernameInputSelector]: expected.username, + }, + async function contentCheckForm(selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + let field = content.document.querySelector(sel); + Assert.equal( + field.value, + value, + sel + " has the expected initial value" + ); + } + } + ); +} + +async function openAndVerifyDoorhanger(browser, type, expected) { + // check a dismissed prompt was shown with extraAttr attribute + let notif = getCaptureDoorhanger(type); + Assert.ok(notif, `${type} doorhanger was created`); + Assert.equal( + notif.dismissed, + expected.dismissed, + "Check notification dismissed property" + ); + Assert.equal( + notif.anchorElement.getAttribute("extraAttr"), + expected.anchorExtraAttr, + "Check icon extraAttr attribute" + ); + let { panel } = PopupNotifications; + // if the doorhanged is dimissed, we will open it to check panel contents + Assert.equal(panel.state, "closed", "Panel is initially closed"); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + // synthesize click on anchor as this also blurs the form field triggering + // a change event + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + await promiseShown; + await Promise.resolve(); + await checkDoorhangerUsernamePassword(expected.username, expected.password); + + let notificationElement = PopupNotifications.panel.childNodes[0]; + let checkbox = notificationElement.querySelector( + "#password-notification-visibilityToggle" + ); + + if (expected.toggle == "visible") { + // Bug 1692284 + // Assert.ok(BrowserTestUtils.is_visible(checkbox), "Toggle checkbox visible as expected"); + } else if (expected.toggle == "hidden") { + Assert.ok( + BrowserTestUtils.is_hidden(checkbox), + "Toggle checkbox hidden as expected" + ); + } else { + info("Not checking toggle checkbox visibility"); + } + return notif; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_generated_password.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_generated_password.js new file mode 100644 index 0000000000..bbcab81854 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_generated_password.js @@ -0,0 +1,1845 @@ +/** + * Test using the generated passwords produces the right doorhangers/notifications + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const FORM_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"; +const passwordInputSelector = "#form-basic-password"; +const usernameInputSelector = "#form-basic-username"; + +requestLongerTimeout(2); + +async function task_setup() { + Services.logins.removeAllUserFacingLogins(); + LoginTestUtils.resetGeneratedPasswordsCache(); + await cleanupPasswordNotifications(); + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(); +} + +async function setup_withOneLogin(username = "username", password = "pass1") { + // Reset to a single, known login + await task_setup(); + let login = await LoginTestUtils.addLogin({ username, password }); + return login; +} + +async function setup_withNoLogins() { + // Reset to a single, known login + await task_setup(); + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "0 logins at the start of the test" + ); +} + +async function fillGeneratedPasswordFromACPopup( + browser, + passwordInputSelector +) { + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, passwordInputSelector); + await fillGeneratedPasswordFromOpenACPopup(browser, passwordInputSelector); +} + +async function checkPromptContents( + anchorElement, + browser, + expectedPasswordLength = 0 +) { + let { panel } = PopupNotifications; + Assert.ok(PopupNotifications.isPanelOpen, "Confirm popup is open"); + let notificationElement = panel.childNodes[0]; + if (expectedPasswordLength) { + info( + `Waiting for password value to be ${expectedPasswordLength} chars long` + ); + await BrowserTestUtils.waitForCondition(() => { + return ( + notificationElement.querySelector("#password-notification-password") + .value.length == expectedPasswordLength + ); + }, "Wait for nsLoginManagerPrompter writeDataToUI()"); + } + + return { + passwordValue: notificationElement.querySelector( + "#password-notification-password" + ).value, + usernameValue: notificationElement.querySelector( + "#password-notification-username" + ).value, + }; +} + +async function verifyGeneratedPasswordWasFilled( + browser, + passwordInputSelector +) { + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFinalFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + let passwordInput = content.document.querySelector(inputSelector); + Assert.equal( + passwordInput.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + } + ); +} + +async function openFormInNewTab(url, formValues, taskFn) { + let formFilled = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await formFilled; + + await SpecialPowers.spawn( + browser, + [formValues], + async function prepareAndCheckForm({ + password: passwordProps, + username: usernameProps, + }) { + let doc = content.document; + // give the form an action so we can know when submit is complete + doc.querySelector("form").action = "/"; + + let props = passwordProps; + if (props) { + // We'll reuse the form_basic.html, but ensure we'll get the generated password autocomplete option + let field = doc.querySelector(props.selector); + if (props.type) { + // Change the type from 'password' to something else. + field.type = props.type; + } + + field.setAttribute("autocomplete", "new-password"); + if (props.hasOwnProperty("expectedValue")) { + Assert.equal( + field.value, + props.expectedValue, + "Check autofilled password value" + ); + } + } + props = usernameProps; + if (props) { + let field = doc.querySelector(props.selector); + if (props.hasOwnProperty("expectedValue")) { + Assert.equal( + field.value, + props.expectedValue, + "Check autofilled username value" + ); + } + } + } + ); + + if (formValues.password && formValues.password.setValue !== undefined) { + info( + "Editing the password, expectedMessage? " + + formValues.password.expectedMessage + ); + let messagePromise = formValues.password.expectedMessage + ? listenForTestNotification(formValues.password.expectedMessage) + : Promise.resolve(); + await changeContentInputValue( + browser, + formValues.password.selector, + formValues.password.setValue + ); + await messagePromise; + info("messagePromise resolved"); + } + + if (formValues.username && formValues.username.setValue !== undefined) { + info( + "Editing the username, expectedMessage? " + + formValues.username.expectedMessage + ); + let messagePromise = formValues.username.expectedMessage + ? listenForTestNotification(formValues.username.expectedMessage) + : Promise.resolve(); + await changeContentInputValue( + browser, + formValues.username.selector, + formValues.username.setValue + ); + await messagePromise; + info("messagePromise resolved"); + } + + await taskFn(browser); + await closePopup( + browser.ownerDocument.getElementById("confirmation-hint") + ); + } + ); +} + +async function openAndVerifyDoorhanger(browser, type, expected) { + // check a dismissed prompt was shown with extraAttr attribute + let notif = getCaptureDoorhanger(type); + Assert.ok(notif, `${type} doorhanger was created`); + Assert.equal( + notif.dismissed, + expected.dismissed, + "Check notification dismissed property" + ); + Assert.equal( + notif.anchorElement.getAttribute("extraAttr"), + expected.anchorExtraAttr, + "Check icon extraAttr attribute" + ); + let { panel } = PopupNotifications; + // if the doorhanged is dimissed, we will open it to check panel contents + if (panel.state !== "open") { + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + if (panel.state !== "showing") { + // synthesize click on anchor as this also blurs the form field triggering + // a change event + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + } + await promiseShown; + } + let { passwordValue, usernameValue } = await checkPromptContents( + notif.anchorElement, + browser, + expected.passwordLength + ); + Assert.equal( + passwordValue.length, + expected.passwordLength || LoginTestUtils.generation.LENGTH, + "Doorhanger password field has generated 15-char value" + ); + Assert.equal( + usernameValue, + expected.usernameValue, + "Doorhanger username field was popuplated" + ); + return notif; +} + +async function appendContentInputvalue(browser, selector, str) { + await ContentTask.spawn( + browser, + { selector, str }, + async function ({ selector, str }) { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let input = content.document.querySelector(selector); + input.focus(); + input.select(); + await EventUtils.synthesizeKey("KEY_ArrowRight", {}, content); + let changedPromise = ContentTaskUtils.waitForEvent(input, "change"); + if (str) { + await EventUtils.sendString(str, content); + } + input.blur(); + await changedPromise; + } + ); + info("Input value changed"); + await TestUtils.waitForTick(); +} + +async function submitForm(browser) { + // Submit the form + info("Now submit the form"); + let correctPathNamePromise = BrowserTestUtils.browserLoaded(browser); + await SpecialPowers.spawn(browser, [], async function () { + content.document.querySelector("form").submit(); + }); + await correctPathNamePromise; + await SpecialPowers.spawn(browser, [], async () => { + let win = content; + await ContentTaskUtils.waitForCondition(() => { + return ( + win.location.pathname == "/" && win.document.readyState == "complete" + ); + }, "Wait for form submission load"); + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.generation.available", true], + ["signon.generation.enabled", true], + ], + }); + // assert that there are no logins + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins"); +}); + +add_task(async function autocomplete_generated_password_auto_saved() { + // confirm behavior when filling a generated password via autocomplete + // when there are no other logins + await setup_withNoLogins(); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { selector: passwordInputSelector, expectedValue: "" }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // Let the hint hide itself this first time + let forceClosePopup = false; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + let [{ username, password }] = await storageChangedPromise; + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + // Check properties of the newly auto-saved login + Assert.equal(username, "", "Saved login should have no username"); + Assert.equal( + password.length, + LoginTestUtils.generation.LENGTH, + "Saved login should have generated password" + ); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + // confirm the extraAttr attribute is removed after opening & dismissing the doorhanger + Assert.ok( + !notif.anchorElement.hasAttribute("extraAttr"), + "Check if the extraAttr attribute was removed" + ); + await cleanupDoorhanger(notif); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + let [autoSavedLogin] = Services.logins.getAllLogins(); + info("waiting for submitForm"); + await submitForm(browser); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: autoSavedLogin.timesUsed + 1, + username: "", + }, + ]); + } + ); +}); + +add_task( + async function autocomplete_generated_password_with_confirm_field_auto_saved() { + // confirm behavior when filling a generated password via autocomplete + // when there are no other logins and the form has a confirm password field + const FORM_WITH_CONFIRM_FIELD_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic_with_confirm_field.html"; + const confirmPasswordInputSelector = "#form-basic-confirm-password"; + await setup_withNoLogins(); + await openFormInNewTab( + TEST_ORIGIN + FORM_WITH_CONFIRM_FIELD_PAGE_PATH, + { + password: { selector: passwordInputSelector, expectedValue: "" }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // Let the hint hide itself this first time + let forceClosePopup = false; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + let [{ username, password }] = await storageChangedPromise; + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + await verifyGeneratedPasswordWasFilled( + browser, + confirmPasswordInputSelector + ); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + // Check properties of the newly auto-saved login + Assert.equal(username, "", "Saved login should have no username"); + Assert.equal( + password.length, + LoginTestUtils.generation.LENGTH, + "Saved login should have generated password" + ); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + // confirm the extraAttr attribute is removed after opening & dismissing the doorhanger + Assert.ok( + !notif.anchorElement.hasAttribute("extraAttr"), + "Check if the extraAttr attribute was removed" + ); + await cleanupDoorhanger(notif); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + let [autoSavedLogin] = Services.logins.getAllLogins(); + info("waiting for submitForm"); + await submitForm(browser); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: autoSavedLogin.timesUsed + 1, + username: "", + }, + ]); + } + ); + } +); + +add_task(async function autocomplete_generated_password_saved_empty_username() { + // confirm behavior when filling a generated password via autocomplete + // when there is an existing saved login with a "" username + await setup_withOneLogin("", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let [savedLogin] = Services.logins.getAllLogins(); + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + await waitForDoorhanger(browser, "password-change"); + info("Waiting to openAndVerifyDoorhanger"); + await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + await hideDoorhangerPopup(); + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + info("waiting for submitForm"); + await submitForm(browser); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: savedLogin.timesUsed + 1, + username: "", + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); +}); + +add_task(async function autocomplete_generated_password_saved_username() { + // confirm behavior when filling a generated password via autocomplete + // into a form with username matching an existing saved login + await setup_withOneLogin("user1", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "user1", + }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + // Check properties of the newly auto-saved login + let [user1LoginSnapshot, autoSavedLogin] = verifyLogins([ + { + username: "user1", + password: "xyzpassword", // user1 is unchanged + }, + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "user1", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + // confirm the extraAttr attribute is removed after opening & dismissing the doorhanger + Assert.ok( + !notif.anchorElement.hasAttribute("extraAttr"), + "Check if the extraAttr attribute was removed" + ); + await cleanupDoorhanger(notif); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + info("waiting for submitForm"); + await submitForm(browser); + promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + await storageChangedPromise; + verifyLogins([ + { + timesUsed: user1LoginSnapshot.timesUsed + 1, + username: "user1", + password: autoSavedLogin.password, + }, + ]); + } + ); +}); + +add_task(async function ac_gen_pw_saved_empty_un_stored_non_empty_un_in_form() { + // confirm behavior when when the form's username field has a non-empty value + // and there is an existing saved login with a "" username + await setup_withOneLogin("", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "", + setValue: "myusername", + // with an empty password value, no message is sent for a username change + expectedMessage: "", + }, + }, + async function taskFn(browser) { + let [savedLogin] = Services.logins.getAllLogins(); + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + await waitForDoorhanger(browser, "password-save"); + info("Waiting to openAndVerifyDoorhanger"); + await openAndVerifyDoorhanger(browser, "password-save", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "myusername", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + await hideDoorhangerPopup(); + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + info("waiting for submitForm"); + await submitForm(browser); + let notif = await openAndVerifyDoorhanger(browser, "password-save", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "myusername", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseHidden; + + info("Waiting for addLogin"); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: savedLogin.timesUsed, + username: "", + password: "xyzpassword", + }, + { + timesUsed: 1, + username: "myusername", + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); +}); + +add_task(async function contextfill_generated_password_saved_empty_username() { + // confirm behavior when filling a generated password via context menu + // when there is an existing saved login with a "" username + await setup_withOneLogin("", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let [savedLogin] = Services.logins.getAllLogins(); + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await waitForDoorhanger(browser, "password-change"); + info("Waiting to openAndVerifyDoorhanger"); + await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + await hideDoorhangerPopup(); + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + info("waiting for submitForm"); + await submitForm(browser); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: savedLogin.timesUsed + 1, + username: "", + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); +}); + +async function autocomplete_generated_password_edited_no_auto_save( + passwordType = "password" +) { + // confirm behavior when filling a generated password via autocomplete + // when there is an existing saved login with a "" username and then editing + // the password and autocompleting again. + await setup_withOneLogin("", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + type: passwordType, + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let [savedLogin] = Services.logins.getAllLogins(); + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + info( + "Filled generated password, waiting for dismissed password-change doorhanger" + ); + await waitForDoorhanger(browser, "password-change"); + info("Waiting to openAndVerifyDoorhanger"); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + await BrowserTestUtils.sendChar("!", browser); + await BrowserTestUtils.sendChar("@", browser); + await BrowserTestUtils.synthesizeKey("KEY_Tab", undefined, browser); + + await waitForDoorhanger(browser, "password-change"); + info("Waiting to openAndVerifyDoorhanger"); + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH + 2, + }); + + promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + verifyLogins([ + { + timesUsed: savedLogin.timesUsed, + username: "", + password: "xyzpassword", + }, + ]); + + info("waiting for submitForm"); + await submitForm(browser); + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH + 2, + }); + + promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: savedLogin.timesUsed + 1, + username: "", + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); + + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); +} + +add_task(autocomplete_generated_password_edited_no_auto_save); + +add_task( + async function autocomplete_generated_password_edited_no_auto_save_type_text() { + await autocomplete_generated_password_edited_no_auto_save("text"); + } +); + +add_task(async function contextmenu_fill_generated_password_and_set_username() { + // test when filling with a generated password and editing the username in the form + // * the prompt should display the form's username + // * the auto-saved login should have "" for username + // * confirming the prompt should edit the "" login and add the username + await setup_withOneLogin("olduser", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "olduser", + setValue: "differentuser", + // with an empty password value, no message is sent for a username change + expectedMessage: "", + }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector, usernameInputSelector]], + function checkEmptyPasswordField([passwordSelector, usernameSelector]) { + Assert.equal( + content.document.querySelector(passwordSelector).value, + "", + "Password field is empty" + ); + } + ); + + // Let the hint hide itself this first time + let forceClosePopup = false; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("waiting to fill generated password using context menu"); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + info("waiting for addLogin"); + await storageChangedPromise; + + // Check properties of the newly auto-saved login + verifyLogins([ + null, // ignore the first one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + + info("Waiting to openAndVerifyDoorhanger"); + await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "differentuser", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + await hideDoorhangerPopup(); + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + info("waiting for submitForm"); + await submitForm(browser); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "differentuser", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + null, + { + username: "differentuser", + passwordLength: LoginTestUtils.generation.LENGTH, + timesUsed: 2, + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); +}); + +add_task(async function contextmenu_password_change_form_without_username() { + // test doorhanger behavior when a generated password is filled into a change-password + // form with no username + await setup_withOneLogin("user1", "xyzpassword"); + await LoginTestUtils.addLogin({ username: "username2", password: "pass2" }); + const passwordInputSelector = "#newpass"; + + const CHANGE_FORM_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_password_change.html"; + await openFormInNewTab( + TEST_ORIGIN + CHANGE_FORM_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "", + }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + // Make the 2nd field use a generated password + info("Using contextmenu to fill with a generated password"); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + // Check properties of the newly auto-saved login + verifyLogins([ + null, // ignore the first one + null, // ignore the 2nd one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + + info("Waiting to openAndVerifyDoorhanger"); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + // remove notification so we can unambiguously check no new notification gets created later + await cleanupDoorhanger(notif); + + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + let { timeLastUsed } = Services.logins.getAllLogins()[2]; + + info("waiting for submitForm"); + await submitForm(browser); + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + null, // ignore the first one + null, // ignore the 2nd one + { + timesUsed: 2, + usedSince: timeLastUsed, + }, + ]); + // Check no new doorhanger was shown + notif = getCaptureDoorhanger("password-change"); + Assert.ok(!notif, "No new doorhanger should be shown"); + await cleanupDoorhanger(); // cleanup for next test + } + ); +}); + +add_task( + async function autosaved_login_updated_to_existing_login_via_doorhanger() { + // test when filling with a generated password and editing the username in the + // doorhanger to match an existing login: + // * the matching login should be updated + // * the auto-saved login should be deleted + // * the metadata for the matching login should be updated + // * the by-origin cache for the password should point at the updated login + await setup_withOneLogin("user1", "xyzpassword"); + await LoginTestUtils.addLogin({ + username: "user2", + password: "abcpassword", + }); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "", + }, + username: { + selector: usernameInputSelector, + expectedValue: "", + }, + }, + async function taskFn(browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("waiting to fill generated password using context menu"); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + info("addLogin promise resolved"); + // Check properties of the newly auto-saved login + let [user1LoginSnapshot, unused, autoSavedLogin] = verifyLogins([ + null, // ignore the first one + null, // ignore the 2nd one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + info("user1LoginSnapshot, guid: " + user1LoginSnapshot.guid); + info("unused, guid: " + unused.guid); + info("autoSavedLogin, guid: " + autoSavedLogin.guid); + + info("verifyLogins ok"); + let passwordCacheEntry = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ); + + Assert.ok( + passwordCacheEntry, + "Got the cached generated password entry for https://example.com" + ); + Assert.equal( + passwordCacheEntry.value, + autoSavedLogin.password, + "Cached password matches the auto-saved login password" + ); + Assert.equal( + passwordCacheEntry.storageGUID, + autoSavedLogin.guid, + "Cached password guid matches the auto-saved login guid" + ); + + info("Waiting to openAndVerifyDoorhanger"); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + password: autoSavedLogin.password, + }); + Assert.ok(notif, "Got password-change notification"); + + info("Calling updateDoorhangerInputValues"); + await updateDoorhangerInputValues({ + username: "user1", + }); + info("doorhanger inputs updated"); + + let loginModifiedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (subject, data) => { + if (data == "modifyLogin") { + info("passwordmgr-storage-changed, action: " + data); + info("subject: " + JSON.stringify(subject)); + return true; + } + return false; + } + ); + let loginRemovedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (subject, data) => { + if (data == "removeLogin") { + info("passwordmgr-storage-changed, action: " + data); + info("subject: " + JSON.stringify(subject)); + return true; + } + return false; + } + ); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + info("clicking change button"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin promise"); + await loginModifiedPromise; + + info("Waiting for removeLogin promise"); + await loginRemovedPromise; + + info("storage-change promises resolved"); + // Check the auto-saved login was removed and the original login updated + verifyLogins([ + { + username: "user1", + password: autoSavedLogin.password, + timeCreated: user1LoginSnapshot.timeCreated, + timeLastUsed: user1LoginSnapshot.timeLastUsed, + passwordChangedSince: autoSavedLogin.timePasswordChanged, + }, + null, // ignore user2 + ]); + + // Check we have no notifications at this point + Assert.ok(!PopupNotifications.isPanelOpen, "No doorhanger is open"); + Assert.ok( + !PopupNotifications.getNotification("password", browser), + "No notifications" + ); + + // make sure the cache entry is unchanged with the removal of the auto-saved login + Assert.equal( + autoSavedLogin.password, + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ).value, + "Generated password cache entry has the expected password value" + ); + } + ); + } +); + +add_task(async function autosaved_login_updated_to_existing_login_onsubmit() { + // test when selecting auto-saved generated password in a form filled with an + // existing login and submitting the form: + // * the matching login should be updated + // * the auto-saved login should be deleted + // * the metadata for the matching login should be updated + // * the by-origin cache for the password should point at the updated login + + // clear both fields which should be autofilled with our single login + await setup_withOneLogin("user1", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "user1", + setValue: "", + // with an empty password value, no message is sent for a username change + expectedMessage: "", + }, + }, + async function taskFn(browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + // first, create an auto-saved login with generated password + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("waiting to fill generated password using context menu"); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + info("addLogin promise resolved"); + // Check properties of the newly auto-saved login + let [user1LoginSnapshot, autoSavedLogin] = verifyLogins([ + null, // ignore the first one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + info("user1LoginSnapshot, guid: " + user1LoginSnapshot.guid); + info("autoSavedLogin, guid: " + autoSavedLogin.guid); + + info("verifyLogins ok"); + let passwordCacheEntry = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ); + + Assert.ok( + passwordCacheEntry, + "Got the cached generated password entry for https://example.com" + ); + Assert.equal( + passwordCacheEntry.value, + autoSavedLogin.password, + "Cached password matches the auto-saved login password" + ); + Assert.equal( + passwordCacheEntry.storageGUID, + autoSavedLogin.guid, + "Cached password guid matches the auto-saved login guid" + ); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + password: autoSavedLogin.password, + }); + await cleanupDoorhanger(notif); + + // now update and submit the form with the user1 username and the generated password + info(`submitting form`); + let submitResults = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#form-basic-username": "user1", + } + ); + Assert.equal( + submitResults.username, + "user1", + "Form submitted with expected username" + ); + Assert.equal( + submitResults.password, + autoSavedLogin.password, + "Form submitted with expected password" + ); + info( + `form was submitted, got username/password ${submitResults.username}/${submitResults.password}` + ); + + await waitForDoorhanger(browser, "password-change"); + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "user1", + password: autoSavedLogin.password, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + let loginModifiedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => { + if (data == "modifyLogin") { + info("passwordmgr-storage-changed, action: " + data); + info("subject: " + JSON.stringify(_)); + return true; + } + return false; + } + ); + let loginRemovedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => { + if (data == "removeLogin") { + info("passwordmgr-storage-changed, action: " + data); + info("subject: " + JSON.stringify(_)); + return true; + } + return false; + } + ); + + info("clicking change button"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin promise"); + await loginModifiedPromise; + + info("Waiting for removeLogin promise"); + await loginRemovedPromise; + + info("storage-change promises resolved"); + // Check the auto-saved login was removed and the original login updated + verifyLogins([ + { + username: "user1", + password: autoSavedLogin.password, + timeCreated: user1LoginSnapshot.timeCreated, + timeLastUsed: user1LoginSnapshot.timeLastUsed, + passwordChangedSince: autoSavedLogin.timePasswordChanged, + }, + ]); + + // Check we have no notifications at this point + Assert.ok(!PopupNotifications.isPanelOpen, "No doorhanger is open"); + Assert.ok( + !PopupNotifications.getNotification("password", browser), + "No notifications" + ); + + // make sure the cache entry is unchanged with the removal of the auto-saved login + Assert.equal( + autoSavedLogin.password, + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ).value, + "Generated password cache entry has the expected password value" + ); + } + ); +}); + +add_task(async function form_change_from_autosaved_login_to_existing_login() { + // test when changing from a generated password in a form to an existing saved login + // * the auto-saved login should not be deleted + // * the metadata for the matching login should be updated + // * the by-origin cache for the password should point at the autosaved login + + await setup_withOneLogin("user1", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "user1", + setValue: "", + // with an empty password value, no message is sent for a username change + expectedMessage: "", + }, + }, + async function taskFn(browser) { + await SimpleTest.promiseFocus(browser); + + // first, create an auto-saved login with generated password + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("Filling generated password from AC menu"); + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + info("addLogin promise resolved"); + // Check properties of the newly auto-saved login + let [user1LoginSnapshot, autoSavedLogin] = verifyLogins([ + null, // ignore the first one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + info("user1LoginSnapshot, guid: " + user1LoginSnapshot.guid); + info("autoSavedLogin, guid: " + autoSavedLogin.guid); + + info("verifyLogins ok"); + let passwordCacheEntry = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ); + + Assert.ok( + passwordCacheEntry, + "Got the cached generated password entry for https://example.com" + ); + Assert.equal( + passwordCacheEntry.value, + autoSavedLogin.password, + "Cached password matches the auto-saved login password" + ); + Assert.equal( + passwordCacheEntry.storageGUID, + autoSavedLogin.guid, + "Cached password guid matches the auto-saved login guid" + ); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + password: autoSavedLogin.password, + }); + + // close but don't remove the doorhanger, we want to ensure it is updated/replaced on further form edits + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + let PN = notif.owner; + PN.panel.hidePopup(); + await promiseHidden; + await TestUtils.waitForTick(); + + // now update the form with the user1 username and password + info(`updating form`); + let passwordEditedMessages = listenForTestNotification( + "PasswordEditedOrGenerated", + 2 + ); + let passwordChangeDoorhangerPromise = waitForDoorhanger( + browser, + "password-change" + ); + let hintDidShow = false; + let hintPromiseShown = BrowserTestUtils.waitForPopupEvent( + document.getElementById("confirmation-hint"), + "shown" + ); + hintPromiseShown.then(() => (hintDidShow = true)); + + info("Entering username and password for the previously saved login"); + + await changeContentFormValues(browser, { + [passwordInputSelector]: user1LoginSnapshot.password, + [usernameInputSelector]: user1LoginSnapshot.username, + }); + info( + "form edited, waiting for test notification of PasswordEditedOrGenerated" + ); + + await passwordEditedMessages; + info("Resolved listenForTestNotification promise"); + + await passwordChangeDoorhangerPromise; + // wait to ensure there's no confirmation hint + try { + await TestUtils.waitForCondition( + () => { + return hintDidShow; + }, + `Waiting for confirmationHint popup`, + undefined, + 25 + ); + } catch (ex) { + info("Got expected timeout from the waitForCondition: ", ex); + } finally { + Assert.ok(!hintDidShow, "No confirmation hint shown"); + } + + // the previous doorhanger would have old values, verify it was updated/replaced with new values from the form + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: user1LoginSnapshot.username, + passwordLength: user1LoginSnapshot.password.length, + }); + await cleanupDoorhanger(notif); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + + // submit the form to ensure the correct updates are made + await submitForm(browser); + info("form submitted, waiting for storage changed"); + await storageChangedPromise; + + // Check the auto-saved login has not changed and only metadata on the original login updated + verifyLogins([ + { + username: "user1", + password: "xyzpassword", + timeCreated: user1LoginSnapshot.timeCreated, + usedSince: user1LoginSnapshot.timeLastUsed, + }, + { + username: "", + password: autoSavedLogin.password, + timeCreated: autoSavedLogin.timeCreated, + timeLastUsed: autoSavedLogin.timeLastUsed, + }, + ]); + + // Check we have no notifications at this point + Assert.ok(!PopupNotifications.isPanelOpen, "No doorhanger is open"); + Assert.ok( + !PopupNotifications.getNotification("password", browser), + "No notifications" + ); + + // make sure the cache entry is unchanged with the removal of the auto-saved login + Assert.equal( + autoSavedLogin.password, + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ).value, + "Generated password cache entry has the expected password value" + ); + } + ); +}); + +add_task(async function form_edit_username_and_password_of_generated_login() { + // test when changing the username and then the password in a form with a generated password (bug 1625242) + // * the toast is not shown for the username change as the auto-saved login is not modified + // * the dismissed doorhanger for the username change has the correct username and password + // * the toast is shown for the change to the generated password + // * the dismissed doorhanger for the password change has the correct username and password + + await setup_withNoLogins(); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + {}, + async function taskFn(browser) { + await SimpleTest.promiseFocus(browser); + + // first, create an auto-saved login with generated password + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("Filling generated password from context menu"); + // there's no new-password field in this form so we'll use the context menu + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + info("addLogin promise resolved"); + // Check properties of the newly auto-saved login + let [autoSavedLoginSnapshot] = verifyLogins([ + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + password: autoSavedLoginSnapshot.password, + }); + + // close but don't remove the doorhanger, we want to ensure it is updated/replaced on further form edits + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + let PN = notif.owner; + PN.panel.hidePopup(); + await promiseHidden; + await TestUtils.waitForTick(); + + // change the username then the password in the form + for (let { + fieldSelector, + fieldValue, + expectedConfirmation, + expectedDoorhangerUsername, + expectedDoorhangerPassword, + expectedDoorhangerType, + } of [ + { + fieldSelector: usernameInputSelector, + fieldValue: "someuser", + expectedConfirmation: false, + expectedDoorhangerUsername: "someuser", + expectedDoorhangerPassword: autoSavedLoginSnapshot.password, + expectedDoorhangerType: "password-change", + }, + { + fieldSelector: passwordInputSelector, + fieldValue: "!!", + expectedConfirmation: true, + expectedDoorhangerUsername: "someuser", + expectedDoorhangerPassword: autoSavedLoginSnapshot.password + "!!", + expectedDoorhangerType: "password-change", + }, + ]) { + let loginModifiedPromise = expectedConfirmation + ? TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ) + : Promise.resolve(); + + // now edit the field value + let passwordEditedMessage = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + let passwordChangeDoorhangerPromise = waitForDoorhanger( + browser, + expectedDoorhangerType + ); + let hintDidShow = false; + let hintPromiseShown = BrowserTestUtils.waitForPopupEvent( + document.getElementById("confirmation-hint"), + "shown" + ); + hintPromiseShown.then(() => (hintDidShow = true)); + + info(`updating form: ${fieldSelector}: ${fieldValue}`); + await appendContentInputvalue(browser, fieldSelector, fieldValue); + info( + "form edited, waiting for test notification of PasswordEditedOrGenerated" + ); + await passwordEditedMessage; + info( + "Resolved listenForTestNotification promise, waiting for doorhanger" + ); + await passwordChangeDoorhangerPromise; + // wait for possible confirmation hint + try { + info("Waiting for hintDidShow"); + await TestUtils.waitForCondition( + () => hintDidShow, + `Waiting for confirmationHint popup`, + undefined, + 25 + ); + } catch (ex) { + info("Got expected timeout from the waitForCondition: " + ex); + } finally { + info("confirmationHint check done, assert on hintDidShow"); + Assert.equal( + hintDidShow, + expectedConfirmation, + "Confirmation hint shown" + ); + } + info( + "Waiting for loginModifiedPromise, expectedConfirmation? " + + expectedConfirmation + ); + await loginModifiedPromise; + + // the previous doorhanger would have old values, verify it was updated/replaced with new values from the form + info("Verifying the doorhanger"); + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: expectedConfirmation ? "attention" : "", + usernameValue: expectedDoorhangerUsername, + passwordLength: expectedDoorhangerPassword.length, + }); + await cleanupDoorhanger(notif); + } + + // submit the form to verify we still get the right doorhanger values + let passwordChangeDoorhangerPromise = waitForDoorhanger( + browser, + "password-change" + ); + await submitForm(browser); + info("form submitted, waiting for doorhanger"); + await passwordChangeDoorhangerPromise; + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "someuser", + passwordLength: LoginTestUtils.generation.LENGTH + 2, + }); + await cleanupDoorhanger(notif); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js new file mode 100644 index 0000000000..acf3b64e08 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js @@ -0,0 +1,303 @@ +/* + * Test capture popup notifications with HTTPS upgrades + */ + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login1 = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +let login1HTTPS = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); + +add_task(async function test_httpsUpgradeCaptureFields_noChange() { + info( + "Check that we don't prompt to remember when capturing an upgraded login with no change" + ); + await Services.logins.addLoginAsync(login1); + // Sanity check the HTTP login exists. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should have the HTTP login"); + + await testSubmittingLoginForm( + "subtst_notifications_1.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + }, + "https://example.com" + ); // This is HTTPS whereas the saved login is HTTP + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login still"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.origin, + "http://example.com", + "Check the origin is unchanged" + ); + Assert.equal(login.username, "notifyu1", "Check the username is unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password is unchanged"); + Assert.equal(login.timesUsed, 2, "Check times used increased"); + + Services.logins.removeLogin(login1); +}); + +add_task(async function test_httpsUpgradeCaptureFields_changePW() { + info( + "Check that we prompt to change when capturing an upgraded login with a new PW" + ); + await Services.logins.addLoginAsync(login1); + // Sanity check the HTTP login exists. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should have the HTTP login"); + + await testSubmittingLoginForm( + "subtst_notifications_8.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "checking for a change popup"); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + }, + "https://example.com" + ); // This is HTTPS whereas the saved login is HTTP + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login still"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.origin, + "https://example.com", + "Check the origin is upgraded" + ); + Assert.equal( + login.formActionOrigin, + "https://example.com", + "Check the formActionOrigin is upgraded" + ); + Assert.equal(login.username, "notifyu1", "Check the username is unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used increased"); + + Services.logins.removeAllUserFacingLogins(); +}); + +add_task( + async function test_httpsUpgradeCaptureFields_changePWWithBothSchemesSaved() { + info( + "Check that we prompt to change and properly save when capturing an upgraded login with a new PW when an http login also exists for that username" + ); + await Services.logins.addLogins([login1, login1HTTPS]); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have both HTTP and HTTPS logins"); + + await testSubmittingLoginForm( + "subtst_notifications_8.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "checking for a change popup"); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + }, + "https://example.com" + ); + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have 2 logins still"); + let loginHTTP = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + let loginHTTPS = logins[1].QueryInterface(Ci.nsILoginMetaInfo); + Assert.ok( + LoginHelper.doLoginsMatch(login1, loginHTTP, { ignorePassword: true }), + "Check HTTP login is equal" + ); + Assert.equal(loginHTTP.timesUsed, 1, "Check times used stayed the same"); + Assert.equal( + loginHTTP.timeCreated, + loginHTTP.timePasswordChanged, + "login.timeCreated == login.timePasswordChanged" + ); + Assert.equal( + loginHTTP.timeLastUsed, + loginHTTP.timePasswordChanged, + "timeLastUsed == timePasswordChanged" + ); + + Assert.ok( + LoginHelper.doLoginsMatch(login1HTTPS, loginHTTPS, { + ignorePassword: true, + }), + "Check HTTPS login is equal" + ); + Assert.equal( + loginHTTPS.username, + "notifyu1", + "Check the username is unchanged" + ); + Assert.equal(loginHTTPS.password, "pass2", "Check the password changed"); + Assert.equal(loginHTTPS.timesUsed, 2, "Check times used increased"); + Assert.ok( + loginHTTPS.timeCreated < loginHTTPS.timePasswordChanged, + "login.timeCreated < login.timePasswordChanged" + ); + Assert.equal( + loginHTTPS.timeLastUsed, + loginHTTPS.timePasswordChanged, + "timeLastUsed == timePasswordChanged" + ); + + Services.logins.removeAllUserFacingLogins(); + } +); + +add_task(async function test_httpsUpgradeCaptureFields_captureMatchingHTTP() { + info("Capture a new HTTP login which matches a stored HTTPS one."); + await Services.logins.addLoginAsync(login1HTTPS); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + + Assert.equal( + Services.logins.getAllLogins().length, + 1, + "Should only have the HTTPS login" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have both HTTP and HTTPS logins"); + for (let login of logins) { + login = login.QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu1", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp1", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on entry"); + } + + info( + "Make sure Remember took effect and we don't prompt for an existing HTTP login" + ); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have both HTTP and HTTPS still"); + + let httpsLogins = LoginHelper.searchLoginsWithObject({ + origin: "https://example.com", + }); + Assert.equal(httpsLogins.length, 1, "Check https logins count"); + let httpsLogin = httpsLogins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.ok(httpsLogin.equals(login1HTTPS), "Check HTTPS login didn't change"); + Assert.equal(httpsLogin.timesUsed, 1, "Check times used"); + + let httpLogins = LoginHelper.searchLoginsWithObject({ + origin: "http://example.com", + }); + Assert.equal(httpLogins.length, 1, "Check http logins count"); + let httpLogin = httpLogins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.ok(httpLogin.equals(login1), "Check HTTP login is as expected"); + Assert.equal(httpLogin.timesUsed, 2, "Check times used increased"); + + Services.logins.removeLogin(login1); + Services.logins.removeLogin(login1HTTPS); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_multipage_form.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_multipage_form.js new file mode 100644 index 0000000000..ff7bffcb44 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_multipage_form.js @@ -0,0 +1,182 @@ +/** + * Test that the doorhanger notification for password saving is populated with + * the correct values in various password capture cases (multipage login form). + */ + +const testCases = [ + { + name: "No saved logins, username and password", + username: "username", + password: "password", + expectOutcome: [ + { + username: "username", + password: "password", + }, + ], + }, + { + name: "No saved logins, password with empty username", + username: "", + password: "password", + expectOutcome: [ + { + username: "", + password: "password", + }, + ], + }, + { + name: "Saved login with username, update password", + username: "username", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "username", + password: "newPassword", + }, + ], + }, + { + name: "Saved login with username, add username", + oldUsername: "username", + username: "newUsername", + password: "password", + expectOutcome: [ + { + username: "newUsername", + password: "password", + }, + ], + }, + { + name: "Saved login with no username, add username and different password", + oldUsername: "", + username: "username", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "", + password: "password", + }, + { + username: "username", + password: "newPassword", + }, + ], + }, +]; + +add_task(async function test_initialize() { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); +}); + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + info("testing with: " + JSON.stringify(testData)); + await test_save_change(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function test_save_change(testData) { + let { oldUsername, username, oldPassword, password, expectOutcome } = + testData; + // Add a login for the origin of the form if testing a change notification. + if (oldPassword) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: typeof oldUsername !== "undefined" ? oldUsername : username, + password: oldPassword, + }) + ); + } + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_multipage.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + // Update the username filed from the test case. + info(`update form with username: ${username}`); + await changeContentFormValues(browser, { + "#form-basic-username": username, + }); + + // Submit the username-only form, which then advance to the password-only + // form. + info(`submit the username-only form`); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic-submit").click(); + await ContentTaskUtils.waitForCondition(() => { + return doc.getElementById("form-basic-password"); + }, "Wait for the username field"); + }); + + // Update the password filed from the test case. + info(`update form with password: ${password}`); + await changeContentFormValues(browser, { + "#form-basic-password": password, + }); + + // Submit the form. + info(`submit the password-only form`); + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic-submit").click(); + }); + await formSubmittedPromise; + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification, expectedDoorhanger; + if (oldPassword !== undefined && oldUsername !== undefined) { + expectedNotification = "addLogin"; + expectedDoorhanger = "password-save"; + } else if (oldPassword !== undefined) { + expectedNotification = "modifyLogin"; + expectedDoorhanger = "password-change"; + } else { + expectedNotification = "addLogin"; + expectedDoorhanger = "password-save"; + } + + info("Waiting for doorhanger of type: " + expectedDoorhanger); + let notif = await waitForDoorhanger(browser, expectedDoorhanger); + + // Check the actual content of the popup notification. + await checkDoorhangerUsernamePassword(username, password); + + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseLogin; + await cleanupDoorhanger(notif); // clean slate for the next test + + // Check that the values in the database match the expected values. + verifyLogins(expectOutcome); + } + ); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllUserFacingLogins(); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_password_edits.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_password_edits.js new file mode 100644 index 0000000000..804612cf53 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_password_edits.js @@ -0,0 +1,220 @@ +/** + * Test changing the password inside the doorhanger notification for passwords. + * + * We check the following cases: + * - Editing the password of a new login. + * - Editing the password of an existing login. + * - Changing both username and password to an existing login. + * - Changing the username to an existing login. + * - Editing username to an empty one and a new password. + * + * If both the username and password matches an already existing login, we should not + * update it's password, but only it's usage timestamp and count. + */ +add_task(async function test_edit_password() { + let testCases = [ + { + description: "No saved logins, update password in doorhanger", + usernameInPage: "username", + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 1, + }, + { + description: "Login is saved, update password in doorhanger", + usernameInPage: "username", + usernameInPageExists: true, + passwordInPage: "password", + passwordInStorage: "oldPassword", + passwordChangedTo: "newPassword", + timesUsed: 2, + }, + { + description: + "Change username in doorhanger to match saved login, update password in doorhanger", + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 2, + }, + { + description: + "Change username in doorhanger to match saved login, dont update password in doorhanger", + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "password", + timesUsed: 2, + checkPasswordNotUpdated: true, + }, + { + description: + "Change username and password in doorhanger to match saved empty-username login", + usernameInPage: "newUsername", + usernameChangedTo: "", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 2, + }, + ]; + + for (let testCase of testCases) { + info("Test case: " + JSON.stringify(testCase)); + // Clean state before the test case is executed. + await LoginTestUtils.clearData(); + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + + // Create the pre-existing logins when needed. + if (testCase.usernameInPageExists) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: testCase.usernameInPage, + password: testCase.passwordInStorage, + }) + ); + } + + if (testCase.usernameChangedToExists) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: testCase.usernameChangedTo, + password: testCase.passwordChangedTo, + }) + ); + } + + let formFilledPromise = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await formFilledPromise; + + // Set the form to a known state so we can expect a single PasswordEditedOrGenerated message + await initForm(browser, { + "#form-basic-username": testCase.usernameInPage, + "#form-basic-password": "", + }); + + let passwordEditedPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + info("Editing the form"); + await changeContentFormValues(browser, { + "#form-basic-password": testCase.passwordInPage, + }); + info("Waiting for passwordEditedPromise"); + await passwordEditedPromise; + + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache, we're only interested in the submit outcome + await clearMessageCache(browser); + + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + info("Submitting the form"); + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown", + event => event.target == PopupNotifications.panel + ); + await SpecialPowers.spawn(browser, [], function () { + content.document.getElementById("form-basic").submit(); + }); + await formSubmittedPromise; + + let notif = await waitForDoorhanger(browser, "any"); + Assert.ok(!notif.dismissed, "Doorhanger is not dismissed"); + await promiseShown; + + // Modify the username & password in the dialog if requested. + await updateDoorhangerInputValues({ + username: testCase.usernameChangedTo, + password: testCase.passwordChangedTo, + }); + + // We expect a modifyLogin notification if the final username used by the + // dialog exists in the logins database, otherwise an addLogin one. + let expectModifyLogin = + typeof testCase.usernameChangedTo !== "undefined" + ? testCase.usernameChangedToExists + : testCase.usernameInPageExists; + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification = expectModifyLogin + ? "modifyLogin" + : "addLogin"; + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + info("Waiting for storage changed"); + let [result] = await promiseLogin; + + // Check that the values in the database match the expected values. + let login = expectModifyLogin + ? result + .QueryInterface(Ci.nsIArray) + .queryElementAt(1, Ci.nsILoginInfo) + : result.QueryInterface(Ci.nsILoginInfo); + let meta = login.QueryInterface(Ci.nsILoginMetaInfo); + + let expectedLogin = { + username: + "usernameChangedTo" in testCase + ? testCase.usernameChangedTo + : testCase.usernameInPage, + password: + "passwordChangedTo" in testCase + ? testCase.passwordChangedTo + : testCase.passwordInPage, + timesUsed: testCase.timesUsed, + }; + // Check that the password was not updated if the user is empty + if (testCase.checkPasswordNotUpdated) { + expectedLogin.usedSince = meta.timeCreated; + expectedLogin.timeCreated = meta.timePasswordChanged; + } + verifyLogins([expectedLogin]); + } + ); + } +}); + +async function initForm(browser, formDefaults = {}) { + await ContentTask.spawn( + browser, + formDefaults, + async function (selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + content.document.querySelector(sel).value = value; + } + } + ); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_promptToChangePassword.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_promptToChangePassword.js new file mode 100644 index 0000000000..84c241e020 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_promptToChangePassword.js @@ -0,0 +1,685 @@ +/** + * Test result of different input to the promptToChangePassword doorhanger + */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const passwordInputSelector = "#form-basic-password"; +const usernameInputSelector = "#form-basic-username"; + +const availLoginsByValue = new Map(); +let savedLoginsByName; +const finalLoginsByGuid = new Map(); +let finalLogins; + +const availLogins = { + emptyXYZ: LoginTestUtils.testData.formLogin({ + username: "", + password: "xyz", + }), + bobXYZ: LoginTestUtils.testData.formLogin({ + username: "bob", + password: "xyz", + }), + bobABC: LoginTestUtils.testData.formLogin({ + username: "bob", + password: "abc", + }), +}; +availLoginsByValue.set(availLogins.emptyXYZ, "emptyXYZ"); +availLoginsByValue.set(availLogins.bobXYZ, "bobXYZ"); +availLoginsByValue.set(availLogins.bobABC, "bobABC"); + +async function showChangePasswordDoorhanger( + browser, + oldLogin, + formLogin, + { notificationType = "password-change", autoSavedLoginGuid = "" } = {} +) { + let windowGlobal = browser.browsingContext.currentWindowGlobal; + let loginManagerActor = windowGlobal.getActor("LoginManager"); + let prompter = loginManagerActor._getPrompter(browser, null); + Assert.ok( + !PopupNotifications.isPanelOpen, + "Check the doorhanger isn't already open" + ); + + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + prompter.promptToChangePassword( + browser, + oldLogin, + formLogin, + false, // dimissed prompt + false, // notifySaved + autoSavedLoginGuid + ); + await promiseShown; + + let notif = getCaptureDoorhanger(notificationType); + Assert.ok(notif, `${notificationType} notification exists`); + + let { panel } = PopupNotifications; + let notificationElement = panel.childNodes[0]; + await BrowserTestUtils.waitForCondition(() => { + return ( + notificationElement.querySelector("#password-notification-password") + .value == formLogin.password && + notificationElement.querySelector("#password-notification-username") + .value == formLogin.username + ); + }, "Wait for the notification panel to be populated"); + return notif; +} + +async function setupLogins(...logins) { + Services.logins.removeAllUserFacingLogins(); + let savedLogins = {}; + let timesCreated = new Set(); + for (let login of logins) { + let loginName = availLoginsByValue.get(login); + let savedLogin = await LoginTestUtils.addLogin(login); + // we rely on sorting by timeCreated so ensure none are identical + Assert.ok( + !timesCreated.has(savedLogin.timeCreated), + "Each login has a different timeCreated" + ); + timesCreated.add(savedLogin.timeCreated); + savedLogins[loginName || savedLogin.guid] = savedLogin.clone(); + } + return savedLogins; +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["signon.autofillForms", false]], + }); + Assert.ok(!PopupNotifications.isPanelOpen, "No notifications panel open"); +}); + +async function promptToChangePasswordTest(testData) { + info("Starting: " + testData.name); + savedLoginsByName = await setupLogins(...testData.initialSavedLogins); + await SimpleTest.promiseFocus(); + info("got focus"); + + let oldLogin = savedLoginsByName[testData.promptArgs.oldLogin]; + let changeLogin = LoginTestUtils.testData.formLogin( + testData.promptArgs.changeLogin + ); + let options; + if (testData.autoSavedLoginName) { + options = { + autoSavedLoginGuid: savedLoginsByName[testData.autoSavedLoginName].guid, + }; + } + info( + "Waiting for showChangePasswordDoorhanger, username: " + + changeLogin.username + ); + await BrowserTestUtils.withNewTab( + { + gBrowser, + TEST_ORIGIN, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + let notif = await showChangePasswordDoorhanger( + browser, + oldLogin, + changeLogin, + options + ); + + await updateDoorhangerInputValues(testData.promptTextboxValues); + + let mainActionButton = getDoorhangerButton(notif, CHANGE_BUTTON); + Assert.equal( + mainActionButton.label, + testData.expectedButtonLabel, + "Check button label" + ); + + let { panel } = PopupNotifications; + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + let storagePromise; + if (testData.expectedStorageChange) { + storagePromise = TestUtils.topicObserved("passwordmgr-storage-changed"); + } + + info("Clicking mainActionButton"); + mainActionButton.doCommand(); + info("Waiting for promiseHidden"); + await promiseHidden; + info("Waiting for storagePromise"); + await storagePromise; + + // ensure the notification was removed to keep clean state for next run + await cleanupDoorhanger(notif); + + info(testData.resultDescription); + + finalLoginsByGuid.clear(); + finalLogins = Services.logins.getAllLogins(); + finalLogins.sort((a, b) => a.timeCreated > b.timeCreated); + + for (let l of finalLogins) { + info(`saved login: ${l.guid}: ${l.username}/${l.password}`); + finalLoginsByGuid.set(l.guid, l); + } + info("verifyLogins next"); + verifyLogins(testData.expectedResultLogins); + if (testData.resultCheck) { + testData.resultCheck(); + } + } + ); +} + +let tests = [ + { + name: "Add username to sole login", + initialSavedLogins: [availLogins.emptyXYZ], + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "zaphod", + password: "xyz", + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: "The existing login just gets a new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "zaphod", + password: "xyz", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + }, + }, + { + name: "Change password of the sole login", + initialSavedLogins: [availLogins.bobXYZ], + promptArgs: { + oldLogin: "bobXYZ", + changeLogin: { + username: "bob", + password: "&*$", + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: "The existing login just gets a new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "&*$", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobXYZ.guid, + "Check guid" + ); + }, + }, + { + name: "Change password of the sole empty-username login", + initialSavedLogins: [availLogins.emptyXYZ], + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "", + password: "&*$", + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: "The existing login just gets a new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "&*$", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + }, + }, + { + name: "Add different username to empty-usernamed login", + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "alice", + password: "xyz", + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: "The existing login just gets a new username", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "alice", + password: "xyz", + }, + { + username: "bob", + password: "abc", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.emptyXYZ.timeLastUsed, + "Check timeLastUsed of 0th login" + ); + }, + }, + { + name: "Add username to autosaved login to match an existing usernamed login", + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + autoSavedLoginName: "emptyXYZ", + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "bob", + password: availLogins.emptyXYZ.password, + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: + "Empty-username login is removed, other login gets the empty-login's password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "xyz", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed changed" + ); + }, + }, + { + name: "Add username to non-autosaved login to match an existing usernamed login", + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + autoSavedLoginName: "", + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "bob", + password: availLogins.emptyXYZ.password, + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + // We can't end up with duplicates (bob:xyz and bob:ABC) so the following seems reasonable. + // We could delete the emptyXYZ but we would want to intelligently merge metadata. + resultDescription: + "Multiple login matches but user indicated they want bob:xyz in the prompt so modify bob to give that", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "xyz", + }, + { + username: "bob", + password: "xyz", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + Assert.equal( + finalLogins[0].timeLastUsed, + savedLoginsByName.emptyXYZ.timeLastUsed, + "Check timeLastUsed didn't change" + ); + Assert.equal( + finalLogins[0].timePasswordChanged, + savedLoginsByName.emptyXYZ.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + + Assert.equal( + finalLogins[1].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[1].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + Assert.ok( + finalLogins[1].timePasswordChanged > + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged did change" + ); + }, + }, + { + name: "Username & password changes to an auto-saved login apply to matching usernamed-login", + // when we update an auto-saved login - changing both username & password, is + // the matching login updated and empty-username login removed? + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + autoSavedLoginName: "emptyXYZ", + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "bob", + password: "xyz", + }, + }, + promptTextboxValues: { + // type a new password in the doorhanger + password: "newpassword", + }, + expectedButtonLabel: "Update", + resultDescription: + "The empty-username login is removed, other login gets the new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "newpassword", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + }, + }, + { + name: "Username & password changes to a non-auto-saved login matching usernamed-login", + // when we update a non-auto-saved login - changing both username & password, is + // the matching login updated and empty-username login unchanged? + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + autoSavedLoginName: "", // no auto-saved logins for this session + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "bob", + password: "xyz", + }, + }, + promptTextboxValues: { + // type a new password in the doorhanger + password: "newpassword", + }, + expectedButtonLabel: "Update", + resultDescription: + "The empty-username login is not changed, other login gets the new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "xyz", + }, + { + username: "bob", + password: "newpassword", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + Assert.equal( + finalLogins[0].timeLastUsed, + savedLoginsByName.emptyXYZ.timeLastUsed, + "Check timeLastUsed didn't change" + ); + Assert.equal( + finalLogins[0].timePasswordChanged, + savedLoginsByName.emptyXYZ.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + Assert.equal( + finalLogins[1].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[1].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + Assert.ok( + finalLogins[1].timePasswordChanged > + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged did change" + ); + }, + }, + { + name: "Remove the username and change password of autosaved login", + initialSavedLogins: [availLogins.bobABC], + autoSavedLoginName: "bobABC", + promptArgs: { + oldLogin: "bobABC", + changeLogin: { + username: "bob", + password: "abc!", // trigger change prompt with a password change + }, + }, + promptTextboxValues: { + username: "", + }, + expectedButtonLabel: "Update", + resultDescription: + "The auto-saved login is updated with new empty-username login and new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "abc!", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + Assert.ok( + finalLogins[0].timePasswordChanged > + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged did change" + ); + }, + }, + { + name: "Remove the username and change password of non-autosaved login", + initialSavedLogins: [availLogins.bobABC], + // no autosaved guid + promptArgs: { + oldLogin: "bobABC", + changeLogin: { + username: "bob", + password: "abc!", // trigger change prompt with a password change + }, + }, + promptTextboxValues: { + username: "", + }, + expectedButtonLabel: "Save", + resultDescription: + "A new empty-username login is created with the new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "abc", + }, + { + username: "", + password: "abc!", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.equal( + finalLogins[0].timeLastUsed, + savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed didn't change" + ); + Assert.equal( + finalLogins[0].timePasswordChanged, + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + }, + }, + { + name: "Remove username from the auto-saved sole login", + initialSavedLogins: [availLogins.bobABC], + autoSavedLoginName: "bobABC", + promptArgs: { + oldLogin: "bobABC", + changeLogin: { + username: "bob", + password: "abc!", // trigger change prompt with a password change + }, + }, + promptTextboxValues: { + username: "", + password: "abc", // put password back to what it was + }, + expectedButtonLabel: "Update", + resultDescription: "The existing login is updated", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "abc", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + todo_is( + finalLogins[0].timePasswordChanged, + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + }, + }, + { + name: "Remove username from the non-auto-saved sole login", + initialSavedLogins: [availLogins.bobABC], + // no autoSavedLoginGuid + promptArgs: { + oldLogin: "bobABC", + changeLogin: { + username: "bob", + password: "abc!", // trigger change prompt with a password change + }, + }, + promptTextboxValues: { + username: "", + password: "abc", // put password back to what it was + }, + expectedButtonLabel: "Save", + resultDescription: "A new empty-username login is created", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "abc", + }, + { + username: "", + password: "abc", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.equal( + finalLogins[0].timeLastUsed, + savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed didn't change" + ); + Assert.equal( + finalLogins[0].timePasswordChanged, + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + }, + }, +]; + +for (let testData of tests) { + let tmp = { + async [testData.name]() { + await promptToChangePasswordTest(testData); + }, + }; + add_task(tmp[testData.name]); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js new file mode 100644 index 0000000000..bc0b245e4d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js @@ -0,0 +1,1275 @@ +/* + * Test capture popup notifications + */ + +const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" +); +const BRAND_SHORT_NAME = BRAND_BUNDLE.GetStringFromName("brandShortName"); + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login1 = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +let login2 = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "", + "notifyp1", + "", + "pass" +); +let login1B = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "notifyu1B", + "notifyp1B", + "user", + "pass" +); +let login2B = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "", + "notifyp1B", + "", + "pass" +); + +requestLongerTimeout(2); + +add_setup(async function () { + // Load recipes for this test. + let recipeParent = await LoginManagerParent.recipeParentPromise; + await recipeParent.load({ + siteRecipes: [ + { + hosts: ["example.org"], + usernameSelector: "#user", + passwordSelector: "#pass", + }, + ], + }); +}); + +add_task(async function test_remember_opens() { + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_clickNever() { + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.ok(notif, "got notification popup"); + Assert.equal( + true, + Services.logins.getLoginSavingEnabled("http://example.com"), + "Checking for login saving enabled" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, NEVER_MENUITEM); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); + + info("Make sure Never took effect"); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + Assert.equal( + false, + Services.logins.getLoginSavingEnabled("http://example.com"), + "Checking for login saving disabled" + ); + Services.logins.setLoginSavingEnabled("http://example.com", true); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_clickRemember() { + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + let promiseNewSavedPassword = TestUtils.topicObserved( + "LoginStats:NewSavedPassword", + (subject, data) => subject == gBrowser.selectedBrowser + ); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseNewSavedPassword; + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu1", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp1", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); + + info( + "Make sure Remember took effect and we don't prompt for an existing login" + ); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + function (fieldValues) { + // form login matches a saved login, we don't expect a notification on change or submit + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username used"); + Assert.equal(login.password, "notifyp1", "Check the password used"); + Assert.equal(login.timesUsed, 2, "Check times used incremented"); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + + // remove that login + Services.logins.removeLogin(login1); + await cleanupDoorhanger(); +}); + +/* signons.rememberSignons pref tests... */ + +add_task(async function test_rememberSignonsFalse() { + info("Make sure we don't prompt with rememberSignons=false"); + Services.prefs.setBoolPref("signon.rememberSignons", false); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_rememberSignonsTrue() { + info("Make sure we prompt with rememberSignons=true"); + Services.prefs.setBoolPref("signon.rememberSignons", true); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +/* autocomplete=off tests... */ + +add_task(async function test_autocompleteOffUsername() { + info( + "Check for notification popup when autocomplete=off present on username" + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_autocompleteOffPassword() { + info( + "Check for notification popup when autocomplete=off present on password" + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_3.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_autocompleteOffForm() { + info("Check for notification popup when autocomplete=off present on form"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_4.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_noPasswordField() { + info("Check for no notification popup when no password field present"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_5.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal(fieldValues.password, "null", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_pwOnlyNewLoginMatchesUPForm() { + info("Check for update popup when new existing pw-only login matches form."); + await Services.logins.addLoginAsync(login2); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Add username to saved password?", + "Check message" + ); + + let { panel } = PopupNotifications; + let passwordVisiblityToggle = panel.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.ok( + !passwordVisiblityToggle.hidden, + "Toggle visible for a recently saved pw" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username"); + Assert.equal(login.password, "notifyp1", "Check the password"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + Services.logins.removeLogin(login); +}); + +add_task(async function test_pwOnlyOldLoginMatchesUPForm() { + info("Check for update popup when old existing pw-only login matches form."); + await Services.logins.addLoginAsync(login2); + + // Change the timePasswordChanged to be old so that the password won't be + // revealed in the doorhanger. + let oldTimeMS = new Date("2009-11-15").getTime(); + Services.logins.modifyLogin( + login2, + LoginHelper.newPropertyBag({ + timeCreated: oldTimeMS, + timeLastUsed: oldTimeMS + 1, + timePasswordChanged: oldTimeMS, + }) + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Add username to saved password?", + "Check message" + ); + + let { panel } = PopupNotifications; + let passwordVisiblityToggle = panel.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.ok( + passwordVisiblityToggle.hidden, + "Toggle hidden for an old saved pw" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username"); + Assert.equal(login.password, "notifyp1", "Check the password"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + Services.logins.removeLogin(login); +}); + +add_task(async function test_pwOnlyFormMatchesLogin() { + info( + "Check for no notification popup when pw-only form matches existing login." + ); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_6.html", + function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username"); + Assert.equal(login.password, "notifyp1", "Check the password"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + Services.logins.removeLogin(login1); +}); + +add_task(async function test_pwOnlyFormDoesntMatchExisting() { + info( + "Check for notification popup when pw-only form doesn't match existing login." + ); + await Services.logins.addLoginAsync(login1B); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_6.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1B", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1B); +}); + +add_task(async function test_changeUPLoginOnUPForm_dont() { + info("Check for change-password popup, u+p login on u+p form. (not changed)"); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_8.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update login for example.com?", + "Check message" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1); +}); + +add_task(async function test_changeUPLoginOnUPForm_remove() { + info("Check for change-password popup, u+p login on u+p form. (remove)"); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_8.html", + async function (fieldValues, browser) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update login for example.com?", + "Check message" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, REMOVE_LOGIN_MENUITEM); + + // Let the hint hide itself + const forceClosePopup = false; + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await verifyConfirmationHint(browser, forceClosePopup, "identity-icon"); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "Should have 0 logins"); +}); + +add_task(async function test_changeUPLoginOnUPForm_change() { + info("Check for change-password popup, u+p login on u+p form."); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_8.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update login for example.com?", + "Check message" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + let promiseLoginUpdateSaved = TestUtils.topicObserved( + "LoginStats:LoginUpdateSaved", + (subject, data) => subject == gBrowser.selectedBrowser + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseLoginUpdateSaved; + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(async function test_changePLoginOnUPForm() { + info("Check for change-password popup, p-only login on u+p form (empty u)."); + await Services.logins.addLoginAsync(login2); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_9.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update password for example.com?", + "Check msg" + ); + + await checkDoorhangerUsernamePassword("", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "", "Check the username unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + // no cleanup -- saved password to be used in the next test. +}); + +add_task(async function test_changePLoginOnPForm() { + info("Check for change-password popup, p-only login on p-only form."); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_10.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update password for example.com?", + "Check msg" + ); + + await checkDoorhangerUsernamePassword("", "notifyp1"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password changed"); + Assert.equal(login.timesUsed, 3, "Check times used"); + + Services.logins.removeLogin(login2); +}); + +add_task(async function test_checkUPSaveText() { + info("Check text on a user+pass notification popup"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.ok(notif, "got notification popup"); + // Check the text, which comes from the localized saveLoginMsg string. + let notificationText = notif.message; + let expectedText = "Save login for example.com?"; + Assert.equal( + notificationText, + expectedText, + "Checking text: " + notificationText + ); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_checkPSaveText() { + info("Check text on a pass-only notification popup"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_6.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.ok(notif, "got notification popup"); + // Check the text, which comes from the localized saveLoginMsgNoUser string. + let notificationText = notif.message; + let expectedText = "Save password for example.com?"; + Assert.equal( + notificationText, + expectedText, + "Checking text: " + notificationText + ); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_capture2pw0un() { + info( + "Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there are no saved logins." + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_0un.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.ok(notif, "got notification popup"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_change2pw0unExistingDifferentUP() { + info( + "Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with a username and different password." + ); + + await Services.logins.addLoginAsync(login1B); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_0un.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1B", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1B); +}); + +add_task(async function test_change2pw0unExistingDifferentP() { + info( + "Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with no username and different password." + ); + + await Services.logins.addLoginAsync(login2B); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_0un.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login2B); +}); + +add_task(async function test_change2pw0unExistingWithSameP() { + info( + "Check for no notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with a username and the same password." + ); + + await Services.logins.addLoginAsync(login2); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_0un.html", + function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-change"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 2, "Check times used incremented"); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + + Services.logins.removeLogin(login2); +}); + +add_task(async function test_changeUPLoginOnPUpdateForm() { + info("Check for change-password popup, u+p login on password update form."); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_change_p.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(async function test_recipeCaptureFields_NewLogin() { + info( + "Check that we capture the proper fields when a field recipe is in use." + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_1un_1text.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + + // Sanity check, no logins should exist yet. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "Should not have any logins yet"); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + }, + "http://example.org" + ); // The recipe is for example.org + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); +}); + +add_task(async function test_recipeCaptureFields_ExistingLogin() { + info( + "Check that we capture the proper fields when a field recipe is in use " + + "and there is a matching login" + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_1un_1text.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + }, + "http://example.org" + ); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 2, "Check times used incremented"); + + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_saveUsingEnter() { + async function testWithTextboxSelector(fieldSelector) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + info("Waiting for form submit and doorhanger interaction"); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + let notificationElement = PopupNotifications.panel.childNodes[0]; + let textbox = notificationElement.querySelector(fieldSelector); + textbox.focus(); + await EventUtils.synthesizeKey("KEY_Enter"); + } + ); + await storageChangedPromise; + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu1", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp1", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); + + Services.logins.removeAllUserFacingLogins(); + } + + await testWithTextboxSelector("#password-notification-password"); + await testWithTextboxSelector("#password-notification-username"); +}); + +add_task(async function test_noShowPasswordOnDismissal() { + info("Check for no Show Password field when the doorhanger is dismissed"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + info("Opening popup"); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + let { panel } = PopupNotifications; + + info("Hiding popup."); + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + await promiseHidden; + + info("Clicking on anchor to reshow popup."); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + await promiseShown; + + let passwordVisiblityToggle = panel.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.equal( + passwordVisiblityToggle.hidden, + true, + "Check that the Show Password field is Hidden" + ); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_showPasswordOn1stOpenOfDismissedByDefault() { + info("Show Password toggle when the doorhanger is dismissed by default"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + info("Opening popup"); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + let { panel } = PopupNotifications; + + info("Hiding popup."); + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + await promiseHidden; + + info("Clicking on anchor to reshow popup."); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + await promiseShown; + + let passwordVisiblityToggle = panel.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.equal( + passwordVisiblityToggle.hidden, + true, + "Check that the Show Password field is Hidden" + ); + await cleanupDoorhanger(notif); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_replace_dismissed_with_visible_while_opening.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_replace_dismissed_with_visible_while_opening.js new file mode 100644 index 0000000000..401e0add1b --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_replace_dismissed_with_visible_while_opening.js @@ -0,0 +1,65 @@ +/** + * Replacing a dismissed doorhanger with a visible one while it's opening. + * + * There are various races between popup notification callbacks to catch with this. + * This can happen in the real world by blurring an edited login field by clicking on the login doorhanger. + */ + +XPCOMUtils.defineLazyServiceGetter( + this, + "prompterSvc", + "@mozilla.org/login-manager/prompter;1", + Ci.nsILoginManagerPrompter +); + +add_task(async function test_replaceDismissedWithVisibleWhileOpening() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com", + }, + async function load(browser) { + info("Show a dismissed save doorhanger"); + prompterSvc.promptToSavePassword( + browser, + LoginTestUtils.testData.formLogin({}), + true, + false, + null + ); + let doorhanger = await waitForDoorhanger(browser, "password-save"); + Assert.ok(doorhanger, "Got doorhanger"); + EventUtils.synthesizeMouseAtCenter(doorhanger.anchorElement, {}); + await BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshowing" + ); + await checkDoorhangerUsernamePassword("the username", "the password"); + info( + "Replace the doorhanger with a non-dismissed one immediately after clicking to open" + ); + prompterSvc.promptToSavePassword( + browser, + LoginTestUtils.testData.formLogin({}), + true, + false, + null + ); + await Promise.race([ + BrowserTestUtils.waitForCondition(() => { + if ( + document.getElementById("password-notification-username").value != + "the username" || + document.getElementById("password-notification-password").value != + "the password" + ) { + return Promise.reject("Field changed to incorrect values"); + } + return false; + }, "See if username/password values change to incorrect values"), + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + new Promise(resolve => setTimeout(resolve, 1000)), + ]); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_save_password.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_save_password.js new file mode 100644 index 0000000000..5401ff31af --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_save_password.js @@ -0,0 +1,159 @@ +/** + * Test that the doorhanger notification for password saving is populated with + * the correct values in various password capture cases. + */ + +const testCases = [ + { + name: "No saved logins, username and password", + username: "username", + password: "password", + expectOutcome: [ + { + username: "username", + password: "password", + }, + ], + }, + { + name: "No saved logins, password with empty username", + username: "", + password: "password", + expectOutcome: [ + { + username: "", + password: "password", + }, + ], + }, + { + name: "Saved login with username, update password", + username: "username", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "username", + password: "newPassword", + }, + ], + }, + { + name: "Saved login with no username, update password", + username: "", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "", + password: "newPassword", + }, + ], + }, + { + name: "Saved login with no username, add username and different password", + oldUsername: "", + username: "username", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "", + password: "password", + }, + { + username: "username", + password: "newPassword", + }, + ], + }, +]; + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + info("testing with: " + JSON.stringify(testData)); + await test_save_change(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function test_save_change(testData) { + let { oldUsername, username, oldPassword, password, expectOutcome } = + testData; + // Add a login for the origin of the form if testing a change notification. + if (oldPassword) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: typeof oldUsername !== "undefined" ? oldUsername : username, + password: oldPassword, + }) + ); + } + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + // Update the form with credentials from the test case. + info(`update form with username: ${username}, password: ${password}`); + await changeContentFormValues(browser, { + "#form-basic-username": username, + "#form-basic-password": password, + }); + + // Submit the form with the new credentials. This will cause the doorhanger + // notification to be displayed. + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + await formSubmittedPromise; + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification, expectedDoorhanger; + if (oldPassword !== undefined && oldUsername !== undefined) { + expectedNotification = "addLogin"; + expectedDoorhanger = "password-save"; + } else if (oldPassword !== undefined) { + expectedNotification = "modifyLogin"; + expectedDoorhanger = "password-change"; + } else { + expectedNotification = "addLogin"; + expectedDoorhanger = "password-save"; + } + + info("Waiting for doorhanger of type: " + expectedDoorhanger); + let notif = await waitForDoorhanger(browser, expectedDoorhanger); + + // Check the actual content of the popup notification. + await checkDoorhangerUsernamePassword(username, password); + + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseLogin; + await cleanupDoorhanger(notif); // clean slate for the next test + + // Check that the values in the database match the expected values. + verifyLogins(expectOutcome); + } + ); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllUserFacingLogins(); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_submit_telemetry.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_submit_telemetry.js new file mode 100644 index 0000000000..97353007ac --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_submit_telemetry.js @@ -0,0 +1,387 @@ +/** + * Test that doorhanger submit telemetry is sent when the user saves/updates. + */ + +add_setup(function () { + // This test used to rely on the initial timer of + // TestUtils.waitForCondition. See bug 1695395. + // The test is perma-fail on Linux asan opt without this. + let originalWaitForCondition = TestUtils.waitForCondition; + TestUtils.waitForCondition = async function ( + condition, + msg, + interval = 100, + maxTries = 50 + ) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 100)); + + return originalWaitForCondition(condition, msg, interval, maxTries); + }; + registerCleanupFunction(function () { + TestUtils.waitForCondition = originalWaitForCondition; + }); +}); + +const PAGE_USERNAME_SELECTOR = "#form-basic-username"; +const PAGE_PASSWORD_SELECTOR = "#form-basic-password"; + +const TEST_CASES = [ + { + description: + "Saving a new login from page values without modification sends a 'no modification' event", + savedLogin: undefined, + userActions: [ + { + pageChanges: { + username: "pageUn", + password: "pagePw", + }, + doorhangerChanges: [], + }, + ], + expectedEvents: [ + { + type: "save", + ping: { + did_edit_un: "false", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: "Saving two logins sends two events", + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + typedUsername: "doorhangerUn", + }, + ], + }, + { + pageChanges: { password: "pagePw2" }, + doorhangerChanges: [ + { + typedPassword: "doorhangerPw", + }, + ], + }, + ], + expectedEvents: [ + { + type: "save", + ping: { + did_edit_un: "true", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + { + type: "update", + ping: { + did_edit_un: "false", + did_select_un: "false", + did_edit_pw: "true", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: "Updating a doorhanger password sends a 'pw updated' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + typedPassword: "doorhangerPw", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "false", + did_select_un: "false", + did_edit_pw: "true", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: + "Saving a new username with an existing password sends a 'un updated' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + typedUsername: "doorhangerUn", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "true", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + /////////////// + { + description: "selecting a saved username sends a 'not edited' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + selectUsername: "savedUn", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "false", + did_select_un: "true", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: + "typing a new username then selecting a saved username sends a 'not edited' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + typedUsername: "doorhangerTypedUn", + }, + { + selectUsername: "savedUn", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "false", + did_select_un: "true", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: + "selecting a saved username then typing a new username sends an 'edited' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + selectUsername: "savedUn", + }, + { + typedUsername: "doorhangerTypedUn", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "true", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// +]; + +for (let testData of TEST_CASES) { + let tmp = { + async [testData.description]() { + info("testing with: " + JSON.stringify(testData)); + await test_submit_telemetry(testData); + }, + }; + add_task(tmp[testData.description]); +} + +function _validateTestCase(tc) { + for (let event of tc.expectedEvents) { + Assert.ok( + !(event.ping.did_edit_un && event.ping.did_select_un), + "'did_edit_un' and 'did_select_un' can never be true at the same time" + ); + Assert.ok( + !(event.ping.did_edit_pw && event.ping.did_select_pw), + "'did_edit_pw' and 'did_select_pw' can never be true at the same time" + ); + } +} + +async function test_submit_telemetry(tc) { + if (tc.savedLogin) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: tc.savedLogin.username, + password: tc.savedLogin.password, + }) + ); + } + + let notif; + for (let userAction of tc.userActions) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + if (userAction.pageChanges) { + info( + `Update form with changes: ${JSON.stringify( + userAction.pageChanges + )}` + ); + let changeTo = {}; + if (userAction.pageChanges.username) { + changeTo[PAGE_USERNAME_SELECTOR] = userAction.pageChanges.username; + } + if (userAction.pageChanges.password) { + changeTo[PAGE_PASSWORD_SELECTOR] = userAction.pageChanges.password; + } + + await changeContentFormValues(browser, changeTo); + } + + info("Submitting form"); + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + await formSubmittedPromise; + + let saveDoorhanger = waitForDoorhanger(browser, "password-save"); + let updateDoorhanger = waitForDoorhanger(browser, "password-change"); + notif = await Promise.race([saveDoorhanger, updateDoorhanger]); + + if (PopupNotifications.panel.state !== "open") { + await BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + } + + if (userAction.doorhangerChanges) { + for (let doorhangerChange of userAction.doorhangerChanges) { + if ( + doorhangerChange.typedUsername || + doorhangerChange.typedPassword + ) { + await updateDoorhangerInputValues({ + username: doorhangerChange.typedUsername, + password: doorhangerChange.typedPassword, + }); + } + + if (doorhangerChange.selectUsername) { + await selectDoorhangerUsername(doorhangerChange.selectUsername); + } + if (doorhangerChange.selectPassword) { + await selectDoorhangerPassword(doorhangerChange.selectPassword); + } + } + } + + info("Waiting for doorhanger"); + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + } + ); + } + + let expectedEvents = tc.expectedEvents.map(expectedEvent => [ + "pwmgr", + "doorhanger_submitted", + expectedEvent.type, + null, + expectedEvent.ping, + ]); + + await LoginTestUtils.telemetry.waitForEventCount( + expectedEvents.length, + "parent", + "pwmgr", + "doorhanger_submitted" + ); + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "pwmgr", method: "doorhanger_submitted" }, + { clear: true } + ); + + // Clean up the database before the next test case is executed. + await cleanupDoorhanger(notif); + Services.logins.removeAllUserFacingLogins(); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_target_blank.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_target_blank.js new file mode 100644 index 0000000000..63ba1fa2a7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_target_blank.js @@ -0,0 +1,94 @@ +/** + * Test capture popup notifications when the login form uses target="_blank" + */ + +add_setup(async function () { + await SimpleTest.promiseFocus(window); +}); + +add_task(async function test_saveTargetBlank() { + // This test submits the form to a new tab using target="_blank". + let url = "subtst_notifications_12_target_blank.html?notifyu3|notifyp3||"; + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + let submissionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => { + info(url); + return url.includes("formsubmit.sjs"); + }, + false, + true + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://mochi.test:8888" + DIRECTORY_PATH + url, + }, + async function () { + // For now the doorhanger appears in the previous tab but it should maybe + // appear in the new tab from target="_blank"? + BrowserTestUtils.removeTab(await submissionTabPromise); + + let notif = await TestUtils.waitForCondition( + () => + getCaptureDoorhangerThatMayOpen( + "password-save", + PopupNotifications, + gBrowser.selectedBrowser + ), + "Waiting for doorhanger" + ); + Assert.ok(notif, "got notification popup"); + + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + await notifShownPromise; + await checkDoorhangerUsernamePassword("notifyu3", "notifyp3"); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (subject, data) => data != "removeLogin" + ); + + clickDoorhangerButton(notif, REMEMBER_BUTTON); + await storageChangedPromised; + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + ); + + // Check result of clicking Remember + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login now"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu3", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp3", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); + + // Check for stale values in the doorhanger after closing. + let usernameField = document.getElementById("password-notification-username"); + todo_is( + usernameField.value, + "", + "Check the username field doesn't have a stale value" + ); + let passwordField = document.getElementById("password-notification-password"); + todo_is( + passwordField.value, + "", + "Check the password field doesn't have a stale value" + ); + + // Cleanup + Services.logins.removeLogin(login); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_toggles.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_toggles.js new file mode 100644 index 0000000000..f529369522 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_toggles.js @@ -0,0 +1,478 @@ +/* eslint no-shadow:"off" */ + +const passwordInputSelector = "#form-basic-password"; +const usernameInputSelector = "#form-basic-username"; +const FORM_URL = + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons.visibilityToggle", true]], + }); +}); + +let testCases = [ + { + /* Test that the doorhanger password field shows plain or * text + * when the checkbox is checked. + */ + name: "test_toggle_password", + logins: [], + enabledPrimaryPassword: false, + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pw", + [usernameInputSelector]: "username", + }, + expected: { + initialForm: { + username: "", + password: "", + }, + passwordChangedDoorhanger: null, + submitDoorhanger: { + type: "password-save", + dismissed: false, + username: "username", + password: "pw", + toggleVisible: true, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + afterToggleClick0: { + inputType: "text", + toggleChecked: true, + }, + afterToggleClick1: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, + { + /* Test that the doorhanger password toggle checkbox is disabled + * when the primary password is set. + */ + name: "test_checkbox_disabled_if_has_primary_password", + logins: [], + enabledPrimaryPassword: true, + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pass", + [usernameInputSelector]: "username", + }, + expected: { + initialForm: { + username: "", + password: "", + }, + passwordChangedDoorhanger: null, + submitDoorhanger: { + type: "password-save", + dismissed: false, + username: "username", + password: "pass", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, + { + /* Test that the reveal password checkbox is hidden when editing the + * password of an autofilled login + */ + name: "test_edit_autofilled_password", + logins: [{ username: "username1", password: "password" }], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "password!", + }, + expected: { + initialForm: { + username: "username1", + password: "password", + }, + passwordChangedDoorhanger: { + type: "password-change", + dismissed: true, + username: "username1", + password: "password!", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + submitDoorhanger: { + type: "password-change", + dismissed: false, + username: "username1", + password: "password!", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, + { + /* Test that the reveal password checkbox is shown when editing the + * password of a login that has been autofilled and then deleted + */ + name: "test_autofilled_cleared_then_updated_password", + logins: [{ username: "username1", password: "password" }], + formDefaults: {}, + formChanges: [ + { + [passwordInputSelector]: "", + }, + { + [passwordInputSelector]: "password!", + }, + ], + expected: { + initialForm: { + username: "username1", + password: "password", + }, + passwordChangedDoorhanger: { + type: "password-change", + dismissed: true, + username: "username1", + password: "password!", + toggleVisible: true, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + submitDoorhanger: { + type: "password-change", + dismissed: false, + username: "username1", + password: "password!", + toggleVisible: true, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, + { + /* Test that the reveal password checkbox is hidden when editing the + * username of an autofilled login + */ + name: "test_edit_autofilled_username", + logins: [{ username: "username1", password: "password" }], + formDefaults: {}, + formChanges: { + [usernameInputSelector]: "username2", + }, + expected: { + initialForm: { + username: "username1", + password: "password", + }, + passwordChangedDoorhanger: { + type: "password-save", + dismissed: true, + username: "username2", + password: "password", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + submitDoorhanger: { + type: "password-save", + dismissed: false, + username: "username2", + password: "password", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, +]; + +for (let testData of testCases) { + if (testData.skip) { + info("Skipping test:", testData.name); + continue; + } + let tmp = { + async [testData.name]() { + await testDoorhangerToggles(testData); + }, + }; + add_task(tmp[testData.name]); +} + +/** + * Set initial test conditions, + * Load and populate the form, + * Submit it and verify doorhanger toggle behavior + */ +async function testDoorhangerToggles({ + logins = [], + formDefaults = {}, + formChanges = {}, + expected, + enabledPrimaryPassword, +}) { + formChanges = Array.isArray(formChanges) ? formChanges : [formChanges]; + + for (let login of logins) { + await LoginTestUtils.addLogin(login); + } + if (enabledPrimaryPassword) { + LoginTestUtils.primaryPassword.enable(); + } + let formProcessedPromise = listenForTestNotification("FormProcessed"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: FORM_URL, + }, + async function (browser) { + info(`Opened tab with url: ${FORM_URL}, waiting for focus`); + await SimpleTest.promiseFocus(browser.ownerGlobal); + info("Waiting for form-processed message"); + await formProcessedPromise; + await initForm(browser, formDefaults); + await checkForm(browser, expected.initialForm); + info("form checked"); + + // some tests check the dismissed doorhanger from editing the password + let formChanged = expected.passwordChangedDoorhanger + ? listenForTestNotification("PasswordEditedOrGenerated") + : Promise.resolve(); + for (let change of formChanges) { + await changeContentFormValues(browser, change, { + method: "paste_text", + }); + } + + await formChanged; + + if (expected.passwordChangedDoorhanger) { + let expectedDoorhanger = expected.passwordChangedDoorhanger; + info("Verifying dismissed doorhanger from password change"); + let notif = await waitForDoorhanger(browser, expectedDoorhanger.type); + Assert.ok(notif, "got notification popup"); + Assert.equal( + notif.dismissed, + expectedDoorhanger.dismissed, + "Check notification dismissed property" + ); + let { panel } = browser.ownerGlobal.PopupNotifications; + // we will open dismissed doorhanger to check panel contents + Assert.equal(panel.state, "closed", "Panel is initially closed"); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + info("Opening the doorhanger popup"); + // synthesize click on anchor as this also blurs the form field triggering + // a change event + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + await promiseShown; + await TestUtils.waitForTick(); + Assert.ok( + panel.children.length, + `Check the popup has at least one notification (${panel.children.length})` + ); + + // Check the password-changed-capture doorhanger contents & behaviour + info("Verifying the doorhanger"); + await verifyDoorhangerToggles(browser, notif, expectedDoorhanger); + await hideDoorhangerPopup(notif); + } + + if (expected.submitDoorhanger) { + let expectedDoorhanger = expected.submitDoorhanger; + let { panel } = browser.ownerGlobal.PopupNotifications; + // submit the form and wait for the doorhanger + info("Submitting the form"); + let submittedPromise = listenForTestNotification("ShowDoorhanger"); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + await submitForm(browser, "/"); + await submittedPromise; + info("Waiting for doorhanger popup to open"); + await promiseShown; + let notif = await getCaptureDoorhanger(expectedDoorhanger.type); + Assert.ok(notif, "got notification popup"); + Assert.equal( + notif.dismissed, + expectedDoorhanger.dismissed, + "Check notification dismissed property" + ); + Assert.ok( + panel.children.length, + `Check the popup has at least one notification (${panel.children.length})` + ); + + // Check the submit-capture doorhanger contents & behaviour + info("Verifying the submit doorhanger"); + await verifyDoorhangerToggles(browser, notif, expectedDoorhanger); + await cleanupDoorhanger(notif); + } + } + ); + await LoginTestUtils.clearData(); + if (enabledPrimaryPassword) { + LoginTestUtils.primaryPassword.disable(); + } + await cleanupPasswordNotifications(); +} + +// -------------------------------------------------------------------- +// Helpers + +async function verifyDoorhangerToggles(browser, notif, expected) { + let { initialToggleState, afterToggleClick0, afterToggleClick1 } = expected; + + let { panel } = browser.ownerGlobal.PopupNotifications; + let notificationElement = panel.childNodes[0]; + let passwordTextbox = notificationElement.querySelector( + "#password-notification-password" + ); + let toggleCheckbox = notificationElement.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.equal(panel.state, "open", "Panel is open"); + Assert.ok( + BrowserTestUtils.is_visible(passwordTextbox), + "The doorhanger password field is visible" + ); + + await checkDoorhangerUsernamePassword(expected.username, expected.password); + if (expected.toggleVisible) { + Assert.ok( + BrowserTestUtils.is_visible(toggleCheckbox), + "The visibility checkbox is shown" + ); + } else { + Assert.ok( + BrowserTestUtils.is_hidden(toggleCheckbox), + "The visibility checkbox is hidden" + ); + } + + if (initialToggleState) { + Assert.equal( + toggleCheckbox.checked, + initialToggleState.toggleChecked, + `Initially, toggle is ${ + initialToggleState.toggleChecked ? "checked" : "unchecked" + }` + ); + Assert.equal( + passwordTextbox.type, + initialToggleState.inputType, + `Initially, password input has type: ${initialToggleState.inputType}` + ); + } + if (afterToggleClick0) { + Assert.ok( + !toggleCheckbox.hidden, + "The checkbox shouldnt be hidden when clicking on it" + ); + info("Clicking on the visibility toggle"); + await EventUtils.synthesizeMouseAtCenter(toggleCheckbox, {}); + await TestUtils.waitForTick(); + Assert.equal( + toggleCheckbox.checked, + afterToggleClick0.toggleChecked, + `After 1st click, expect toggle to be checked? ${afterToggleClick0.toggleChecked}, actual: ${toggleCheckbox.checked}` + ); + Assert.equal( + passwordTextbox.type, + afterToggleClick0.inputType, + `After 1st click, expect password input to have type: ${afterToggleClick0.inputType}` + ); + } + if (afterToggleClick1) { + Assert.ok( + !toggleCheckbox.hidden, + "The checkbox shouldnt be hidden when clicking on it" + ); + info("Clicking on the visibility toggle again"); + await EventUtils.synthesizeMouseAtCenter(toggleCheckbox, {}); + await TestUtils.waitForTick(); + Assert.equal( + toggleCheckbox.checked, + afterToggleClick1.toggleChecked, + `After 2nd click, expect toggle to be checked? ${afterToggleClick0.toggleChecked}, actual: ${toggleCheckbox.checked}` + ); + Assert.equal( + passwordTextbox.type, + afterToggleClick1.inputType, + `After 2nd click, expect password input to have type: ${afterToggleClick1.inputType}` + ); + } +} + +async function initForm(browser, formDefaults) { + await ContentTask.spawn( + browser, + formDefaults, + async function (selectorValues = {}) { + for (let [sel, value] of Object.entries(selectorValues)) { + content.document.querySelector(sel).value = value; + } + } + ); +} + +async function checkForm(browser, expected) { + await ContentTask.spawn( + browser, + { + [passwordInputSelector]: expected.password, + [usernameInputSelector]: expected.username, + }, + async function contentCheckForm(selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + let field = content.document.querySelector(sel); + Assert.equal( + field.value, + value, + sel + " has the expected initial value" + ); + } + } + ); +} + +async function submitForm(browser, action = "") { + // Submit the form + let correctPathNamePromise = BrowserTestUtils.browserLoaded(browser); + await SpecialPowers.spawn(browser, [action], async function (actionPathname) { + let form = content.document.querySelector("form"); + if (actionPathname) { + form.action = actionPathname; + } + info("Submitting form to:" + form.action); + form.submit(); + info("Submitted the form"); + }); + await correctPathNamePromise; + await SpecialPowers.spawn(browser, [action], async actionPathname => { + let win = content; + await ContentTaskUtils.waitForCondition(() => { + return ( + win.location.pathname == actionPathname && + win.document.readyState == "complete" + ); + }, "Wait for form submission load"); + }); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_username_edits.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_username_edits.js new file mode 100644 index 0000000000..79dd92db12 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_username_edits.js @@ -0,0 +1,192 @@ +/** + * Test changing the username inside the doorhanger notification for passwords. + * + * We have to test combination of existing and non-existing logins both for + * the original one from the webpage and the final one used by the dialog. + * + * We also check switching to and from empty usernames. + */ +add_task(async function test_edit_username() { + let testCases = [ + { + usernameInPage: "username", + usernameChangedTo: "newUsername", + }, + { + usernameInPage: "username", + usernameInPageExists: true, + usernameChangedTo: "newUsername", + }, + { + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, + { + usernameInPage: "username", + usernameInPageExists: true, + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, + { + usernameInPage: "", + usernameChangedTo: "newUsername", + }, + { + usernameInPage: "newUsername", + usernameChangedTo: "", + }, + { + usernameInPage: "", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, + { + usernameInPage: "newUsername", + usernameChangedTo: "", + usernameChangedToExists: true, + }, + ]; + + for (let testCase of testCases) { + info("Test case: " + JSON.stringify(testCase)); + // Clean state before the test case is executed. + await LoginTestUtils.clearData(); + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + + // Create the pre-existing logins when needed. + if (testCase.usernameInPageExists) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: testCase.usernameInPage, + password: "old password", + }) + ); + } + + if (testCase.usernameChangedToExists) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: testCase.usernameChangedTo, + password: "old password", + }) + ); + } + + let formFilledPromise = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await formFilledPromise; + await initForm(browser, { + "#form-basic-username": testCase.usernameInPage, + }); + + let passwordEditedPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + info("Editing the form"); + await changeContentFormValues(browser, { + "#form-basic-password": "password", + }); + info("Waiting for passwordEditedPromise"); + await passwordEditedPromise; + + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache, we're only interested in the submit outcome + await clearMessageCache(browser); + + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + info("Submitting the form"); + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown", + event => event.target == PopupNotifications.panel + ); + await SpecialPowers.spawn(browser, [], async function () { + content.document.getElementById("form-basic").submit(); + }); + info("Waiting for the submit message"); + await formSubmittedPromise; + + info("Waiting for the doorhanger"); + let notif = await waitForDoorhanger(browser, "any"); + Assert.ok(!notif.dismissed, "Doorhanger is not dismissed"); + await promiseShown; + + // Modify the username in the dialog if requested. + if (testCase.usernameChangedTo !== undefined) { + await updateDoorhangerInputValues({ + username: testCase.usernameChangedTo, + }); + } + + // We expect a modifyLogin notification if the final username used by the + // dialog exists in the logins database, otherwise an addLogin one. + let expectModifyLogin = + testCase.usernameChangedTo !== undefined + ? testCase.usernameChangedToExists + : testCase.usernameInPageExists; + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification = expectModifyLogin + ? "modifyLogin" + : "addLogin"; + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + info("Waiting for storage changed"); + let [result] = await promiseLogin; + + // Check that the values in the database match the expected values. + let login = expectModifyLogin + ? result + .QueryInterface(Ci.nsIArray) + .queryElementAt(1, Ci.nsILoginInfo) + : result.QueryInterface(Ci.nsILoginInfo); + Assert.equal( + login.username, + testCase.usernameChangedTo !== undefined + ? testCase.usernameChangedTo + : testCase.usernameInPage + ); + Assert.equal(login.password, "password"); + } + ); + } +}); + +async function initForm(browser, formDefaults = {}) { + await ContentTask.spawn( + browser, + formDefaults, + async function (selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + content.document.querySelector(sel).value = value; + } + } + ); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_window_open.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_window_open.js new file mode 100644 index 0000000000..2375a3c53a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_window_open.js @@ -0,0 +1,201 @@ +/* + * Test capture popup notifications in content opened by window.open + */ + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login1 = new nsLoginInfo( + "http://mochi.test:8888", + "http://mochi.test:8888", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +let login2 = new nsLoginInfo( + "http://mochi.test:8888", + "http://mochi.test:8888", + null, + "notifyu2", + "notifyp2", + "user", + "pass" +); + +function withTestTabUntilStorageChange(aPageFile, aTaskFn) { + function storageChangedObserved(subject, data) { + // Watch for actions triggered from a doorhanger (not cleanup tasks with removeLogin) + if (data == "removeLogin") { + return false; + } + return true; + } + + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + storageChangedObserved + ); + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://mochi.test:8888" + DIRECTORY_PATH + aPageFile, + }, + async function (browser) { + Assert.ok(true, "loaded " + aPageFile); + info("running test case task"); + await aTaskFn(); + info("waiting for storage change"); + await storageChangedPromised; + } + ); +} + +add_setup(async function () { + await SimpleTest.promiseFocus(window); +}); + +add_task(async function test_saveChromeHiddenAutoClose() { + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // query arguments are: username, password, features, auto-close (delimited by '|') + let url = + "subtst_notifications_11.html?notifyu1|notifyp1|" + + "menubar=no,toolbar=no,location=no|autoclose"; + await withTestTabUntilStorageChange(url, async function () { + info("waiting for popupshown"); + await notifShownPromise; + // the popup closes and the doorhanger should appear in the opener + let popup = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(popup, "got notification popup"); + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + // Sanity check, no logins should exist yet. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "Should not have any logins yet"); + + clickDoorhangerButton(popup, REMEMBER_BUTTON); + }); + // Check result of clicking Remember + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); + Assert.equal( + login.username, + "notifyu1", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp1", + "Check the password used on the new entry" + ); +}); + +add_task(async function test_changeChromeHiddenAutoClose() { + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let url = + "subtst_notifications_11.html?notifyu1|pass2|menubar=no,toolbar=no,location=no|autoclose"; + await withTestTabUntilStorageChange(url, async function () { + info("waiting for popupshown"); + await notifShownPromise; + let popup = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(popup, "got notification popup"); + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(popup, CHANGE_BUTTON); + }); + + // Check to make sure we updated the password, timestamps and use count for + // the login being changed with this form. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username"); + Assert.equal(login.password, "pass2", "Check password changed"); + Assert.equal(login.timesUsed, 2, "check .timesUsed incremented on change"); + Assert.ok(login.timeCreated < login.timeLastUsed, "timeLastUsed bumped"); + Assert.ok( + login.timeLastUsed == login.timePasswordChanged, + "timeUsed == timeChanged" + ); + + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(async function test_saveChromeVisibleSameWindow() { + // This test actually opens a new tab in the same window with default browser settings. + let url = "subtst_notifications_11.html?notifyu2|notifyp2||"; + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await withTestTabUntilStorageChange(url, async function () { + await notifShownPromise; + let popup = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(popup, "got notification popup"); + await checkDoorhangerUsernamePassword("notifyu2", "notifyp2"); + clickDoorhangerButton(popup, REMEMBER_BUTTON); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); + + // Check result of clicking Remember + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login now"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu2", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp2", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); +}); + +add_task(async function test_changeChromeVisibleSameWindow() { + let url = "subtst_notifications_11.html?notifyu2|pass2||"; + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await withTestTabUntilStorageChange(url, async function () { + await notifShownPromise; + let popup = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(popup, "got notification popup"); + await checkDoorhangerUsernamePassword("notifyu2", "pass2"); + clickDoorhangerButton(popup, CHANGE_BUTTON); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); + + // Check to make sure we updated the password, timestamps and use count for + // the login being changed with this form. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu2", "Check the username"); + Assert.equal(login.password, "pass2", "Check password changed"); + Assert.equal(login.timesUsed, 2, "check .timesUsed incremented on change"); + Assert.ok(login.timeCreated < login.timeLastUsed, "timeLastUsed bumped"); + Assert.ok( + login.timeLastUsed == login.timePasswordChanged, + "timeUsed == timeChanged" + ); + + // cleanup + login2.password = "pass2"; + Services.logins.removeLogin(login2); + login2.password = "notifyp2"; +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_entry_point_telemetry.js b/toolkit/components/passwordmgr/test/browser/browser_entry_point_telemetry.js new file mode 100644 index 0000000000..aabf2cf63a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_entry_point_telemetry.js @@ -0,0 +1,103 @@ +const TEST_ORIGIN = "https://example.com"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons.visibilityToggle", true]], + }); + Services.telemetry.clearEvents(); +}); + +add_task(async function mainMenu_entryPoint() { + await SimpleTest.promiseFocus(); + info("mainMenu_entryPoint, got focus"); + + let mainMenu = document.getElementById("appMenu-popup"); + let target = document.getElementById("PanelUI-menu-button"); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(target), + "Main menu button should be visible." + ); + info("mainMenu_entryPoint, Main menu button is visible"); + Assert.equal( + mainMenu.state, + "closed", + `Menu panel (${mainMenu.id}) is initally closed.` + ); + + info("mainMenu_entryPoint, clicking target and waiting for popup"); + let popupshown = BrowserTestUtils.waitForEvent(mainMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(target, {}); + await popupshown; + + info("mainMenu_entryPoint, main menu popup is shown"); + Assert.equal(mainMenu.state, "open", `Menu panel (${mainMenu.id}) is open.`); + + let loginsButtonID = "appMenu-passwords-button"; + + let item = document.getElementById(loginsButtonID); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(item), + "Logins and passwords button is visible." + ); + + info("mainMenu_entryPoint, clicking on Logins and passwords button"); + let openingFunc = () => EventUtils.synthesizeMouseAtCenter(item, {}); + let passwordManager = await openPasswordManager(openingFunc); + info("mainMenu_entryPoint, password manager dialog shown"); + + await LoginTestUtils.telemetry.waitForEventCount(1); + TelemetryTestUtils.assertEvents( + [["pwmgr", "open_management", "mainmenu"]], + { + category: "pwmgr", + method: "open_management", + }, + { clear: true, process: "content" } + ); + + info("mainMenu_entryPoint, close dialog and main menu"); + await passwordManager.close(); + mainMenu.hidePopup(); +}); + +add_task(async function pageInfo_entryPoint() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN, + }, + async function (browser) { + info("pageInfo_entryPoint, opening pageinfo"); + let pageInfo = BrowserPageInfo(TEST_ORIGIN, "securityTab", {}); + await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init"); + info( + "pageInfo_entryPoint, got pageinfo, wait until password button is visible" + ); + let passwordsButton = pageInfo.document.getElementById( + "security-view-password" + ); + + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(passwordsButton), + "Show passwords button should be visible." + ); + info("pageInfo_entryPoint, clicking the show passwords button..."); + await SimpleTest.promiseFocus(pageInfo); + let openingFunc = () => + EventUtils.synthesizeMouseAtCenter(passwordsButton, {}, pageInfo); + + info("pageInfo_entryPoint, waiting for the passwords manager dialog"); + let passwordManager = await openPasswordManager(openingFunc); + + TelemetryTestUtils.assertEvents( + [["pwmgr", "open_management", "pageinfo"]], + { category: "pwmgr", method: "open_management" }, + { clear: true, process: "content" } + ); + + info("pageInfo_entryPoint, close dialog and pageInfo"); + await passwordManager.close(); + pageInfo.close(); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js b/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js new file mode 100644 index 0000000000..854f0656b8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js @@ -0,0 +1,141 @@ +"use strict"; + +const LOGIN_HOST = "https://example.com"; + +function openExceptionsDialog() { + return window.openDialog( + "chrome://browser/content/preferences/dialogs/permissions.xhtml", + "Toolkit:PasswordManagerExceptions", + "", + { + blockVisible: true, + sessionVisible: false, + allowVisible: false, + hideStatusColumn: true, + prefilledHost: "", + permissionType: "login-saving", + } + ); +} + +function countDisabledHosts(dialog) { + return dialog.document.getElementById("permissionsBox").itemCount; +} + +function promiseStorageChanged(expectedData) { + function observer(subject, data) { + return ( + data == expectedData && + subject.QueryInterface(Ci.nsISupportsString).data == LOGIN_HOST + ); + } + + return TestUtils.topicObserved("passwordmgr-storage-changed", observer); +} + +add_task(async function test_disable() { + let dialog = openExceptionsDialog(); + let promiseChanged = promiseStorageChanged("hostSavingDisabled"); + + await BrowserTestUtils.waitForEvent(dialog, "load"); + await new Promise(resolve => { + waitForFocus(resolve, dialog); + }); + Services.logins.setLoginSavingEnabled(LOGIN_HOST, false); + await promiseChanged; + Assert.equal(countDisabledHosts(dialog), 1, "Verify disabled host added"); + await BrowserTestUtils.closeWindow(dialog); +}); + +add_task(async function test_enable() { + let dialog = openExceptionsDialog(); + let promiseChanged = promiseStorageChanged("hostSavingEnabled"); + + await BrowserTestUtils.waitForEvent(dialog, "load"); + await new Promise(resolve => { + waitForFocus(resolve, dialog); + }); + Services.logins.setLoginSavingEnabled(LOGIN_HOST, true); + await promiseChanged; + Assert.equal(countDisabledHosts(dialog), 0, "Verify disabled host removed"); + await BrowserTestUtils.closeWindow(dialog); +}); + +add_task(async function test_block_button_with_enter_key() { + // Test ensures that the Enter/Return key does not activate the "Allow" button + // in the "Saved Logins" exceptions dialog + + let dialog = openExceptionsDialog(); + + await BrowserTestUtils.waitForEvent(dialog, "load"); + await new Promise(resolve => waitForFocus(resolve, dialog)); + let btnBlock = dialog.document.getElementById("btnBlock"); + let btnCookieSession = dialog.document.getElementById("btnCookieSession"); + let btnHttpsOnlyOff = dialog.document.getElementById("btnHttpsOnlyOff"); + let btnHttpsOnlyOffTmp = dialog.document.getElementById("btnHttpsOnlyOffTmp"); + let btnAllow = dialog.document.getElementById("btnAllow"); + + Assert.ok(!btnBlock.hidden, "Block button is visible"); + Assert.ok(btnCookieSession.hidden, "Cookie session button is not visible"); + Assert.ok(btnAllow.hidden, "Allow button is not visible"); + Assert.ok(btnHttpsOnlyOff.hidden, "HTTPS-Only session button is not visible"); + Assert.ok( + btnHttpsOnlyOffTmp.hidden, + "HTTPS-Only session button is not visible" + ); + Assert.ok(btnBlock.disabled, "Block button is initially disabled"); + Assert.ok( + btnCookieSession.disabled, + "Cookie session button is initially disabled" + ); + Assert.ok(btnAllow.disabled, "Allow button is initially disabled"); + Assert.ok( + btnHttpsOnlyOff.disabled, + "HTTPS-Only off-button is initially disabled" + ); + Assert.ok( + btnHttpsOnlyOffTmp.disabled, + "HTTPS-Only temporary off-button is initially disabled" + ); + + EventUtils.sendString(LOGIN_HOST, dialog); + + Assert.ok( + !btnBlock.disabled, + "Block button is enabled after entering text in the URL input" + ); + Assert.ok( + btnCookieSession.disabled, + "Cookie session button is still disabled after entering text in the URL input" + ); + Assert.ok( + btnAllow.disabled, + "Allow button is still disabled after entering text in the URL input" + ); + Assert.ok( + btnHttpsOnlyOff.disabled, + "HTTPS-Only off-button is still disabled after entering text in the URL input" + ); + Assert.ok( + btnHttpsOnlyOffTmp.disabled, + "HTTPS-Only session off-button is still disabled after entering text in the URL input" + ); + + Assert.equal( + countDisabledHosts(dialog), + 0, + "No blocked hosts should be present before hitting the Enter/Return key" + ); + EventUtils.sendKey("return", dialog); + + Assert.equal( + countDisabledHosts(dialog), + 1, + "Verify the blocked host was added" + ); + Assert.ok( + btnBlock.disabled, + "Block button is disabled after submitting to the list" + ); + await BrowserTestUtils.closeWindow(dialog); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_fileURIOrigin.js b/toolkit/components/passwordmgr/test/browser/browser_fileURIOrigin.js new file mode 100644 index 0000000000..ae8c860792 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_fileURIOrigin.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getDataFromNextSubmitMessage() { + return new Promise(resolve => { + LoginManagerParent.setListenerForTests((msg, data) => { + if (msg == "ShowDoorhanger") { + resolve(data); + } + }); + }); +} + +add_task(async function testCrossOriginFormUsesCorrectOrigin() { + const registry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( + Ci.nsIChromeRegistry + ); + + let dataPromise = getDataFromNextSubmitMessage(); + + let url = + registry.convertChromeURL(Services.io.newURI(getRootDirectory(gTestPath))) + .asciiSpec + "form_basic.html"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + await SpecialPowers.spawn(browser.browsingContext, [], () => { + let doc = content.document; + doc.getElementById("form-basic-username").setUserInput("username"); + doc.getElementById("form-basic-password").setUserInput("password"); + doc.getElementById("form-basic").submit(); + info("Submitting form"); + }); + } + ); + + let data = await dataPromise; + info("Origin retrieved from message listener"); + + Assert.equal( + data.origin, + "file://", + "Message origin should match form origin" + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_focus_before_first_DOMContentLoaded.js b/toolkit/components/passwordmgr/test/browser/browser_focus_before_first_DOMContentLoaded.js new file mode 100644 index 0000000000..783f8ea3d8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_focus_before_first_DOMContentLoaded.js @@ -0,0 +1,103 @@ +/** + * Test that autocomplete is properly attached to a username field which gets + * focused before DOMContentLoaded in a new browser and process. + */ + +"use strict"; + +add_setup(async () => { + let nsLoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + Assert.ok(nsLoginInfo != null, "nsLoginInfo constructor"); + + info("Adding two logins to get autocomplete instead of autofill"); + let login1 = new nsLoginInfo( + "https://example.com", + "https://autocomplete:8888", + null, + "tempuser1", + "temppass1" + ); + + let login2 = new nsLoginInfo( + "https://example.com", + "https://autocomplete:8888", + null, + "testuser2", + "testpass2" + ); + + await Services.logins.addLogins([login1, login2]); +}); + +add_task(async function test_autocompleteFromUsername() { + let autocompletePopup = document.getElementById("PopupAutoComplete"); + let autocompletePopupShown = BrowserTestUtils.waitForEvent( + autocompletePopup, + "popupshown" + ); + + const URL = `https://example.com${DIRECTORY_PATH}file_focus_before_DOMContentLoaded.sjs`; + + let newTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: URL, + forceNewProcess: true, + }); + + await SpecialPowers.spawn( + newTab.linkedBrowser, + [], + function checkInitialValues() { + let doc = content.document; + let uname = doc.querySelector("#uname"); + let pword = doc.querySelector("#pword"); + + Assert.ok(uname, "Username field found"); + Assert.ok(pword, "Password field found"); + + Assert.equal( + doc.activeElement, + uname, + "#uname element should be focused" + ); + Assert.equal(uname.value, "", "Checking username is empty"); + Assert.equal(pword.value, "", "Checking password is empty"); + } + ); + + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, newTab.linkedBrowser); + await autocompletePopupShown; + + let richlistbox = autocompletePopup.richlistbox; + Assert.equal( + richlistbox.localName, + "richlistbox", + "The richlistbox should be the first anonymous node" + ); + for (let i = 0; i < autocompletePopup.view.matchCount; i++) { + if ( + richlistbox.selectedItem && + richlistbox.selectedItem.textContent.includes("tempuser1") + ) { + break; + } + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, newTab.linkedBrowser); + } + + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, newTab.linkedBrowser); + + await SpecialPowers.spawn(newTab.linkedBrowser, [], function checkFill() { + let doc = content.document; + let uname = doc.querySelector("#uname"); + let pword = doc.querySelector("#pword"); + + Assert.equal(uname.value, "tempuser1", "Checking username is filled"); + Assert.equal(pword.value, "temppass1", "Checking password is filled"); + }); + + BrowserTestUtils.removeTab(newTab); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js b/toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js new file mode 100644 index 0000000000..ad76e5479c --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js @@ -0,0 +1,65 @@ +const { FormHistoryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FormHistoryTestUtils.sys.mjs" +); + +const usernameFieldName = "user"; + +async function cleanup() { + Services.prefs.clearUserPref("signon.rememberSignons"); + Services.logins.removeAllLogins(); + await FormHistoryTestUtils.clear(usernameFieldName); +} + +add_setup(async function () { + await cleanup(); +}); + +add_task( + async function test_username_not_saved_in_form_history_when_password_manager_enabled() { + Services.prefs.setBoolPref("signon.rememberSignons", true); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async () => { + const notif = await getCaptureDoorhangerThatMayOpen("password-save"); + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + } + ); + + const loginEntries = Services.logins.getAllLogins().length; + const historyEntries = await FormHistoryTestUtils.count(usernameFieldName); + + Assert.equal( + loginEntries, + 1, + "Username should be saved in password manager" + ); + + Assert.equal( + historyEntries, + 0, + "Username should not be saved in form history" + ); + await cleanup(); + } +); + +add_task( + async function test_username_saved_in_form_history_when_password_manager_disabled() { + Services.prefs.setBoolPref("signon.rememberSignons", false); + + await testSubmittingLoginFormHTTP("subtst_notifications_1.html"); + + const loginEntries = Services.logins.getAllLogins().length; + const historyEntries = await FormHistoryTestUtils.count(usernameFieldName); + + Assert.equal( + loginEntries, + 0, + "Username should not be saved in password manager" + ); + + Assert.equal(historyEntries, 1, "Username should be saved in form history"); + await cleanup(); + } +); diff --git a/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js b/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js new file mode 100644 index 0000000000..212b5f79d8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js @@ -0,0 +1,161 @@ +/* + * Test that browser chrome UI interactions don't trigger a capture doorhanger. + */ + +"use strict"; + +async function fillTestPage( + aBrowser, + username = "my_username", + password = "my_password" +) { + let notif = getCaptureDoorhanger("any", undefined, aBrowser); + Assert.ok(!notif, "No doorhangers should be present before filling the form"); + + await changeContentFormValues(aBrowser, { + "#form-basic-username": username, + "#form-basic-password": password, + }); + if (LoginHelper.passwordEditCaptureEnabled) { + // Filling the password will generate a dismissed doorhanger. + // Check and remove that before running the rest of the task + notif = await waitForDoorhanger(aBrowser, "any"); + Assert.ok(notif.dismissed, "Only a dismissed doorhanger should be present"); + await cleanupDoorhanger(notif); + } +} + +function withTestPage(aTaskFn) { + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "formless_basic.html", + }, + async function (aBrowser) { + info("tab opened"); + await fillTestPage(aBrowser); + await aTaskFn(aBrowser); + + // Give a chance for the doorhanger to appear + await new Promise(resolve => SimpleTest.executeSoon(resolve)); + let notif = getCaptureDoorhanger("any"); + Assert.ok(!notif, "No doorhanger should be present"); + await cleanupDoorhanger(notif); + } + ); +} + +add_setup(async function () { + await SimpleTest.promiseFocus(window); +}); + +add_task(async function test_urlbar_new_URL() { + await withTestPage(async function (aBrowser) { + gURLBar.value = ""; + let focusPromise = BrowserTestUtils.waitForEvent(gURLBar, "focus"); + gURLBar.focus(); + await focusPromise; + info("focused"); + EventUtils.sendString("http://mochi.test:8888/"); + EventUtils.synthesizeKey("KEY_Enter"); + await BrowserTestUtils.browserLoaded( + aBrowser, + false, + "http://mochi.test:8888/" + ); + }); +}); + +add_task(async function test_urlbar_fragment_enter() { + await withTestPage(function (aBrowser) { + gURLBar.focus(); + gURLBar.select(); + EventUtils.synthesizeKey("KEY_ArrowRight"); + EventUtils.sendString("#fragment"); + EventUtils.synthesizeKey("KEY_Enter"); + }); +}); + +add_task(async function test_backButton_forwardButton() { + await withTestPage(async function (aBrowser) { + info("Loading formless_basic.html?second"); + // Load a new page in the tab so we can test going back + BrowserTestUtils.loadURIString( + aBrowser, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html?second" + ); + await BrowserTestUtils.browserLoaded( + aBrowser, + false, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html?second" + ); + info("Loaded formless_basic.html?second"); + await fillTestPage(aBrowser, "my_username", "password_2"); + + info("formless_basic.html?second form is filled, clicking back"); + let backPromise = BrowserTestUtils.browserStopped(aBrowser); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("back-button"), + {} + ); + await backPromise; + + // Give a chance for the doorhanger to appear + await new Promise(resolve => SimpleTest.executeSoon(resolve)); + Assert.ok(!getCaptureDoorhanger("any"), "No doorhanger should be present"); + + // Now go forward again after filling + await fillTestPage(aBrowser, "my_username", "password_3"); + + let forwardButton = document.getElementById("forward-button"); + await BrowserTestUtils.waitForCondition(() => { + return !forwardButton.disabled; + }); + let forwardPromise = BrowserTestUtils.browserStopped(aBrowser); + info("click the forward button"); + EventUtils.synthesizeMouseAtCenter(forwardButton, {}); + await forwardPromise; + info("done"); + }); +}); + +add_task(async function test_reloadButton() { + await withTestPage(async function (aBrowser) { + let reloadButton = document.getElementById("reload-button"); + let loadPromise = BrowserTestUtils.browserLoaded( + aBrowser, + false, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html" + ); + + await BrowserTestUtils.waitForCondition(() => { + return !reloadButton.disabled; + }); + EventUtils.synthesizeMouseAtCenter(reloadButton, {}); + await loadPromise; + }); +}); + +add_task(async function test_back_keyboard_shortcut() { + await withTestPage(async function (aBrowser) { + // Load a new page in the tab so we can test going back + BrowserTestUtils.loadURIString( + aBrowser, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html?second" + ); + await BrowserTestUtils.browserLoaded( + aBrowser, + false, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html?second" + ); + await fillTestPage(aBrowser); + + let backPromise = BrowserTestUtils.browserStopped(aBrowser); + + const goBackKeyModifier = + AppConstants.platform == "macosx" ? { metaKey: true } : { altKey: true }; + EventUtils.synthesizeKey("KEY_ArrowLeft", goBackKeyModifier); + + await backPromise; + }); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js b/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js new file mode 100644 index 0000000000..68d5663258 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js @@ -0,0 +1,131 @@ +"use strict"; + +const WARNING_PATTERN = [ + { + key: "INSECURE_FORM_ACTION", + msg: 'JavaScript Warning: "Password fields present in a form with an insecure (http://) form action. This is a security risk that allows user login credentials to be stolen."', + }, + { + key: "INSECURE_PAGE", + msg: 'JavaScript Warning: "Password fields present on an insecure (http://) page. This is a security risk that allows user login credentials to be stolen."', + }, +]; + +add_task(async function testInsecurePasswordWarning() { + // By default, proxies don't apply to 127.0.0.1. We need them to for this test, though: + await SpecialPowers.pushPrefEnv({ + set: [["network.proxy.allow_hijacking_localhost", true]], + }); + let warningPatternHandler; + + function messageHandler(msgObj) { + function findWarningPattern(msg) { + return WARNING_PATTERN.find(patternPair => { + return msg.includes(patternPair.msg); + }); + } + + let warning = findWarningPattern(msgObj.message); + + // Only handle the insecure password related warning messages. + if (warning) { + // Prevent any unexpected or redundant matched warning message coming after + // the test case is ended. + Assert.ok( + warningPatternHandler, + "Invoke a valid warning message handler" + ); + warningPatternHandler(warning, msgObj.message); + } + } + Services.console.registerListener(messageHandler); + registerCleanupFunction(function () { + Services.console.unregisterListener(messageHandler); + }); + + for (let [origin, testFile, expectWarnings] of [ + ["http://127.0.0.1", "form_basic.html", []], + ["http://127.0.0.1", "formless_basic.html", []], + ["http://example.com", "form_basic.html", ["INSECURE_PAGE"]], + ["http://example.com", "formless_basic.html", ["INSECURE_PAGE"]], + ["https://example.com", "form_basic.html", []], + ["https://example.com", "formless_basic.html", []], + + // For a form with customized action link in the same origin. + ["http://127.0.0.1", "form_same_origin_action.html", []], + ["http://example.com", "form_same_origin_action.html", ["INSECURE_PAGE"]], + ["https://example.com", "form_same_origin_action.html", []], + + // For a form with an insecure (http) customized action link. + [ + "http://127.0.0.1", + "form_cross_origin_insecure_action.html", + ["INSECURE_FORM_ACTION"], + ], + [ + "http://example.com", + "form_cross_origin_insecure_action.html", + ["INSECURE_PAGE"], + ], + [ + "https://example.com", + "form_cross_origin_insecure_action.html", + ["INSECURE_FORM_ACTION"], + ], + + // For a form with a secure (https) customized action link. + ["http://127.0.0.1", "form_cross_origin_secure_action.html", []], + [ + "http://example.com", + "form_cross_origin_secure_action.html", + ["INSECURE_PAGE"], + ], + ["https://example.com", "form_cross_origin_secure_action.html", []], + ]) { + let testURL = origin + DIRECTORY_PATH + testFile; + let promiseConsoleMessages = new Promise(resolve => { + warningPatternHandler = function (warning, originMessage) { + Assert.ok(warning, "Handling a warning pattern"); + let fullMessage = `[${warning.msg} {file: "${testURL}" line: 0 column: 0 source: "0"}]`; + Assert.equal( + originMessage, + fullMessage, + "Message full matched:" + originMessage + ); + + let index = expectWarnings.indexOf(warning.key); + isnot( + index, + -1, + "Found warning: " + warning.key + " for URL:" + testURL + ); + if (index !== -1) { + // Remove the shown message. + expectWarnings.splice(index, 1); + } + if (expectWarnings.length === 0) { + info("All warnings are shown for URL:" + testURL); + resolve(); + } + }; + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: testURL, + }, + function () { + if (expectWarnings.length === 0) { + info("All warnings are shown for URL:" + testURL); + return Promise.resolve(); + } + return promiseConsoleMessages; + } + ); + + // Remove warningPatternHandler to stop handling the matched warning pattern + // and the task should not get any warning anymore. + warningPatternHandler = null; + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js b/toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js new file mode 100644 index 0000000000..0f256d74a1 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js @@ -0,0 +1,42 @@ +"use strict"; + +const TEST_URL = `https://example.com${DIRECTORY_PATH}form_signup_detection.html`; + +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + await SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" + ); + const loginManagerChild = new LoginManagerChild(); + const docState = loginManagerChild.stateForDocument(doc); + let isSignUpForm; + + info("Test case: Obvious signup form is detected as sign up form"); + const signUpForm = doc.getElementById("obvious-signup-form"); + isSignUpForm = docState.isProbablyASignUpForm(signUpForm); + Assert.equal(isSignUpForm, true); + + info( + "Test case: Obvious non signup form is detected as non sign up form" + ); + const loginForm = doc.getElementById("obvious-login-form"); + isSignUpForm = docState.isProbablyASignUpForm(loginForm); + Assert.equal(isSignUpForm, false); + + info( + "Test case: An HTML element is detected as non sign up form" + ); + const inputField = doc.getElementById("obvious-signup-username"); + isSignUpForm = docState.isProbablyASignUpForm(inputField); + Assert.equal(isSignUpForm, false); + }); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_localip_frame.js b/toolkit/components/passwordmgr/test/browser/browser_localip_frame.js new file mode 100644 index 0000000000..33ae0e6c0b --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_localip_frame.js @@ -0,0 +1,86 @@ +"use strict"; + +add_setup(async () => { + const login1 = LoginTestUtils.testData.formLogin({ + origin: "http://10.0.0.0", + formActionOrigin: "https://example.org", + username: "username1", + password: "password1", + }); + const login2 = LoginTestUtils.testData.formLogin({ + origin: "https://example.org", + formActionOrigin: "https://example.org", + username: "username2", + password: "password2", + }); + await Services.logins.addLogins([login1, login2]); +}); + +add_task(async function test_warningForLocalIP() { + let tests = [ + /* when the url of top-level and iframe are both ip address, do not show insecure warning */ + { + top: "http://192.168.0.0", + iframe: "http://10.0.0.0", + expected: `[originaltype="loginWithOrigin"]`, + }, + { + top: "http://192.168.0.0", + iframe: "https://example.org", + expected: `[type="insecureWarning"]`, + }, + { + top: "http://example.com", + iframe: "http://10.0.0.0", + expected: `[type="insecureWarning"]`, + }, + { + top: "http://example.com", + iframe: "http://example.org", + expected: `[type="insecureWarning"]`, + }, + ]; + + for (let test of tests) { + let urlTop = test.top + DIRECTORY_PATH + "empty.html"; + let urlIframe = + test.iframe + DIRECTORY_PATH + "insecure_test_subframe.html"; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, urlTop); + let browser = tab.linkedBrowser; + + await SpecialPowers.spawn(browser, [urlIframe], async url => { + await new content.Promise(resolve => { + let ifr = content.document.createElement("iframe"); + ifr.onload = resolve; + ifr.src = url; + content.document.body.appendChild(ifr); + }); + }); + + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + + let ifr = browser.browsingContext.children[0]; + Assert.ok(ifr, "Got iframe"); + + let popupShown = openACPopup( + popup, + tab.linkedBrowser, + "#form-basic-username", + ifr + ); + await popupShown; + + let item = popup.querySelector(test.expected); + Assert.ok(item, "Got expected richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !item.collapsed, + "Wait for autocomplete to show" + ); + + await closePopup(popup); + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_message_onFormSubmit.js b/toolkit/components/passwordmgr/test/browser/browser_message_onFormSubmit.js new file mode 100644 index 0000000000..5e611a7384 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_message_onFormSubmit.js @@ -0,0 +1,82 @@ +/** + * Test "passwordmgr-form-submission-detected" should be notified + * regardless of whehter the password saving is enabled. + */ + +async function waitForFormSubmissionDetected() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, topic) { + Services.obs.removeObserver( + observer, + "passwordmgr-form-submission-detected" + ); + resolve(); + }, "passwordmgr-form-submission-detected"); + }); +} + +add_task(async function test_login_save_disable() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await changeContentFormValues(browser, { + "#form-basic-username": "username", + "#form-basic-password": "password", + }); + + let promise = waitForFormSubmissionDetected(); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + + await promise; + Assert.ok(true, "Test completed"); + } + ); +}); + +add_task(async function test_login_save_enable() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons", true]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await changeContentFormValues(browser, { + "#form-basic-username": "username", + "#form-basic-password": "password", + }); + + // When login saving is enabled, we should receive both FormSubmit + // event and "passwordmgr-form-submission-detected" event + let p1 = waitForFormSubmissionDetected(); + let p2 = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + + await Promise.all([p1, p2]); + Assert.ok(true, "Test completed"); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_openPasswordManager.js b/toolkit/components/passwordmgr/test/browser/browser_openPasswordManager.js new file mode 100644 index 0000000000..0bb50f81c8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_openPasswordManager.js @@ -0,0 +1,161 @@ +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +add_task(async function test_noFilter() { + let openingFunc = () => + LoginHelper.openPasswordManager(window, { entryPoint: "mainmenu" }); + let passwordManager = await openPasswordManager(openingFunc); + + Assert.ok(passwordManager, "Login dialog was opened"); + await passwordManager.close(); + await TestUtils.waitForCondition(() => { + return Services.wm.getMostRecentWindow("Toolkit:PasswordManager") === null; + }, "Waiting for the password manager dialog to close"); +}); + +add_task(async function test_filter() { + // Greek IDN for example.test + let domain = "παράδειγμα.δοκιμή"; + let openingFunc = () => + LoginHelper.openPasswordManager(window, { + filterString: domain, + entryPoint: "mainmenu", + }); + let passwordManager = await openPasswordManager(openingFunc, true); + Assert.equal( + passwordManager.filterValue, + domain, + "search string to filter logins should match expectation" + ); + await passwordManager.close(); + await TestUtils.waitForCondition(() => { + return Services.wm.getMostRecentWindow("Toolkit:PasswordManager") === null; + }, "Waiting for the password manager dialog to close"); +}); + +add_task(async function test_management_noFilter() { + let tabOpenPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:logins"); + LoginHelper.openPasswordManager(window, { entryPoint: "mainmenu" }); + let tab = await tabOpenPromise; + Assert.ok(tab, "Got the new tab"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_management_filter() { + let tabOpenPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:logins?filter=%CF%80%CE%B1%CF%81%CE%AC%CE%B4%CE%B5%CE%B9%CE%B3%CE%BC%CE%B1.%CE%B4%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE" + ); + // Greek IDN for example.test + LoginHelper.openPasswordManager(window, { + filterString: "παράδειγμα.δοκιμή", + entryPoint: "mainmenu", + }); + let tab = await tabOpenPromise; + Assert.ok(tab, "Got the new tab with a domain filter"); + BrowserTestUtils.removeTab(tab); +}); + +add_task( + async function test_url_when_opening_password_manager_without_a_filterString() { + sinon.spy(window, "openTrustedLinkIn"); + const openingFunc = () => + LoginHelper.openPasswordManager(window, { + filterString: "", + entryPoint: "mainmenu", + }); + const passwordManager = await openPasswordManager(openingFunc); + + const url = window.openTrustedLinkIn.lastCall.args[0]; + + Assert.ok( + !url.includes("filter"), + "LoginHelper.openPasswordManager call without a filterString navigated to a URL with a filter query param" + ); + Assert.equal( + 0, + url.split("").filter(char => char === "&").length, + "LoginHelper.openPasswordManager call without a filterString navigated to a URL with an &" + ); + Assert.equal( + url, + "about:logins?entryPoint=mainmenu", + "LoginHelper.openPasswordManager call without a filterString navigated to an unexpected URL" + ); + + Assert.ok(passwordManager, "Login dialog was opened"); + await passwordManager.close(); + window.openTrustedLinkIn.restore(); + } +); + +add_task( + async function test_url_when_opening_password_manager_with_a_filterString() { + sinon.spy(window, "openTrustedLinkIn"); + const openingFunc = () => + LoginHelper.openPasswordManager(window, { + filterString: "testFilter", + entryPoint: "mainmenu", + }); + const passwordManager = await openPasswordManager(openingFunc); + + const url = window.openTrustedLinkIn.lastCall.args[0]; + + Assert.ok( + url.includes("filter"), + "LoginHelper.openPasswordManager call with a filterString navigated to a URL without a filter query param" + ); + Assert.equal( + 1, + url.split("").filter(char => char === "&").length, + "LoginHelper.openPasswordManager call with a filterString navigated to a URL without the correct number of '&'s" + ); + Assert.equal( + url, + "about:logins?filter=testFilter&entryPoint=mainmenu", + "LoginHelper.openPasswordManager call with a filterString navigated to an unexpected URL" + ); + + Assert.ok(passwordManager, "Login dialog was opened"); + await passwordManager.close(); + window.openTrustedLinkIn.restore(); + } +); + +add_task( + async function test_url_when_opening_password_manager_without_filterString_or_entryPoint() { + sinon.spy(window, "openTrustedLinkIn"); + const openingFunc = () => + LoginHelper.openPasswordManager(window, { + filterString: "", + entryPoint: "", + }); + const passwordManager = await openPasswordManager(openingFunc); + + const url = window.openTrustedLinkIn.lastCall.args[0]; + + Assert.ok( + !url.includes("filter"), + "LoginHelper.openPasswordManager call without a filterString navigated to a URL with a filter query param" + ); + Assert.ok( + !url.includes("entryPoint"), + "LoginHelper.openPasswordManager call without an entryPoint navigated to a URL with an entryPoint query param" + ); + Assert.equal( + 0, + url.split("").filter(char => char === "&").length, + "LoginHelper.openPasswordManager call without query params navigated to a URL that included at least one '&'" + ); + Assert.equal( + url, + "about:logins", + "LoginHelper.openPasswordManager call without a filterString or entryPoint navigated to an unexpected URL" + ); + + Assert.ok(passwordManager, "Login dialog was opened"); + await passwordManager.close(); + window.openTrustedLinkIn.restore(); + } +); diff --git a/toolkit/components/passwordmgr/test/browser/browser_preselect_login.js b/toolkit/components/passwordmgr/test/browser/browser_preselect_login.js new file mode 100644 index 0000000000..146b0ae92b --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_preselect_login.js @@ -0,0 +1,183 @@ +const TEST_ORIGIN = "https://example.org"; +const ABOUT_LOGINS_ORIGIN = "about:logins"; +const TEST_URL_PATH = `${TEST_ORIGIN}${DIRECTORY_PATH}form_basic_login.html`; + +const LOGINS_DATA = [ + { + origin: "https://aurl.com", + username: "user1", + password: "pass1", + guid: Services.uuid.generateUUID().toString(), + }, + { + origin: TEST_ORIGIN, + username: "user2", + password: "pass2", + guid: Services.uuid.generateUUID().toString(), + }, + { + origin: TEST_ORIGIN, + username: "user3", + password: "pass3", + guid: Services.uuid.generateUUID().toString(), + }, +]; + +const waitForAppMenu = async () => { + const appMenu = document.getElementById("appMenu-popup"); + const appMenuButton = document.getElementById("PanelUI-menu-button"); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(appMenuButton), + "App menu button should be visible." + ); + + let popupshown = BrowserTestUtils.waitForEvent(appMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(appMenuButton, {}); + await popupshown; + Assert.equal( + appMenu.state, + "open", + `Menu panel (${appMenu.id}) should be visible` + ); +}; + +const isExpectedLoginItemSelected = async ({ expectedGuid }) => { + const loginList = content.document.querySelector("login-list").shadowRoot; + + await ContentTaskUtils.waitForCondition( + () => + loginList.querySelector("li[aria-selected='true']")?.dataset?.guid === + expectedGuid, + "Wait for login item to be selected" + ); + + Assert.equal( + loginList.querySelector("li[aria-selected='true']")?.dataset?.guid, + expectedGuid, + "Expected login is preselected" + ); +}; + +add_setup(async () => { + await Services.logins.addLogins( + LOGINS_DATA.map(login => LoginTestUtils.testData.formLogin(login)) + ); +}); + +add_task(async function test_about_logins_defaults_to_first_item() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins#random-guid", + }, + async function (gBrowser) { + await SpecialPowers.spawn( + gBrowser, + [{ expectedGuid: LOGINS_DATA[0].guid }], + isExpectedLoginItemSelected + ); + + Assert.ok( + true, + "First item of the list is selected when random hash is supplied." + ); + } + ); +}); + +add_task( + async function test_gear_icon_opens_about_logins_with_preselected_login() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + + await openACPopup(popup, browser, "#form-basic-username"); + + const secondLoginItem = popup.firstChild.getItemAtIndex(1); + const secondLoginItemSettingsIcon = secondLoginItem.querySelector( + ".ac-settings-button" + ); + + Assert.ok( + !secondLoginItemSettingsIcon.checkVisibility({ + checkVisibilityCSS: true, + }), + "Gear icon should not be visible initially" + ); + + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + + await BrowserTestUtils.waitForCondition( + () => secondLoginItem.attributes.selected, + "Wait for second login to become active" + ); + + Assert.ok( + secondLoginItemSettingsIcon.checkVisibility({ + checkVisibilityCSS: true, + }), + "Gear icon should be visible when login item is active" + ); + + const aboutLoginsTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => url.includes(ABOUT_LOGINS_ORIGIN), + true + ); + + EventUtils.synthesizeMouseAtCenter(secondLoginItemSettingsIcon, {}); + const aboutLoginsTab = await aboutLoginsTabPromise; + + await SpecialPowers.spawn( + aboutLoginsTab.linkedBrowser, + [{ expectedGuid: LOGINS_DATA[2].guid }], + isExpectedLoginItemSelected + ); + + await closePopup(popup); + gBrowser.removeTab(aboutLoginsTab); + } + ); + } +); + +add_task( + async function test_password_menu_opens_about_logins_with_preselected_login() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await waitForAppMenu(); + + const appMenuPasswordsButton = document.getElementById( + "appMenu-passwords-button" + ); + + const aboutLoginsTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => url.includes(ABOUT_LOGINS_ORIGIN), + true + ); + + EventUtils.synthesizeMouseAtCenter(appMenuPasswordsButton, {}); + + const aboutLoginsTab = await aboutLoginsTabPromise; + + await SpecialPowers.spawn( + aboutLoginsTab.linkedBrowser, + [{ expectedGuid: LOGINS_DATA[1].guid }], + isExpectedLoginItemSelected + ); + + gBrowser.removeTab(aboutLoginsTab); + } + ); + } +); diff --git a/toolkit/components/passwordmgr/test/browser/browser_private_window.js b/toolkit/components/passwordmgr/test/browser/browser_private_window.js new file mode 100644 index 0000000000..513549fbbe --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_private_window.js @@ -0,0 +1,954 @@ +"use strict"; + +async function focusWindow(win) { + if (Services.focus.activeWindow == win) { + return; + } + let promise = new Promise(resolve => { + win.addEventListener( + "focus", + function () { + resolve(); + }, + { capture: true, once: true } + ); + }); + win.focus(); + await promise; +} + +function getDialogDoc() { + // Trudge through all the open windows, until we find the one + // that has either commonDialog.xhtml or selectDialog.xhtml loaded. + // var enumerator = Services.wm.getEnumerator("navigator:browser"); + for (let { docShell } of Services.wm.getEnumerator(null)) { + var containedDocShells = docShell.getAllDocShellsInSubtree( + docShell.typeChrome, + docShell.ENUMERATE_FORWARDS + ); + for (let childDocShell of containedDocShells) { + // Get the corresponding document for this docshell + // We don't want it if it's not done loading. + if (childDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) { + continue; + } + var childDoc = childDocShell.contentViewer.DOMDocument; + if ( + childDoc.location.href != + "chrome://global/content/commonDialog.xhtml" && + childDoc.location.href != "chrome://global/content/selectDialog.xhtml" + ) { + continue; + } + + // We're expecting the dialog to be focused. If it's not yet, try later. + // (In particular, this is needed on Linux to reliably check focused elements.) + if (Services.focus.focusedWindow != childDoc.defaultView) { + continue; + } + + return childDoc; + } + } + + return null; +} + +async function waitForAuthPrompt() { + let promptDoc = await TestUtils.waitForCondition(() => { + return getAuthPrompt(); + }); + info("Got prompt: " + promptDoc); + return promptDoc; +} + +function getAuthPrompt() { + let doc = getDialogDoc(); + if (!doc) { + return false; // try again in a bit + } + return doc; +} + +async function loadAccessRestrictedURL(browser, url, username, password) { + let browserLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, url); + + // Wait for the auth prompt, enter the login details and close the prompt + await PromptTestUtils.handleNextPrompt( + browser, + { modalType: authPromptModalType, promptType: "promptUserAndPass" }, + { buttonNumClick: 0, loginInput: username, passwordInput: password } + ); + + await SimpleTest.promiseFocus(browser.ownerGlobal); + await browserLoaded; +} + +const PRIVATE_BROWSING_CAPTURE_PREF = "signon.privateBrowsingCapture.enabled"; +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +const form1Url = `https://example.com/${DIRECTORY_PATH}subtst_privbrowsing_1.html`; +const form2Url = `https://example.com/${DIRECTORY_PATH}form_password_change.html`; +const authUrl = `https://example.com/${DIRECTORY_PATH}authenticate.sjs`; + +let normalWin; +let privateWin; +let authPromptModalType; + +// XXX: Note that tasks are currently run in sequence. Some tests may assume the state +// resulting from successful or unsuccessful logins in previous tasks + +add_task(async function test_setup() { + authPromptModalType = Services.prefs.getIntPref("prompts.modalType.httpAuth"); + normalWin = await BrowserTestUtils.openNewBrowserWindow({ private: false }); + privateWin = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_normal_popup_notification_1() { + info("test 1: run outside of private mode, popup notification should appear"); + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); +}); + +add_task(async function test_private_popup_notification_2() { + info( + "test 2: run inside of private mode, dismissed popup notification should appear" + ); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Assert.ok( + capturePrefValue, + `Expect ${PRIVATE_BROWSING_CAPTURE_PREF} to default to true` + ); + + // clear existing logins for parity with the previous test + Services.logins.removeAllUserFacingLogins(); + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + Assert.ok(notif, "Expected notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => notif.dismissed, + "notification should be dismissed" + ); + + let { panel } = privateWin.PopupNotifications; + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + await promiseShown; + + let notificationElement = panel.childNodes[0]; + let toggleCheckbox = notificationElement.querySelector( + "#password-notification-visibilityToggle" + ); + + Assert.ok( + !toggleCheckbox.hidden, + "Toggle should be visible upon 1st opening" + ); + + info("Hiding popup."); + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + await promiseHidden; + + info("Clicking on anchor to reshow popup."); + promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + await promiseShown; + + Assert.ok( + toggleCheckbox.hidden, + "Toggle should be hidden upon 2nd opening" + ); + + await cleanupDoorhanger(notif); + } + } + ); + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "No logins were saved" + ); +}); + +add_task(async function test_private_popup_notification_no_capture_pref_2b() { + info( + "test 2b: run inside of private mode, with capture pref off," + + "popup notification should not appear" + ); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Services.prefs.setBoolPref(PRIVATE_BROWSING_CAPTURE_PREF, false); + + // clear existing logins for parity with the previous test + Services.logins.removeAllUserFacingLogins(); + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + // restore the pref to its original value + Services.prefs.setBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF, + capturePrefValue + ); + + Assert.ok(!notif, "Expected no notification popup"); + if (notif) { + await cleanupDoorhanger(notif); + } + } + ); + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "No logins were saved" + ); +}); + +add_task(async function test_normal_popup_notification_3() { + info( + "test 3: run with a login, outside of private mode, " + + "match existing username/password: no popup notification should appear" + ); + + Services.logins.removeAllUserFacingLogins(); + await Services.logins.addLoginAsync(login); + let allLogins = Services.logins.getAllLogins(); + // Sanity check the HTTP login exists. + Assert.equal(allLogins.length, 1, "Should have the HTTP login"); + let timeLastUsed = allLogins[0].timeLastUsed; + let loginGuid = allLogins[0].guid; + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger("any", PopupNotifications, browser); + Assert.ok(!notif, "got no notification popup"); + if (notif) { + await cleanupDoorhanger(notif); + } + } + ); + allLogins = Services.logins.getAllLogins(); + Assert.equal( + allLogins[0].guid, + loginGuid, + "Sanity-check we are comparing the same login record" + ); + Assert.ok( + allLogins[0].timeLastUsed > timeLastUsed, + "The timeLastUsed timestamp has been updated" + ); +}); + +add_task(async function test_private_popup_notification_3b() { + info( + "test 3b: run with a login, in private mode," + + " match existing username/password: no popup notification should appear" + ); + + Services.logins.removeAllUserFacingLogins(); + await Services.logins.addLoginAsync(login); + let allLogins = Services.logins.getAllLogins(); + // Sanity check the HTTP login exists. + Assert.equal(allLogins.length, 1, "Should have the HTTP login"); + let timeLastUsed = allLogins[0].timeLastUsed; + let loginGuid = allLogins[0].guid; + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger("any", PopupNotifications, browser); + + Assert.ok(!notif, "got no notification popup"); + if (notif) { + await cleanupDoorhanger(notif); + } + } + ); + allLogins = Services.logins.getAllLogins(); + Assert.equal( + allLogins[0].guid, + loginGuid, + "Sanity-check we are comparing the same login record" + ); + Assert.equal( + allLogins[0].timeLastUsed, + timeLastUsed, + "The timeLastUsed timestamp has not been updated" + ); +}); + +add_task(async function test_normal_new_password_4() { + info( + "test 4: run with a login, outside of private mode," + + " add a new password: popup notification should appear" + ); + Services.logins.removeAllUserFacingLogins(); + await Services.logins.addLoginAsync(login); + let allLogins = Services.logins.getAllLogins(); + // Sanity check the HTTP login exists. + Assert.equal(allLogins.length, 1, "Should have the HTTP login"); + let timeLastUsed = allLogins[0].timeLastUsed; + let loginGuid = allLogins[0].guid; + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form2Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#pass": "notifyp1", + "#newpass": "notifyp2", + } + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger( + "password-change", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); + // We put up a doorhanger, but didn't interact with it, so we expect the login timestamps + // to be unchanged + allLogins = Services.logins.getAllLogins(); + Assert.equal( + allLogins[0].guid, + loginGuid, + "Sanity-check we are comparing the same login record" + ); + Assert.equal( + allLogins[0].timeLastUsed, + timeLastUsed, + "The timeLastUsed timestamp was not updated" + ); +}); + +add_task(async function test_private_new_password_5() { + info( + "test 5: run with a login, in private mode," + + "add a new password: popup notification should appear" + ); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Assert.ok( + capturePrefValue, + `Expect ${PRIVATE_BROWSING_CAPTURE_PREF} to default to true` + ); + + let allLogins = Services.logins.getAllLogins(); + // Sanity check the HTTP login exists. + Assert.equal(allLogins.length, 1, "Should have the HTTP login"); + let timeLastUsed = allLogins[0].timeLastUsed; + let loginGuid = allLogins[0].guid; + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form2Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#pass": "notifyp1", + "#newpass": "notifyp2", + } + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger( + "password-change", + PopupNotifications, + browser + ); + Assert.ok(notif, "Expected notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); + // We put up a doorhanger, but didn't interact with it, so we expect the login timestamps + // to be unchanged + allLogins = Services.logins.getAllLogins(); + Assert.equal( + allLogins[0].guid, + loginGuid, + "Sanity-check we are comparing the same login record" + ); + Assert.equal( + allLogins[0].timeLastUsed, + timeLastUsed, + "The timeLastUsed timestamp has not been updated" + ); +}); + +add_task(async function test_normal_with_login_6() { + info( + "test 6: run with a login, outside of private mode, " + + "submit with an existing password (from test 4): popup notification should appear" + ); + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form2Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#pass": "notifyp1", + "#newpass": "notifyp2", + } + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger( + "password-change", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + Services.logins.removeLogin(login); + } + ); +}); + +add_task(async function test_normal_autofilled_7() { + info("test 7: verify that the user/pass pair was autofilled"); + await Services.logins.addLoginAsync(login); + + // Sanity check the HTTP login exists. + Assert.equal( + Services.logins.getAllLogins().length, + 1, + "Should have the HTTP login" + ); + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: "about:blank", + }, + async function (browser) { + // Add the observer before loading the form page + let formFilled = listenForTestNotification("FormProcessed"); + await SimpleTest.promiseFocus(browser.ownerGlobal); + BrowserTestUtils.loadURIString(browser, form1Url); + await formFilled; + + // the form should have been autofilled, so submit without updating field values + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + {} + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + } + ); +}); + +add_task(async function test_private_not_autofilled_8() { + info("test 8: verify that the user/pass pair was not autofilled"); + // Sanity check the HTTP login exists. + Assert.equal( + Services.logins.getAllLogins().length, + 1, + "Should have the HTTP login" + ); + + let formFilled = listenForTestNotification("FormProcessed"); + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form1Url, + }, + async function (browser) { + await formFilled; + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + {} + ); + Assert.ok(!fieldValues.username, "Checking submitted username"); + Assert.ok(!fieldValues.password, "Checking submitted password"); + } + ); +}); + +// Disabled for Bug 1523777 +// add_task(async function test_private_autocomplete_9() { +// info("test 9: verify that the user/pass pair was available for autocomplete"); +// // Sanity check the HTTP login exists. +// Assert.equal(Services.logins.getAllLogins().length, 1, "Should have the HTTP login"); + +// await focusWindow(privateWin); +// await BrowserTestUtils.withNewTab({ +// gBrowser: privateWin.gBrowser, +// url: form1Url, +// }, async function(browser) { +// let popup = document.getElementById("PopupAutoComplete"); +// Assert.ok(popup, "Got popup"); + +// let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + +// // focus the user field. This should trigger the autocomplete menu +// await ContentTask.spawn(browser, null, async function() { +// content.document.getElementById("user").focus(); +// }); +// await promiseShown; +// Assert.ok(promiseShown, "autocomplete shown"); + +// let promiseFormInput = ContentTask.spawn(browser, null, async function() { +// let doc = content.document; +// await new Promise(resolve => { +// doc.getElementById("form").addEventListener("input", resolve, { once: true }); +// }); +// }); +// info("sending keys"); +// // select the item and hit enter to fill the form +// await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); +// await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); +// await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); +// await promiseFormInput; + +// let fieldValues = await submitFormAndGetResults(browser, "formsubmit.sjs", {}); +// Assert.equal(fieldValues.username, "notifyu1", "Checking submitted username"); +// Assert.equal(fieldValues.password, "notifyp1", "Checking submitted password"); +// }); +// }); + +add_task(async function test_normal_autofilled_10() { + info( + "test 10: verify that the user/pass pair does get autofilled in non-private window" + ); + // Sanity check the HTTP login exists. + Assert.equal( + Services.logins.getAllLogins().length, + 1, + "Should have the HTTP login" + ); + + let formFilled = listenForTestNotification("FormProcessed"); + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form1Url, + }, + async function (browser) { + await formFilled; + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + {} + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + } + ); +}); + +add_task(async function test_normal_http_basic_auth() { + info( + "test normal/basic-auth: verify that we get a doorhanger after basic-auth login" + ); + Services.logins.removeAllUserFacingLogins(); + clearHttpAuths(); + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: "https://example.com", + }, + async function (browser) { + await loadAccessRestrictedURL(browser, authUrl, "test", "testpass"); + Assert.ok(true, "Auth-required page loaded"); + + // verify result in the response document + let fieldValues = await SpecialPowers.spawn( + browser, + [[]], + async function () { + let username = content.document.getElementById("user").textContent; + let password = content.document.getElementById("pass").textContent; + let ok = content.document.getElementById("ok").textContent; + return { + username, + password, + ok, + }; + } + ); + Assert.equal(fieldValues.ok, "PASS", "Checking authorization passed"); + Assert.equal( + fieldValues.username, + "test", + "Checking authorized username" + ); + Assert.equal( + fieldValues.password, + "testpass", + "Checking authorized password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); +}); + +add_task(async function test_private_http_basic_auth() { + info( + "test private/basic-auth: verify that we don't get a doorhanger after basic-auth login" + ); + Services.logins.removeAllUserFacingLogins(); + clearHttpAuths(); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Assert.ok( + capturePrefValue, + `Expect ${PRIVATE_BROWSING_CAPTURE_PREF} to default to true` + ); + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: "https://example.com", + }, + async function (browser) { + await loadAccessRestrictedURL(browser, authUrl, "test", "testpass"); + + let fieldValues = await getFormSubmitResponseResult( + browser, + "authenticate.sjs" + ); + Assert.equal( + fieldValues.username, + "test", + "Checking authorized username" + ); + Assert.equal( + fieldValues.password, + "testpass", + "Checking authorized password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => notif.dismissed, + "notification should be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); +}); + +add_task(async function test_private_http_basic_auth_no_capture_pref() { + info( + "test private/basic-auth: verify that we don't get a doorhanger after basic-auth login" + + "with capture pref off" + ); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Services.prefs.setBoolPref(PRIVATE_BROWSING_CAPTURE_PREF, false); + + Services.logins.removeAllUserFacingLogins(); + clearHttpAuths(); + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: "https://example.com", + }, + async function (browser) { + await loadAccessRestrictedURL(browser, authUrl, "test", "testpass"); + + let fieldValues = await getFormSubmitResponseResult( + browser, + "authenticate.sjs" + ); + Assert.equal( + fieldValues.username, + "test", + "Checking authorized username" + ); + Assert.equal( + fieldValues.password, + "testpass", + "Checking authorized password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + // restore the pref to its original value + Services.prefs.setBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF, + capturePrefValue + ); + + Assert.ok(!notif, "got no notification popup"); + if (notif) { + await cleanupDoorhanger(notif); + } + } + ); +}); + +add_task(async function test_cleanup() { + await BrowserTestUtils.closeWindow(normalWin); + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_proxyAuth_prompt.js b/toolkit/components/passwordmgr/test/browser/browser_proxyAuth_prompt.js new file mode 100644 index 0000000000..478f204581 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_proxyAuth_prompt.js @@ -0,0 +1,182 @@ +/* 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"; + +let proxyChannel; + +function initProxy() { + return new Promise(resolve => { + let proxyChannel; + + let proxyCallback = { + QueryInterface: ChromeUtils.generateQI(["nsIProtocolProxyCallback"]), + + onProxyAvailable(req, uri, pi, status) { + class ProxyChannelListener { + onStartRequest(request) { + resolve(proxyChannel); + } + onStopRequest(request, status) {} + } + // I'm cheating a bit here... We should probably do some magic foo to get + // something implementing nsIProxiedProtocolHandler and then call + // NewProxiedChannel(), so we have something that's definately a proxied + // channel. But Mochitests use a proxy for a number of hosts, so just + // requesting a normal channel will give us a channel that's proxied. + // The proxyChannel needs to move to at least on-modify-request to + // have valid ProxyInfo, but we use OnStartRequest during startup() + // for simplicity. + proxyChannel = Services.io.newChannel( + "http://mochi.test:8888", + null, + null, + null, // aLoadingNode + Services.scriptSecurityManager.getSystemPrincipal(), + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + proxyChannel.asyncOpen(new ProxyChannelListener()); + }, + }; + + // Need to allow for arbitrary network servers defined in PAC instead of a hardcoded moz-proxy. + let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService(); + + let channel = Services.io.newChannel( + "https://example.com", + null, + null, + null, // aLoadingNode + Services.scriptSecurityManager.getSystemPrincipal(), + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + pps.asyncResolve(channel, 0, proxyCallback); + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + // This test relies on tab auth prompts. + set: [["prompts.modalType.httpAuth", Services.prompt.MODAL_TYPE_TAB]], + }); + proxyChannel = await initProxy(); +}); + +/** + * Create an object for consuming an nsIAuthPromptCallback. + * @returns result + * @returns {nsIAuthPromptCallback} result.callback - Callback to be passed into + * asyncPromptAuth. + * @returns {Promise} result.promise - Promise which resolves with authInfo once + * the callback has been called. + */ +function getAuthPromptCallback() { + let callbackResolver; + let promise = new Promise(resolve => { + callbackResolver = resolve; + }); + let callback = { + onAuthAvailable(context, authInfo) { + callbackResolver(authInfo); + }, + }; + return { callback, promise }; +} + +/** + * Tests that if a window proxy auth prompt is open, subsequent auth calls with + * matching realm will be merged into the existing prompt. This should work even + * if the follwing auth call has browser parent. + */ +add_task(async function testProxyAuthPromptMerge() { + let tabA = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + let tabB = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.org" + ); + + const promptFac = Cc[ + "@mozilla.org/passwordmanager/authpromptfactory;1" + ].getService(Ci.nsIPromptFactory); + let prompter = promptFac.getPrompt(window, Ci.nsIAuthPrompt2); + + let level = Ci.nsIAuthPrompt2.LEVEL_NONE; + let proxyAuthinfo = { + username: "", + password: "", + domain: "", + flags: Ci.nsIAuthInformation.AUTH_PROXY, + authenticationScheme: "basic", + realm: "", + }; + + // The next prompt call will result in window prompt, because the prompt does + // not have a browser set yet. The browser is used as a parent for tab + // prompts. + let promptOpened = PromptTestUtils.waitForPrompt(null, { + modalType: Services.prompt.MODAL_TYPE_WINDOW, + }); + let cbWinPrompt = getAuthPromptCallback(); + info("asyncPromptAuth no parent"); + prompter.asyncPromptAuth( + proxyChannel, + cbWinPrompt.callback, + null, + level, + proxyAuthinfo + ); + let prompt = await promptOpened; + + // Set a browser so the next prompt would open as tab prompt. + prompter.QueryInterface(Ci.nsILoginManagerAuthPrompter).browser = + tabA.linkedBrowser; + + // Since we already have an open window prompts, subsequent calls with + // matching realm should be merged into this prompt and no additional prompts + // must be spawned. + let cbNoPrompt = getAuthPromptCallback(); + info("asyncPromptAuth tabA parent"); + prompter.asyncPromptAuth( + proxyChannel, + cbNoPrompt.callback, + null, + level, + proxyAuthinfo + ); + + // Switch to the next tabs browser. + prompter.QueryInterface(Ci.nsILoginManagerAuthPrompter).browser = + tabB.linkedBrowser; + + let cbNoPrompt2 = getAuthPromptCallback(); + info("asyncPromptAuth tabB parent"); + prompter.asyncPromptAuth( + proxyChannel, + cbNoPrompt2.callback, + null, + level, + proxyAuthinfo + ); + + // Accept the prompt. + PromptTestUtils.handlePrompt(prompt, {}); + + // Accepting the window prompts should complete all auth requests. + let authInfo1 = await cbWinPrompt.promise; + Assert.ok(authInfo1, "Received callback from first proxy auth call."); + let authInfo2 = await cbNoPrompt.promise; + Assert.ok(authInfo2, "Received callback from second proxy auth call."); + let authInfo3 = await cbNoPrompt2.promise; + Assert.ok(authInfo3, "Received callback from third proxy auth call."); + + BrowserTestUtils.removeTab(tabA); + BrowserTestUtils.removeTab(tabB); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js b/toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js new file mode 100644 index 0000000000..4a6cee3b43 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js @@ -0,0 +1,514 @@ +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { FirefoxRelayTelemetry } = ChromeUtils.importESModule( + "resource://gre/modules/FirefoxRelayTelemetry.mjs" +); + +const gFxAccounts = getFxAccountsSingleton(); +let gRelayACOptionsTitles; +let gHttpServer; + +const TEST_URL_PATH = `https://example.org${DIRECTORY_PATH}form_basic_signup.html`; + +const MOCK_MASKS = [ + { + full_address: "email1@mozilla.com", + description: "Email 1 Description", + enabled: true, + }, + { + full_address: "email2@mozilla.com", + description: "Email 2 Description", + enabled: false, + }, + { + full_address: "email3@mozilla.com", + description: "Email 3 Description", + enabled: true, + }, +]; + +const SERVER_SCENARIOS = { + free_tier_limit: { + "/relayaddresses/": { + POST: (request, response) => { + response.setStatusLine(request.httpVersion, 403); + response.write(JSON.stringify({ error_code: "free_tier_limit" })); + }, + GET: (_, response) => { + response.write(JSON.stringify(MOCK_MASKS)); + }, + }, + }, + unknown_error: { + "/relayaddresses/": { + default: (request, response) => { + response.setStatusLine(request.httpVersion, 408); + }, + }, + }, + + default: { + default: (request, response) => { + response.setStatusLine(request.httpVersion, 200); + response.write(JSON.stringify({ foo: "bar" })); + }, + }, +}; + +const simpleRouter = scenarioName => (request, response) => { + const routeHandler = + SERVER_SCENARIOS[scenarioName][request._path] ?? SERVER_SCENARIOS.default; + const methodHandler = + routeHandler?.[request._method] ?? + routeHandler.default ?? + SERVER_SCENARIOS.default.default; + methodHandler(request, response); +}; +const setupServerScenario = (scenarioName = "default") => + gHttpServer.registerPrefixHandler("/", simpleRouter(scenarioName)); + +const setupRelayScenario = async scenarioName => { + await SpecialPowers.pushPrefEnv({ + set: [["signon.firefoxRelay.feature", scenarioName]], + }); + Services.telemetry.clearEvents(); +}; + +const waitForEvents = async expectedEvents => + TestUtils.waitForCondition( + () => { + const snapshots = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + + return (snapshots.parent?.length ?? 0) >= (expectedEvents.length ?? 0); + }, + "Wait for telemetry to be collected", + 100, + 100 + ); + +async function assertEvents(expectedEvents) { + // To avoid intermittent failures, we wait for telemetry to be collected + await waitForEvents(expectedEvents); + const events = TelemetryTestUtils.getEvents( + { category: "relay_integration" }, + { process: "parent" } + ); + + for (let i = 0; i < expectedEvents.length; i++) { + const keysInExpectedEvent = Object.keys(expectedEvents[i]); + keysInExpectedEvent.forEach(key => { + const assertFn = + typeof events[i][key] === "object" + ? Assert.deepEqual.bind(Assert) + : Assert.equal.bind(Assert); + assertFn( + events[i][key], + expectedEvents[i][key], + `Key value for ${key} should match` + ); + }); + } +} + +async function openRelayAC(browser) { + // In rare cases, especially in chaos mode in verify tests, some events creep in. + // Clear them out before we start. + Services.telemetry.clearEvents(); + const popup = document.getElementById("PopupAutoComplete"); + await openACPopup(popup, browser, "#form-basic-username"); + const popupItem = document + .querySelector("richlistitem") + .getAttribute("ac-label"); + const popupItemTitle = JSON.parse(popupItem).title; + + Assert.ok( + gRelayACOptionsTitles.some(title => title.value === popupItemTitle), + "AC Popup has an item Relay option shown in popup" + ); + + const promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.firstChild.getItemAtIndex(0).click(); + await promiseHidden; +} + +add_setup(async function () { + gHttpServer = new HttpServer(); + setupServerScenario(); + + gHttpServer.start(-1); + + const API_ENDPOINT = `http://localhost:${gHttpServer.identity.primaryPort}/`; + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.firefoxRelay.feature", "available"], + ["signon.firefoxRelay.base_url", API_ENDPOINT], + ], + }); + + sinon.stub(gFxAccounts, "hasLocalSession").returns(true); + sinon + .stub(gFxAccounts.constructor.config, "isProductionConfig") + .returns(true); + sinon.stub(gFxAccounts, "getOAuthToken").returns("MOCK_TOKEN"); + sinon.stub(gFxAccounts, "getSignedInUser").returns({ + email: "example@mozilla.com", + }); + + const canRecordExtendedOld = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("relay_integration", true); + + gRelayACOptionsTitles = await new Localization([ + "browser/firefoxRelay.ftl", + "toolkit/branding/brandings.ftl", + ]).formatMessages([ + "firefox-relay-opt-in-title-1", + "firefox-relay-use-mask-title", + ]); + + registerCleanupFunction(async () => { + await new Promise(resolve => { + gHttpServer.stop(function () { + resolve(); + }); + }); + Services.telemetry.setEventRecordingEnabled("relay_integration", false); + Services.telemetry.clearEvents(); + Services.telemetry.canRecordExtended = canRecordExtendedOld; + sinon.restore(); + }); +}); + +add_task(async function test_pref_toggle() { + await setupRelayScenario("available"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:preferences#privacy", + }, + async function (browser) { + const relayIntegrationCheckbox = content.document.querySelector( + "checkbox#relayIntegration" + ); + relayIntegrationCheckbox.click(); + relayIntegrationCheckbox.click(); + await assertEvents([ + { object: "pref_change", method: "disabled" }, + { object: "pref_change", method: "enabled" }, + ]); + } + ); +}); + +add_task(async function test_popup_option_optin_enabled() { + await setupRelayScenario("available"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + + notificationPopup + .querySelector("button.popup-notification-primary-button") + .click(); + + await notificationHidden; + + await BrowserTestUtils.waitForEvent( + ConfirmationHint._panel, + "popuphidden" + ); + + await assertEvents([ + { + object: "offer_relay", + method: "shown", + extra: { is_relay_user: "true", scenario: "SignUpFormScenario" }, + }, + { + object: "offer_relay", + method: "clicked", + extra: { is_relay_user: "true", scenario: "SignUpFormScenario" }, + }, + { object: "opt_in_panel", method: "shown" }, + { object: "opt_in_panel", method: "enabled" }, + { + object: "fill_username", + method: "shown", + extra: { error_code: "0" }, + }, + ]); + } + ); +}); + +add_task(async function test_popup_option_optin_postponed() { + await setupRelayScenario("available"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + + notificationPopup + .querySelector("button.popup-notification-secondary-button") + .click(); + + await notificationHidden; + + await assertEvents([ + { object: "offer_relay", method: "shown" }, + { object: "offer_relay", method: "clicked" }, + { object: "opt_in_panel", method: "shown" }, + { object: "opt_in_panel", method: "postponed" }, + ]); + } + ); +}); + +add_task(async function test_popup_option_optin_disabled() { + await setupRelayScenario("available"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + const menupopup = notificationPopup.querySelector("menupopup"); + const menuitem = menupopup.querySelector("menuitem"); + + menuitem.click(); + await notificationHidden; + + await assertEvents([ + { object: "offer_relay", method: "shown" }, + { object: "offer_relay", method: "clicked" }, + { object: "opt_in_panel", method: "shown" }, + { object: "opt_in_panel", method: "disabled" }, + ]); + } + ); +}); + +add_task(async function test_popup_option_fillusername() { + await setupRelayScenario("enabled"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + await BrowserTestUtils.waitForEvent( + ConfirmationHint._panel, + "popuphidden" + ); + await assertEvents([ + { object: "fill_username", method: "shown" }, + { + object: "fill_username", + method: "clicked", + }, + ]); + } + ); +}); + +add_task(async function test_fillusername_free_tier_limit() { + await setupRelayScenario("enabled"); + setupServerScenario("free_tier_limit"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + notificationPopup.querySelector(".reusable-relay-masks button").click(); + await notificationHidden; + + await assertEvents([ + { object: "fill_username", method: "shown" }, + { + object: "fill_username", + method: "clicked", + }, + { + object: "fill_username", + method: "shown", + extra: { error_code: "free_tier_limit" }, + }, + { + object: "reuse_panel", + method: "shown", + }, + { + object: "reuse_panel", + method: "reuse_mask", + }, + ]); + + await SpecialPowers.spawn(browser, [], async function () { + const username = content.document.getElementById("form-basic-username"); + Assert.equal( + username.value, + "email1@mozilla.com", + "Username field should be filled with the first mask" + ); + }); + } + ); +}); + +add_task(async function test_fillusername_error() { + await setupRelayScenario("enabled"); + setupServerScenario("unknown_error"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + + await notificationShown; + Assert.equal( + notificationPopup.querySelector("popupnotification").id, + "relay-integration-error-notification", + "Error message should be displayed" + ); + + await assertEvents([ + { object: "fill_username", method: "shown" }, + { + object: "fill_username", + method: "clicked", + }, + { + object: "reuse_panel", + method: "shown", + extra: { error_code: "408" }, + }, + ]); + } + ); +}); + +add_task(async function test_auth_token_error() { + setupRelayScenario("enabled"); + gFxAccounts.getOAuthToken.restore(); + const oauthTokenStub = sinon.stub(gFxAccounts, "getOAuthToken").throws(); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + + notificationPopup + .querySelector("button.popup-notification-primary-button") + .click(); + + await notificationHidden; + + await assertEvents([ + { + object: "fill_username", + method: "shown", + extra: { error_code: "0" }, + }, + { + object: "fill_username", + method: "clicked", + extra: { error_code: "0" }, + }, + { + object: "fill_username", + method: "shown", + extra: { error_code: "418" }, + }, + ]); + } + ); + oauthTokenStub.restore(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js b/toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js new file mode 100644 index 0000000000..e1ea3af99a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js @@ -0,0 +1,57 @@ +"use strict"; + +const SIGNUP_DETECTION_HISTOGRAM = "PWMGR_SIGNUP_FORM_DETECTION_MS"; +const TEST_URL = `https://example.com${DIRECTORY_PATH}form_signup_detection.html`; + +/** + * + * @param {Object} histogramData The histogram data to examine + * @returns The amount of entries found in the histogram data + */ +function countEntries(histogramData) { + info(typeof histogramData); + return histogramData + ? Object.values(histogramData.values).reduce((a, b) => a + b, 0) + : null; +} + +/** + * @param {String} id The histogram to examine + * @param {Number} expected The expected amount of entries for a histogram + */ +async function countEntriesOfChildHistogram(id, expected) { + let histogram; + await TestUtils.waitForCondition(() => { + let histograms = Services.telemetry.getSnapshotForHistograms( + "main", + false + ).content; + + histogram = histograms[id]; + + return !!histogram && countEntries(histogram) == expected; + }, `The histogram ${id} was expected to have ${expected} entries.`); + Assert.equal(countEntries(histogram), expected); +} + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [["signon.signupDetection.enabled", true]], + }); + Services.telemetry.getHistogramById(SIGNUP_DETECTION_HISTOGRAM).clear(); +}); + +add_task(async () => { + let formProcessed = listenForTestNotification("FormProcessed", 2); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + await formProcessed; + + info( + "Test case: When loading the document the two
HTML elements are processed and each one is run against the SignUpFormRuleset. After the page load the histogram PWMGR_SIGNUP_FORM_DETECTION_MS should have two entries." + ); + await countEntriesOfChildHistogram(SIGNUP_DETECTION_HISTOGRAM, 2); + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_test_changeContentInputValue.js b/toolkit/components/passwordmgr/test/browser/browser_test_changeContentInputValue.js new file mode 100644 index 0000000000..d0016708b2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_test_changeContentInputValue.js @@ -0,0 +1,129 @@ +/** + * Tests head.js#changeContentInputValue. + */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; +const USERNAME_INPUT_SELECTOR = "#form-basic-username"; + +let testCases = [ + { + name: "blank string should clear input value", + originalValue: "start text", + inputEvent: "", + expectedKeypresses: ["Backspace"], + }, + { + name: "input value that adds to original string should only add the difference", + originalValue: "start text", + inputEvent: "start text!!!", + expectedKeypresses: ["!", "!", "!"], + }, + { + name: "input value that is a subset of original string should only delete the difference", + originalValue: "start text", + inputEvent: "start", + expectedKeypresses: ["Backspace"], + }, + { + name: "input value that is unrelated to the original string should replace it", + originalValue: "start text", + inputEvent: "wut?", + expectedKeypresses: ["w", "u", "t", "?"], + }, +]; + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + await testStringChange(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function testStringChange({ + name, + originalValue, + inputEvent, + expectedKeypresses, +}) { + info("Starting test " + name); + await LoginTestUtils.clearData(); + + await LoginTestUtils.addLogin({ + username: originalValue, + password: "password", + }); + + let formProcessedPromise = listenForTestNotification("FormProcessed"); + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + info("Opening tab with url: " + url); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + info(`Opened tab with url: ${url}, waiting for focus`); + await SimpleTest.promiseFocus(browser.ownerGlobal); + info("Waiting for form-processed message"); + await formProcessedPromise; + await checkForm(browser, originalValue); + info("form checked"); + + await ContentTask.spawn( + browser, + { USERNAME_INPUT_SELECTOR, expectedKeypresses }, + async function ({ USERNAME_INPUT_SELECTOR, expectedKeypresses }) { + let input = content.document.querySelector(USERNAME_INPUT_SELECTOR); + + let verifyKeyListener = event => { + Assert.equal( + expectedKeypresses[0], + event.key, + "Key press matches expected value" + ); + expectedKeypresses.shift(); + + if (!expectedKeypresses.length) { + input.removeEventListner("keydown", verifyKeyListener); + input.addEventListener("keydown", () => { + throw new Error("Unexpected keypress encountered"); + }); + } + }; + + input.addEventListener("keydown", verifyKeyListener); + } + ); + + changeContentInputValue(browser, USERNAME_INPUT_SELECTOR, inputEvent); + } + ); +} + +async function checkForm(browser, expectedUsername) { + await ContentTask.spawn( + browser, + { + expectedUsername, + USERNAME_INPUT_SELECTOR, + }, + async function contentCheckForm({ + expectedUsername, + USERNAME_INPUT_SELECTOR, + }) { + let field = content.document.querySelector(USERNAME_INPUT_SELECTOR); + Assert.equal( + field.value, + expectedUsername, + `Username field has teh expected initial value '${expectedUsername}'` + ); + } + ); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_username_only_form_telemetry.js b/toolkit/components/passwordmgr/test/browser/browser_username_only_form_telemetry.js new file mode 100644 index 0000000000..54304c24ac --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_username_only_form_telemetry.js @@ -0,0 +1,198 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +async function setupForms(numUsernameOnly, numBasic, numOther) { + const TEST_HOSTNAME = "https://example.com"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_HOSTNAME + DIRECTORY_PATH + "empty.html" + ); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [ + { + numUsernameOnly, + numBasic, + }, + ], + async function (data) { + // type: 1: basic, 2:usernameOnly, 3:other + function addForm(type) { + const form = content.document.createElement("form"); + content.document.body.appendChild(form); + + const user = content.document.createElement("input"); + if (type === 3) { + user.type = "url"; + } else { + user.type = "text"; + user.autocomplete = "username"; + } + form.appendChild(user); + + if (type === 1) { + const password = content.document.createElement("input"); + password.type = "password"; + form.appendChild(password); + } + } + for (let i = 0; i < data.numBasic; i++) { + addForm(1); + } + for (let i = 0; i < data.numUsernameOnly; i++) { + addForm(2); + } + for (let i = 0; i < data.numOther; i++) { + addForm(3); + } + } + ); + + return tab; +} + +async function checkChildHistogram(id, index, expected) { + let histogram; + await TestUtils.waitForCondition(() => { + let histograms = Services.telemetry.getSnapshotForHistograms( + "main", + false /* clear */ + ).content; + + histogram = histograms[id]; + return !!histogram && histogram.values[index] == expected; + }); + Assert.equal(histogram.values[index], expected); +} + +add_setup(async function () { + SpecialPowers.pushPrefEnv({ + set: [ + ["signon.usernameOnlyForm.enabled", true], + ["signon.usernameOnlyForm.lookupThreshold", 100], // ignore the threshold in test + ], + }); + + // Wait 1sec to make sure all the telemetry data recorded prior to the beginning of the + // test is cleared. + await new Promise(res => setTimeout(res, 1000)); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); + +add_task(async function test_oneUsernameOnlyForm() { + const numUsernameOnlyForms = 1; + const numBasicForms = 0; + + // number of "other" forms doesn't change the outcome, set it to 2 here and + // in the following testcase just to ensure it doesn't affect the result. + let tab = await setupForms(numUsernameOnlyForms, numBasicForms, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + numUsernameOnlyForms, + 1 + ); + + BrowserTestUtils.removeTab(tab); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); + +add_task(async function test_multipleUsernameOnlyForms() { + const numUsernameOnlyForms = 3; + const numBasicForms = 2; + + let tab = await setupForms(numUsernameOnlyForms, numBasicForms, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + 5, + 1 + ); + + BrowserTestUtils.removeTab(tab); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); + +add_task(async function test_multipleDocument() { + // The first document + let numUsernameOnlyForms1 = 2; + let numBasicForms1 = 2; + + let tab1 = await setupForms(numUsernameOnlyForms1, numBasicForms1, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms1 + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + numUsernameOnlyForms1 + numBasicForms1, + 1 + ); + + // The second document + let numUsernameOnlyForms2 = 15; + let numBasicForms2 = 3; + + let tab2 = await setupForms(numUsernameOnlyForms2, numBasicForms2, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms1 + numUsernameOnlyForms2 + ); + + // the result is stacked, so the new document add a counter to all + // buckets under "numUsernameOnlyForms2 + numBasicForms2" + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + numUsernameOnlyForms1 + numBasicForms1, + 2 + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + numUsernameOnlyForms2 + numBasicForms2, + 1 + ); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); + +add_task(async function test_tooManyUsernameOnlyForms() { + const numUsernameOnlyForms = 25; + const numBasicForms = 2; + + let tab = await setupForms(numUsernameOnlyForms, numBasicForms, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + 21, + numUsernameOnlyForms + numBasicForms - 20 // maximum is 20 + ); + + BrowserTestUtils.removeTab(tab); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js b/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js new file mode 100644 index 0000000000..03ac74e9ef --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js @@ -0,0 +1,177 @@ +/* + * Test username selection dialog, on password update from a p-only form, + * when there are multiple saved logins on the domain. + */ + +// Copied from prompt_common.js. TODO: share the code. +function getSelectDialogDoc() { + // Trudge through all the open windows, until we find the one + // that has selectDialog.xhtml loaded. + // var enumerator = Services.wm.getEnumerator("navigator:browser"); + for (let { docShell } of Services.wm.getEnumerator(null)) { + var containedDocShells = docShell.getAllDocShellsInSubtree( + docShell.typeChrome, + docShell.ENUMERATE_FORWARDS + ); + for (let childDocShell of containedDocShells) { + // We don't want it if it's not done loading. + if (childDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) { + continue; + } + var childDoc = childDocShell.contentViewer.DOMDocument; + + if ( + childDoc.location.href == "chrome://global/content/selectDialog.xhtml" + ) { + return childDoc; + } + } + } + + return null; +} + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login1 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +let login1B = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1B", + "notifyp1B", + "user", + "pass" +); + +add_task(async function test_changeUPLoginOnPUpdateForm_accept() { + info( + "Select an u+p login from multiple logins, on password update form, and accept." + ); + await Services.logins.addLogins([login1, login1B]); + + let selectDialogPromise = TestUtils.topicObserved("select-dialog-loaded"); + + await testSubmittingLoginForm( + "subtst_notifications_change_p.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + + info("Waiting for select dialog to appear."); + let doc = (await selectDialogPromise)[0].document; + let dialog = doc.getElementsByTagName("dialog")[0]; + let listbox = doc.getElementById("list"); + + Assert.equal(listbox.selectedIndex, 0, "Checking selected index"); + Assert.equal(listbox.itemCount, 2, "Checking selected length"); + ["notifyu1", "notifyu1B"].forEach((username, i) => { + Assert.equal( + listbox.getItemAtIndex(i).label, + username, + "Check username selection on dialog" + ); + }); + + dialog.acceptDialog(); + + await TestUtils.waitForCondition(() => { + return !getSelectDialogDoc(); + }, "Wait for selection dialog to disappear."); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have 2 logins"); + + let login = SpecialPowers.wrap(logins[0]).QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + login = SpecialPowers.wrap(logins[1]).QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1B", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; + + Services.logins.removeLogin(login1B); +}); + +add_task(async function test_changeUPLoginOnPUpdateForm_cancel() { + info( + "Select an u+p login from multiple logins, on password update form, and cancel." + ); + await Services.logins.addLogins([login1, login1B]); + + let selectDialogPromise = TestUtils.topicObserved("select-dialog-loaded"); + + await testSubmittingLoginForm( + "subtst_notifications_change_p.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + + info("Waiting for select dialog to appear."); + let doc = (await selectDialogPromise)[0].document; + let dialog = doc.getElementsByTagName("dialog")[0]; + let listbox = doc.getElementById("list"); + + Assert.equal(listbox.selectedIndex, 0, "Checking selected index"); + Assert.equal(listbox.itemCount, 2, "Checking selected length"); + ["notifyu1", "notifyu1B"].forEach((username, i) => { + Assert.equal( + listbox.getItemAtIndex(i).label, + username, + "Check username selection on dialog" + ); + }); + + dialog.cancelDialog(); + + await TestUtils.waitForCondition(() => { + return !getSelectDialogDoc(); + }, "Wait for selection dialog to disappear."); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have 2 logins"); + + let login = SpecialPowers.wrap(logins[0]).QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + login = SpecialPowers.wrap(logins[1]).QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1B", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + // cleanup + Services.logins.removeLogin(login1); + Services.logins.removeLogin(login1B); +}); diff --git a/toolkit/components/passwordmgr/test/browser/empty.html b/toolkit/components/passwordmgr/test/browser/empty.html new file mode 100644 index 0000000000..1ad28bb1f7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/empty.html @@ -0,0 +1,8 @@ + + + +Empty file + + + + diff --git a/toolkit/components/passwordmgr/test/browser/file_focus_before_DOMContentLoaded.sjs b/toolkit/components/passwordmgr/test/browser/file_focus_before_DOMContentLoaded.sjs new file mode 100644 index 0000000000..b99246c166 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/file_focus_before_DOMContentLoaded.sjs @@ -0,0 +1,35 @@ +/** + * Focus a username field before DOMContentLoaded. + */ + +"use strict"; + +const DELAY = 2 * 1000; // Delay two seconds before completing the request. + +// In an SJS file we need to get the setTimeout bits ourselves, despite +// what eslint might think applies for browser tests. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +let { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html;charset=utf-8", false); + response.setHeader("Cache-Control", "no-cache", false); + response.write(` + + + + + +
+ + `); + + setTimeout(function finishOutput() { + response.write(``); + response.finish(); + }, DELAY); +} diff --git a/toolkit/components/passwordmgr/test/browser/form_autofocus_frame.html b/toolkit/components/passwordmgr/test/browser/form_autofocus_frame.html new file mode 100644 index 0000000000..3a145a9ea8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_autofocus_frame.html @@ -0,0 +1,10 @@ + + +
+ + + +
+ + + diff --git a/toolkit/components/passwordmgr/test/browser/form_autofocus_js.html b/toolkit/components/passwordmgr/test/browser/form_autofocus_js.html new file mode 100644 index 0000000000..76056e3751 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_autofocus_js.html @@ -0,0 +1,10 @@ + + + +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_basic.html b/toolkit/components/passwordmgr/test/browser/form_basic.html new file mode 100644 index 0000000000..df2083a93c --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_basic.html @@ -0,0 +1,12 @@ + + + + +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html b/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html new file mode 100644 index 0000000000..dd34739e6d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_login.html b/toolkit/components/passwordmgr/test/browser/form_basic_login.html new file mode 100644 index 0000000000..f63aca8b41 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_basic_login.html @@ -0,0 +1,12 @@ + + + + +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_no_username.html b/toolkit/components/passwordmgr/test/browser/form_basic_no_username.html new file mode 100644 index 0000000000..885fa9c11c --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_basic_no_username.html @@ -0,0 +1,11 @@ + + + + +
+ + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_signup.html b/toolkit/components/passwordmgr/test/browser/form_basic_signup.html new file mode 100644 index 0000000000..a42a8cc609 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_basic_signup.html @@ -0,0 +1,10 @@ + + +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_with_confirm_field.html b/toolkit/components/passwordmgr/test/browser/form_basic_with_confirm_field.html new file mode 100644 index 0000000000..c638be4af7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_basic_with_confirm_field.html @@ -0,0 +1,13 @@ + + + + +
+ + + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_cross_origin_insecure_action.html b/toolkit/components/passwordmgr/test/browser/form_cross_origin_insecure_action.html new file mode 100644 index 0000000000..8dde7ceb63 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_cross_origin_insecure_action.html @@ -0,0 +1,12 @@ + + + + +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_cross_origin_secure_action.html b/toolkit/components/passwordmgr/test/browser/form_cross_origin_secure_action.html new file mode 100644 index 0000000000..2d95419549 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_cross_origin_secure_action.html @@ -0,0 +1,12 @@ + + + + +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_crossframe.html b/toolkit/components/passwordmgr/test/browser/form_crossframe.html new file mode 100644 index 0000000000..fbb759c632 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_crossframe.html @@ -0,0 +1,13 @@ + + + + +
+ + + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_crossframe_inner.html b/toolkit/components/passwordmgr/test/browser/form_crossframe_inner.html new file mode 100644 index 0000000000..9a919993bf --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_crossframe_inner.html @@ -0,0 +1,13 @@ + + + + +
+ + + +
+ + + diff --git a/toolkit/components/passwordmgr/test/browser/form_disabled_readonly_passwordField.html b/toolkit/components/passwordmgr/test/browser/form_disabled_readonly_passwordField.html new file mode 100644 index 0000000000..4e47d9790d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_disabled_readonly_passwordField.html @@ -0,0 +1,12 @@ + +
+ + + +
+
+ + + +
+ diff --git a/toolkit/components/passwordmgr/test/browser/form_expanded.html b/toolkit/components/passwordmgr/test/browser/form_expanded.html new file mode 100644 index 0000000000..567a373f5b --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_expanded.html @@ -0,0 +1,16 @@ + + + + + +
+ + + + + +
+ + + diff --git a/toolkit/components/passwordmgr/test/browser/form_multipage.html b/toolkit/components/passwordmgr/test/browser/form_multipage.html new file mode 100644 index 0000000000..908457fd50 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_multipage.html @@ -0,0 +1,32 @@ + + + + +
+ + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/form_password_change.html b/toolkit/components/passwordmgr/test/browser/form_password_change.html new file mode 100644 index 0000000000..279de622f2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_password_change.html @@ -0,0 +1,17 @@ + + + + + Test for Login Manager notifications w/ new password + + +

Test for Login Manager notifications w/ new password

+ +
+ + + +
+ + + diff --git a/toolkit/components/passwordmgr/test/browser/form_same_origin_action.html b/toolkit/components/passwordmgr/test/browser/form_same_origin_action.html new file mode 100644 index 0000000000..8f0c9a14e2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_same_origin_action.html @@ -0,0 +1,12 @@ + + + + +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/form_signup_detection.html b/toolkit/components/passwordmgr/test/browser/form_signup_detection.html new file mode 100644 index 0000000000..de01223239 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_signup_detection.html @@ -0,0 +1,31 @@ + + + + + + +

Sign up

+
+ + + + + + + + + +
+

Login

+
+ + + + + Password forgotten? + + + +
+ + diff --git a/toolkit/components/passwordmgr/test/browser/formless_basic.html b/toolkit/components/passwordmgr/test/browser/formless_basic.html new file mode 100644 index 0000000000..17455df96e --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/formless_basic.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/browser/head.js b/toolkit/components/passwordmgr/test/browser/head.js new file mode 100644 index 0000000000..70a1f685e2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/head.js @@ -0,0 +1,965 @@ +const DIRECTORY_PATH = "/browser/toolkit/components/passwordmgr/test/browser/"; + +var { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +add_setup(async function common_initialize() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.rememberSignons", true], + ["signon.testOnlyUserHasInteractedByPrefValue", true], + ["signon.testOnlyUserHasInteractedWithDocument", true], + ["toolkit.telemetry.ipcBatchTimeout", 0], + ], + }); + if (LoginHelper.relatedRealmsEnabled) { + await LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials(); + registerCleanupFunction(async function () { + await LoginTestUtils.remoteSettings.cleanWebsitesWithSharedCredentials(); + }); + } +}); + +registerCleanupFunction( + async function cleanup_removeAllLoginsAndResetRecipes() { + await SpecialPowers.popPrefEnv(); + + LoginTestUtils.clearData(); + LoginTestUtils.resetGeneratedPasswordsCache(); + clearHttpAuths(); + Services.telemetry.clearEvents(); + + let recipeParent = LoginTestUtils.recipes.getRecipeParent(); + if (!recipeParent) { + // No need to reset the recipes if the recipe module wasn't even loaded. + return; + } + await recipeParent.then(recipeParentResult => recipeParentResult.reset()); + + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + await closePopup(document.getElementById("contentAreaContextMenu")); + await closePopup(document.getElementById("PopupAutoComplete")); + } +); + +/** + * Compared logins in storage to expected values + * + * @param {array} expectedLogins + * An array of expected login properties + * @return {nsILoginInfo[]} - All saved logins sorted by timeCreated + */ +function verifyLogins(expectedLogins = []) { + let allLogins = Services.logins.getAllLogins(); + allLogins.sort((a, b) => a.timeCreated > b.timeCreated); + Assert.equal( + allLogins.length, + expectedLogins.length, + "Check actual number of logins matches the number of provided expected property-sets" + ); + for (let i = 0; i < expectedLogins.length; i++) { + // if the test doesn't care about comparing properties for this login, just pass false/null. + let expected = expectedLogins[i]; + if (expected) { + let login = allLogins[i]; + if (typeof expected.timesUsed !== "undefined") { + Assert.equal(login.timesUsed, expected.timesUsed, "Check timesUsed"); + } + if (typeof expected.passwordLength !== "undefined") { + Assert.equal( + login.password.length, + expected.passwordLength, + "Check passwordLength" + ); + } + if (typeof expected.username !== "undefined") { + Assert.equal(login.username, expected.username, "Check username"); + } + if (typeof expected.password !== "undefined") { + Assert.equal(login.password, expected.password, "Check password"); + } + if (typeof expected.usedSince !== "undefined") { + Assert.ok( + login.timeLastUsed > expected.usedSince, + "Check timeLastUsed" + ); + } + if (typeof expected.passwordChangedSince !== "undefined") { + Assert.ok( + login.timePasswordChanged > expected.passwordChangedSince, + "Check timePasswordChanged" + ); + } + if (typeof expected.timeCreated !== "undefined") { + Assert.equal( + login.timeCreated, + expected.timeCreated, + "Check timeCreated" + ); + } + } + } + return allLogins; +} + +/** + * Submit the content form and return a promise resolving to the username and + * password values echoed out in the response + * + * @param {Object} [browser] - browser with the form + * @param {String = ""} formAction - Optional url to set the form's action to before submitting + * @param {Object = null} selectorValues - Optional object with field values to set before form submission + * @param {Object = null} responseSelectors - Optional object with selectors to find the username and password in the response + */ +async function submitFormAndGetResults( + browser, + formAction = "", + selectorValues, + responseSelectors +) { + async function contentSubmitForm([contentFormAction, contentSelectorValues]) { + const { WrapPrivileged } = ChromeUtils.importESModule( + "resource://testing-common/WrapPrivileged.sys.mjs" + ); + let doc = content.document; + let form = doc.querySelector("form"); + if (contentFormAction) { + form.action = contentFormAction; + } + for (let [sel, value] of Object.entries(contentSelectorValues)) { + try { + let field = doc.querySelector(sel); + let gotInput = ContentTaskUtils.waitForEvent( + field, + "input", + "Got input event on " + sel + ); + // we don't get an input event if the new value == the old + field.value = "###"; + WrapPrivileged.wrap(field, this).setUserInput(value); + await gotInput; + } catch (ex) { + throw new Error( + `submitForm: Couldn't set value of field at: ${sel}: ${ex.message}` + ); + } + } + form.submit(); + } + + let loadPromise = BrowserTestUtils.browserLoaded(browser); + await SpecialPowers.spawn( + browser, + [[formAction, selectorValues]], + contentSubmitForm + ); + await loadPromise; + + let result = await getFormSubmitResponseResult( + browser, + formAction, + responseSelectors + ); + return result; +} + +/** + * Wait for a given result page to load and return a promise resolving to an object with the parsed-out + * username/password values from the response + * + * @param {Object} [browser] - browser which is loading this page + * @param {String} resultURL - the path or filename to look for in the content.location + * @param {Object = null} - Optional object with selectors to find the username and password in the response + */ +async function getFormSubmitResponseResult( + browser, + resultURL = "/formsubmit.sjs", + { username = "#user", password = "#pass" } = {} +) { + // default selectors are for the response page produced by formsubmit.sjs + let fieldValues = await ContentTask.spawn( + browser, + { + resultURL, + usernameSelector: username, + passwordSelector: password, + }, + async function ({ resultURL, usernameSelector, passwordSelector }) { + await ContentTaskUtils.waitForCondition(() => { + return ( + content.location.pathname.endsWith(resultURL) && + content.document.readyState == "complete" + ); + }, `Wait for form submission load (${resultURL})`); + let username = + content.document.querySelector(usernameSelector).textContent; + // Bug 1686071: Since generated passwords can have special characters in them, + // we need to unescape the characters. These special characters are automatically escaped + // when we submit a form in `submitFormAndGetResults`. + // Otherwise certain tests will intermittently fail when these special characters are present in the passwords. + let password = unescape( + content.document.querySelector(passwordSelector).textContent + ); + return { + username, + password, + }; + } + ); + return fieldValues; +} + +/** + * Loads a test page in `DIRECTORY_URL` which automatically submits to formsubmit.sjs and returns a + * promise resolving with the field values when the optional `aTaskFn` is done. + * + * @param {String} aPageFile - test page file name which auto-submits to formsubmit.sjs + * @param {Function} aTaskFn - task which can be run before the tab closes. + * @param {String} [aOrigin="https://example.com"] - origin of the server to use + * to load `aPageFile`. + */ +function testSubmittingLoginForm( + aPageFile, + aTaskFn, + aOrigin = "https://example.com" +) { + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: aOrigin + DIRECTORY_PATH + aPageFile, + }, + async function (browser) { + Assert.ok(true, "loaded " + aPageFile); + let fieldValues = await getFormSubmitResponseResult( + browser, + "/formsubmit.sjs" + ); + Assert.ok(true, "form submission loaded"); + if (aTaskFn) { + await aTaskFn(fieldValues, browser); + } + return fieldValues; + } + ); +} +/** + * Loads a test page in `DIRECTORY_URL` which automatically submits to formsubmit.sjs and returns a + * promise resolving with the field values when the optional `aTaskFn` is done. + * + * @param {String} aPageFile - test page file name which auto-submits to formsubmit.sjs + * @param {Function} aTaskFn - task which can be run before the tab closes. + * @param {String} [aOrigin="http://example.com"] - origin of the server to use + * to load `aPageFile`. + */ +function testSubmittingLoginFormHTTP( + aPageFile, + aTaskFn, + aOrigin = "http://example.com" +) { + return testSubmittingLoginForm(aPageFile, aTaskFn, aOrigin); +} + +function checkOnlyLoginWasUsedTwice({ justChanged }) { + // Check to make sure we updated the timestamps and use count on the + // existing login that was submitted for the test. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + Assert.ok(logins[0] instanceof Ci.nsILoginMetaInfo, "metainfo QI"); + Assert.equal( + logins[0].timesUsed, + 2, + "check .timesUsed for existing login submission" + ); + Assert.ok( + logins[0].timeCreated < logins[0].timeLastUsed, + "timeLastUsed bumped" + ); + if (justChanged) { + Assert.equal( + logins[0].timeLastUsed, + logins[0].timePasswordChanged, + "timeLastUsed == timePasswordChanged" + ); + } else { + Assert.equal( + logins[0].timeCreated, + logins[0].timePasswordChanged, + "timeChanged not updated" + ); + } +} + +function clearHttpAuths() { + let authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService( + Ci.nsIHttpAuthManager + ); + authMgr.clearAll(); +} + +// Begin popup notification (doorhanger) functions // + +const REMEMBER_BUTTON = "button"; +const NEVER_MENUITEM = 0; + +const CHANGE_BUTTON = "button"; +const DONT_CHANGE_BUTTON = "secondaryButton"; +const REMOVE_LOGIN_MENUITEM = 0; + +/** + * Checks if we have a password capture popup notification + * of the right type and with the right label. + * + * @param {String} aKind The desired `passwordNotificationType` ("any" for any type) + * @param {Object} [popupNotifications = PopupNotifications] + * @param {Object} [browser = null] Optional browser whose notifications should be searched. + * @return the found password popup notification. + */ +function getCaptureDoorhanger( + aKind, + popupNotifications = PopupNotifications, + browser = null +) { + Assert.ok(true, "Looking for " + aKind + " popup notification"); + let notification = popupNotifications.getNotification("password", browser); + if (!aKind) { + throw new Error( + "getCaptureDoorhanger needs aKind to be a non-empty string" + ); + } + if (aKind !== "any" && notification) { + Assert.equal( + notification.options.passwordNotificationType, + aKind, + "Notification type matches." + ); + if (aKind == "password-change") { + Assert.equal( + notification.mainAction.label, + "Update", + "Main action label matches update doorhanger." + ); + } else if (aKind == "password-save") { + Assert.equal( + notification.mainAction.label, + "Save", + "Main action label matches save doorhanger." + ); + } + } + return notification; +} + +async function getCaptureDoorhangerThatMayOpen( + aKind, + popupNotifications = PopupNotifications, + browser = null +) { + let notif = getCaptureDoorhanger(aKind, popupNotifications, browser); + if (notif && !notif.dismissed) { + if (popupNotifications.panel.state !== "open") { + await BrowserTestUtils.waitForEvent( + popupNotifications.panel, + "popupshown" + ); + } + } + return notif; +} + +async function waitForDoorhanger(browser, type) { + let notif; + await TestUtils.waitForCondition(() => { + notif = PopupNotifications.getNotification("password", browser); + if (notif && type !== "any") { + return ( + notif.options.passwordNotificationType == type && + notif.anchorElement && + BrowserTestUtils.is_visible(notif.anchorElement) + ); + } + return notif; + }, `Waiting for a ${type} notification`); + return notif; +} + +async function hideDoorhangerPopup() { + info("hideDoorhangerPopup"); + if (!PopupNotifications.isPanelOpen) { + return; + } + let { panel } = PopupNotifications; + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + await promiseHidden; + info("got popuphidden from notification panel"); +} + +function getDoorhangerButton(aPopup, aButtonIndex) { + let notifications = aPopup.owner.panel.children; + Assert.ok(!!notifications.length, "at least one notification displayed"); + Assert.ok(true, notifications.length + " notification(s)"); + let notification = notifications[0]; + + if (aButtonIndex == "button") { + return notification.button; + } else if (aButtonIndex == "secondaryButton") { + return notification.secondaryButton; + } + return notification.menupopup.querySelectorAll("menuitem")[aButtonIndex]; +} + +/** + * Clicks the specified popup notification button. + * + * @param {Element} aPopup Popup Notification element + * @param {Number} aButtonIndex Number indicating which button to click. + * See the constants in this file. + */ +function clickDoorhangerButton(aPopup, aButtonIndex) { + Assert.ok(true, "Looking for action at index " + aButtonIndex); + + let button = getDoorhangerButton(aPopup, aButtonIndex); + if (aButtonIndex == "button") { + Assert.ok(true, "Triggering main action"); + } else if (aButtonIndex == "secondaryButton") { + Assert.ok(true, "Triggering secondary action"); + } else { + Assert.ok(true, "Triggering menuitem # " + aButtonIndex); + } + button.doCommand(); +} + +async function cleanupDoorhanger(notif) { + let PN = notif ? notif.owner : PopupNotifications; + if (notif) { + notif.remove(); + } + let promiseHidden = PN.isPanelOpen + ? BrowserTestUtils.waitForEvent(PN.panel, "popuphidden") + : Promise.resolve(); + PN.panel.hidePopup(); + await promiseHidden; +} + +async function cleanupPasswordNotifications( + popupNotifications = PopupNotifications +) { + let notif; + while ((notif = popupNotifications.getNotification("password"))) { + notif.remove(); + } +} + +async function clearMessageCache(browser) { + await SpecialPowers.spawn(browser, [], async () => { + const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" + ); + let docState = LoginManagerChild.forWindow(content).stateForDocument( + content.document + ); + docState.lastSubmittedValuesByRootElement = new content.WeakMap(); + }); +} + +/** + * Checks the doorhanger's username and password. + * + * @param {String} username The username. + * @param {String} password The password. + */ +async function checkDoorhangerUsernamePassword(username, password) { + await BrowserTestUtils.waitForCondition(() => { + return ( + document.getElementById("password-notification-username").value == + username && + document.getElementById("password-notification-password").value == + password + ); + }, "Wait for nsLoginManagerPrompter writeDataToUI() to update to the correct username/password values"); +} + +/** + * Change the doorhanger's username and password input values. + * + * @param {object} newValues + * named values to update + * @param {string} [newValues.password = undefined] + * An optional string value to replace whatever is in the password field + * @param {string} [newValues.username = undefined] + * An optional string value to replace whatever is in the username field + * @param {Object} [popupNotifications = PopupNotifications] + */ +async function updateDoorhangerInputValues( + newValues, + popupNotifications = PopupNotifications +) { + let { panel } = popupNotifications; + if (popupNotifications.panel.state !== "open") { + await BrowserTestUtils.waitForEvent(popupNotifications.panel, "popupshown"); + } + Assert.equal(panel.state, "open", "Check the doorhanger is already open"); + + let notifElem = panel.childNodes[0]; + + // Note: setUserInput does not reliably dispatch input events from chrome elements? + async function setInputValue(target, value) { + info(`setInputValue: on target: ${target.id}, value: ${value}`); + target.focus(); + target.select(); + info( + `setInputValue: current value: '${target.value}', setting new value '${value}'` + ); + await EventUtils.synthesizeKey("KEY_Backspace"); + await EventUtils.sendString(value); + await EventUtils.synthesizeKey("KEY_Tab"); + return Promise.resolve(); + } + + let passwordField = notifElem.querySelector( + "#password-notification-password" + ); + let usernameField = notifElem.querySelector( + "#password-notification-username" + ); + + if (typeof newValues.password !== "undefined") { + if (passwordField.value !== newValues.password) { + await setInputValue(passwordField, newValues.password); + } + } + if (typeof newValues.username !== "undefined") { + if (usernameField.value !== newValues.username) { + await setInputValue(usernameField, newValues.username); + } + } +} + +/** + * Open doorhanger autocomplete popup and select a username value. + * + * @param {string} text the text value of the username that should be selected. + * Noop if `text` is falsy. + */ +async function selectDoorhangerUsername(text) { + await _selectDoorhanger( + text, + "#password-notification-username", + "#password-notification-username-dropmarker" + ); +} + +/** + * Open doorhanger autocomplete popup and select a password value. + * + * @param {string} text the text value of the password that should be selected. + * Noop if `text` is falsy. + */ +async function selectDoorhangerPassword(text) { + await _selectDoorhanger( + text, + "#password-notification-password", + "#password-notification-password-dropmarker" + ); +} + +async function _selectDoorhanger(text, inputSelector, dropmarkerSelector) { + if (!text) { + return; + } + + info("Opening doorhanger suggestion popup"); + + let doorhangerPopup = document.getElementById("password-notification"); + let dropmarker = doorhangerPopup.querySelector(dropmarkerSelector); + + let autocompletePopup = document.getElementById("PopupAutoComplete"); + let popupShown = BrowserTestUtils.waitForEvent( + autocompletePopup, + "popupshown" + ); + // the dropmarker gets un-hidden async when looking up username suggestions + await TestUtils.waitForCondition(() => !dropmarker.hidden); + + EventUtils.synthesizeMouseAtCenter(dropmarker, {}); + + await popupShown; + + let suggestions = [ + ...document + .getElementById("PopupAutoComplete") + .getElementsByTagName("richlistitem"), + ].filter(richlistitem => !richlistitem.collapsed); + + let suggestionText = suggestions.map( + richlistitem => richlistitem.querySelector(".ac-title-text").innerHTML + ); + + let targetIndex = suggestionText.indexOf(text); + Assert.ok(targetIndex != -1, "Suggestions include expected text"); + + let promiseHidden = BrowserTestUtils.waitForEvent( + autocompletePopup, + "popuphidden" + ); + + info("Selecting doorhanger suggestion"); + + EventUtils.synthesizeMouseAtCenter(suggestions[targetIndex], {}); + + await promiseHidden; +} + +// End popup notification (doorhanger) functions // + +async function openPasswordManager(openingFunc, waitForFilter) { + info("waiting for new tab to open"); + let tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => url.includes("about:logins") && !url.includes("entryPoint="), + true + ); + await openingFunc(); + let tab = await tabPromise; + Assert.ok(tab, "got password management tab"); + let filterValue; + if (waitForFilter) { + filterValue = await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + let loginFilter = Cu.waiveXrays( + content.document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter") + ); + await ContentTaskUtils.waitForCondition( + () => !!loginFilter.value, + "wait for login-filter to have a value" + ); + return loginFilter.value; + }); + } + return { + filterValue, + close() { + BrowserTestUtils.removeTab(tab); + }, + }; +} + +// Autocomplete popup related functions // + +async function openACPopup( + popup, + browser, + inputSelector, + iframeBrowsingContext = null +) { + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + + await SimpleTest.promiseFocus(browser); + info("content window focused"); + + // Focus the username field to open the popup. + let target = iframeBrowsingContext || browser; + await SpecialPowers.spawn( + target, + [[inputSelector]], + function openAutocomplete(sel) { + content.document.querySelector(sel).focus(); + } + ); + + let shown = await promiseShown; + Assert.ok(shown, "autocomplete popup shown"); + return shown; +} + +async function closePopup(popup) { + if (popup.state == "closed") { + await Promise.resolve(); + } else { + let promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.hidePopup(); + await promiseHidden; + } +} + +async function fillGeneratedPasswordFromOpenACPopup( + browser, + passwordInputSelector +) { + let popup = browser.ownerDocument.getElementById("PopupAutoComplete"); + let item; + + await new Promise(requestAnimationFrame); + await TestUtils.waitForCondition(() => { + item = popup.querySelector(`[originaltype="generatedPassword"]`); + return item && !EventUtils.isHidden(item); + }, "Waiting for item to become visible"); + + let inputEventPromise = ContentTask.spawn( + browser, + [passwordInputSelector], + async function waitForInput(inputSelector) { + let passwordInput = content.document.querySelector(inputSelector); + await ContentTaskUtils.waitForEvent( + passwordInput, + "input", + "Password input value changed" + ); + } + ); + + let passwordGeneratedPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + + info("Clicking the generated password AC item"); + EventUtils.synthesizeMouseAtCenter(item, {}); + info("Waiting for the content input value to change"); + await inputEventPromise; + info("Waiting for the passwordGeneratedPromise"); + await passwordGeneratedPromise; +} + +// Contextmenu functions // + +/** + * Synthesize mouse clicks to open the password manager context menu popup + * for a target password input element. + * + * assertCallback should return true if we should continue or else false. + */ +async function openPasswordContextMenu( + browser, + input, + assertCallback = null, + browsingContext = null, + openFillMenu = null +) { + const doc = browser.ownerDocument; + const CONTEXT_MENU = doc.getElementById("contentAreaContextMenu"); + const POPUP_HEADER = doc.getElementById("fill-login"); + const LOGIN_POPUP = doc.getElementById("fill-login-popup"); + + if (!browsingContext) { + browsingContext = browser.browsingContext; + } + + let contextMenuShownPromise = BrowserTestUtils.waitForEvent( + CONTEXT_MENU, + "popupshown" + ); + + // Synthesize a right mouse click over the password input element, we have to trigger + // both events because formfill code relies on this event happening before the contextmenu + // (which it does for real user input) in order to not show the password autocomplete. + let eventDetails = { type: "mousedown", button: 2 }; + await BrowserTestUtils.synthesizeMouseAtCenter( + input, + eventDetails, + browsingContext + ); + // Synthesize a contextmenu event to actually open the context menu. + eventDetails = { type: "contextmenu", button: 2 }; + await BrowserTestUtils.synthesizeMouseAtCenter( + input, + eventDetails, + browsingContext + ); + + await contextMenuShownPromise; + + if (assertCallback) { + let shouldContinue = await assertCallback(); + if (!shouldContinue) { + return; + } + } + + if (openFillMenu) { + // Open the fill login menu. + let popupShownPromise = BrowserTestUtils.waitForEvent( + LOGIN_POPUP, + "popupshown" + ); + POPUP_HEADER.openMenu(true); + await popupShownPromise; + } +} + +/** + * Listen for the login manager test notification specified by + * expectedMessage. Possible messages: + * FormProcessed - a form was processed after page load. + * FormSubmit - a form was just submitted. + * PasswordEditedOrGenerated - a password was filled in or modified. + * + * The count is the number of that messages to wait for. This should + * typically be used when waiting for the FormProcessed message for a page + * that has subframes to ensure all have been handled. + * + * Returns a promise that will passed additional data specific to the message. + */ +function listenForTestNotification(expectedMessage, count = 1) { + return new Promise(resolve => { + LoginManagerParent.setListenerForTests((msg, data) => { + if (msg == expectedMessage && --count == 0) { + LoginManagerParent.setListenerForTests(null); + info("listenForTestNotification, resolving for message: " + msg); + resolve(data); + } + }); + }); +} + +/** + * Use the contextmenu to fill a field with a generated password + */ +async function doFillGeneratedPasswordContextMenuItem(browser, passwordInput) { + await SimpleTest.promiseFocus(browser); + await openPasswordContextMenu(browser, passwordInput); + + let generatedPasswordItem = document.getElementById( + "fill-login-generated-password" + ); + let generatedPasswordSeparator = document.getElementById( + "passwordmgr-items-separator" + ); + + Assert.ok( + BrowserTestUtils.is_visible(generatedPasswordItem), + "generated password item is visible" + ); + Assert.ok( + BrowserTestUtils.is_visible(generatedPasswordSeparator), + "separator is visible" + ); + + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + + await new Promise(resolve => { + SimpleTest.executeSoon(resolve); + }); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.activateItem(generatedPasswordItem); + + await promiseShown; + await fillGeneratedPasswordFromOpenACPopup(browser, passwordInput); +} + +// Content form helpers +async function changeContentFormValues( + browser, + selectorValues, + shouldBlur = true +) { + for (let [sel, value] of Object.entries(selectorValues)) { + info("changeContentFormValues, update: " + sel + ", to: " + value); + await changeContentInputValue(browser, sel, value, shouldBlur); + await TestUtils.waitForTick(); + } +} + +async function changeContentInputValue( + browser, + selector, + str, + shouldBlur = true +) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + let oldValue = await ContentTask.spawn(browser, [selector], function (sel) { + return content.document.querySelector(sel).value; + }); + + if (str === oldValue) { + info("no change needed to value of " + selector + ": " + oldValue); + return; + } + info(`changeContentInputValue: from "${oldValue}" to "${str}"`); + await ContentTask.spawn( + browser, + { selector, str, shouldBlur }, + async function ({ selector, str, shouldBlur }) { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let input = content.document.querySelector(selector); + + input.focus(); + if (!str) { + input.select(); + await EventUtils.synthesizeKey("KEY_Backspace", {}, content); + } else if (input.value.startsWith(str)) { + info( + `New string is substring of value: ${str.length}, ${input.value.length}` + ); + input.setSelectionRange(str.length, input.value.length); + await EventUtils.synthesizeKey("KEY_Backspace", {}, content); + } else if (str.startsWith(input.value)) { + info( + `New string appends to value: ${input.value}, ${str.substring( + input.value.length + )}` + ); + input.setSelectionRange(input.value.length, input.value.length); + await EventUtils.sendString(str.substring(input.value.length), content); + } else { + input.select(); + await EventUtils.sendString(str, content); + } + + if (shouldBlur) { + let changedPromise = ContentTaskUtils.waitForEvent(input, "change"); + input.blur(); + await changedPromise; + } + + Assert.equal(str, input.value, `Expected value '${str}' is set on input`); + } + ); + info("Input value changed"); + await TestUtils.waitForTick(); +} + +async function verifyConfirmationHint( + browser, + forceClose, + anchorID = "password-notification-icon" +) { + let hintElem = browser.ownerGlobal.ConfirmationHint._panel; + await BrowserTestUtils.waitForPopupEvent(hintElem, "shown"); + try { + Assert.equal(hintElem.state, "open", "hint popup is open"); + Assert.ok( + BrowserTestUtils.is_visible(hintElem.anchorNode), + "hint anchorNode is visible" + ); + Assert.equal( + hintElem.anchorNode.id, + anchorID, + "Hint should be anchored on the expected notification icon" + ); + info("verifyConfirmationHint, hint is shown and has its anchorNode"); + if (forceClose) { + await closePopup(hintElem); + } else { + info("verifyConfirmationHint, assertion ok, wait for poopuphidden"); + await BrowserTestUtils.waitForPopupEvent(hintElem, "hidden"); + info("verifyConfirmationHint, hintElem popup is hidden"); + } + } catch (ex) { + Assert.ok(false, "Confirmation hint not shown: " + ex.message); + } finally { + info("verifyConfirmationHint promise finalized"); + } +} diff --git a/toolkit/components/passwordmgr/test/browser/insecure_test.html b/toolkit/components/passwordmgr/test/browser/insecure_test.html new file mode 100644 index 0000000000..fedea1428e --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/insecure_test.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_1.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_1.html new file mode 100644 index 0000000000..c90fb083ae --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_1.html @@ -0,0 +1,29 @@ + + + + + Subtest for Login Manager notifications - Basic 1un 1pw + + +

Subtest 1

+
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_10.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_10.html new file mode 100644 index 0000000000..7eca613bc3 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_10.html @@ -0,0 +1,27 @@ + + + + + Subtest for Login Manager notifications + + +

Subtest 10

+
+ + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_11.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_11.html new file mode 100644 index 0000000000..cd29d0536f --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_11.html @@ -0,0 +1,26 @@ + + + + + Subtest for Login Manager notifications - Popup Windows + + +

Subtest 11 (popup windows)

+ + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_11_popup.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_11_popup.html new file mode 100644 index 0000000000..67aa3fafd8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_11_popup.html @@ -0,0 +1,32 @@ + + + + + Subtest for Login Manager notifications + + +

Subtest 11

+
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_12_target_blank.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_12_target_blank.html new file mode 100644 index 0000000000..06c924c2ab --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_12_target_blank.html @@ -0,0 +1,31 @@ + + + + + target="_blank" subtest for Login Manager notifications + + +

Subtest 12 - target="_blank"

+
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_2.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2.html new file mode 100644 index 0000000000..e732252c16 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2.html @@ -0,0 +1,30 @@ + + + + + Subtest for Login Manager notifications - autocomplete=off on the username field + + +

Subtest 2

+(username autocomplete=off) +
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_0un.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_0un.html new file mode 100644 index 0000000000..f6a4b1bf78 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_0un.html @@ -0,0 +1,27 @@ + + + + + Subtest for Login Manager notifications with 2 password fields and no username + + +

Subtest 24

+
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_1un_1text.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_1un_1text.html new file mode 100644 index 0000000000..d5ccbc8d18 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_1un_1text.html @@ -0,0 +1,31 @@ + + + + + Subtest for Login Manager notifications with 2 password fields and 1 username field and one other text field before the first password field + + +

1 username field followed by a text field followed by 2 username fields

+
+ + + + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_3.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_3.html new file mode 100644 index 0000000000..1a5e6c3417 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_3.html @@ -0,0 +1,30 @@ + + + + + Subtest for Login Manager notifications - autocomplete=off on the password field + + +

Subtest 3

+(password autocomplete=off) +
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_4.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_4.html new file mode 100644 index 0000000000..2df721f995 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_4.html @@ -0,0 +1,30 @@ + + + + + Subtest for Login Manager notifications + + +

Subtest 4

+(form autocomplete=off) +
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_5.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_5.html new file mode 100644 index 0000000000..b0c5034272 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_5.html @@ -0,0 +1,26 @@ + + + + + Subtest for Login Manager notifications - Form with only a username field + + +

Subtest 5

+
+ + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_6.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_6.html new file mode 100644 index 0000000000..a90930bb59 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_6.html @@ -0,0 +1,27 @@ + + + + + Subtest for Login Manager notifications + + +

Subtest 6

+(password-only form) +
+ + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_8.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_8.html new file mode 100644 index 0000000000..7455d15d90 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_8.html @@ -0,0 +1,29 @@ + + + + + Subtest for Login Manager notifications + + +

Subtest 8

+
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_9.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_9.html new file mode 100644 index 0000000000..404b4f9a26 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_9.html @@ -0,0 +1,29 @@ + + + + + Subtest for Login Manager notifications + + +

Subtest 9

+
+ + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_notifications_change_p.html b/toolkit/components/passwordmgr/test/browser/subtst_notifications_change_p.html new file mode 100644 index 0000000000..8f52fe9682 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_notifications_change_p.html @@ -0,0 +1,32 @@ + + + + + Subtest for Login Manager notifications + + +

Change password

+
+ + + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/browser/subtst_privbrowsing_1.html b/toolkit/components/passwordmgr/test/browser/subtst_privbrowsing_1.html new file mode 100644 index 0000000000..2ef067ea43 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/subtst_privbrowsing_1.html @@ -0,0 +1,16 @@ + + + + + Test Login Manager notifications + + +

Test Login Manager notifications

+ +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/formsubmit.sjs b/toolkit/components/passwordmgr/test/formsubmit.sjs new file mode 100644 index 0000000000..f519234d36 --- /dev/null +++ b/toolkit/components/passwordmgr/test/formsubmit.sjs @@ -0,0 +1,37 @@ +function handleRequest(request, response) { + try { + reallyHandleRequest(request, response); + } catch (e) { + response.setStatusLine("1.0", 200, "AlmostOK"); + response.write("Error handling request: " + e); + } +} + +function reallyHandleRequest(request, response) { + let match; + + // XXX I bet this doesn't work for POST requests. + let query = request.queryString; + + let user = null, + pass = null; + // user=xxx + match = /user(?:name)?=([^&]*)/.exec(query); + if (match) { + user = match[1]; + } + + // pass=xxx + match = /pass(?:word)?=([^&]*)/.exec(query); + if (match) { + pass = match[1]; + } + + response.setStatusLine("1.0", 200, "OK"); + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write(""); + response.write("

User: " + user + "

\n"); + response.write("

Pass: " + pass + "

\n"); + response.write(""); +} diff --git a/toolkit/components/passwordmgr/test/mochitest/.eslintrc.js b/toolkit/components/passwordmgr/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..beb8ec4738 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/.eslintrc.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = { + globals: { + promptDone: true, + startTest: true, + // Make no-undef happy with our runInParent mixed environments since you + // can't indicate a single function is a new env. + assert: true, + addMessageListener: true, + sendAsyncMessage: true, + Assert: true, + }, + rules: { + "no-var": "off", + }, +}; diff --git a/toolkit/components/passwordmgr/test/mochitest/auth2/authenticate.sjs b/toolkit/components/passwordmgr/test/mochitest/auth2/authenticate.sjs new file mode 100644 index 0000000000..bc11bb29f8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/auth2/authenticate.sjs @@ -0,0 +1,216 @@ +function handleRequest(request, response) { + try { + reallyHandleRequest(request, response); + } catch (e) { + response.setStatusLine("1.0", 200, "AlmostOK"); + response.write("Error handling request: " + e); + } +} + +function reallyHandleRequest(request, response) { + let match; + let requestAuth = true, + requestProxyAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + let query = "?" + request.queryString; + + let expected_user = "", + expected_pass = "", + realm = "mochitest"; + let proxy_expected_user = "", + proxy_expected_pass = "", + proxy_realm = "mochi-proxy"; + let huge = false, + plugin = false, + anonymous = false; + let authHeaderCount = 1; + // user=xxx + match = /[^_]user=([^&]*)/.exec(query); + if (match) { + expected_user = match[1]; + } + + // pass=xxx + match = /[^_]pass=([^&]*)/.exec(query); + if (match) { + expected_pass = match[1]; + } + + // realm=xxx + match = /[^_]realm=([^&]*)/.exec(query); + if (match) { + realm = match[1]; + } + + // proxy_user=xxx + match = /proxy_user=([^&]*)/.exec(query); + if (match) { + proxy_expected_user = match[1]; + } + + // proxy_pass=xxx + match = /proxy_pass=([^&]*)/.exec(query); + if (match) { + proxy_expected_pass = match[1]; + } + + // proxy_realm=xxx + match = /proxy_realm=([^&]*)/.exec(query); + if (match) { + proxy_realm = match[1]; + } + + // huge=1 + match = /huge=1/.exec(query); + if (match) { + huge = true; + } + + // plugin=1 + match = /plugin=1/.exec(query); + if (match) { + plugin = true; + } + + // multiple=1 + match = /multiple=([^&]*)/.exec(query); + if (match) { + authHeaderCount = match[1] + 0; + } + + // anonymous=1 + match = /anonymous=1/.exec(query); + if (match) { + anonymous = true; + } + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + let actual_user = "", + actual_pass = "", + authHeader, + authPresent = false; + if (request.hasHeader("Authorization")) { + authPresent = true; + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + + let userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + actual_user = match[1]; + actual_pass = match[2]; + } + + let proxy_actual_user = "", + proxy_actual_pass = ""; + if (request.hasHeader("Proxy-Authorization")) { + authHeader = request.getHeader("Proxy-Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + + let userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + proxy_actual_user = match[1]; + proxy_actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && expected_pass == actual_pass) { + requestAuth = false; + } + if ( + proxy_expected_user == proxy_actual_user && + proxy_expected_pass == proxy_actual_pass + ) { + requestProxyAuth = false; + } + + if (anonymous) { + if (authPresent) { + response.setStatusLine( + "1.0", + 400, + "Unexpected authorization header found" + ); + } else { + response.setStatusLine("1.0", 200, "Authorization header not found"); + } + } else if (requestProxyAuth) { + response.setStatusLine("1.0", 407, "Proxy authentication required"); + for (let i = 0; i < authHeaderCount; ++i) { + response.setHeader( + "Proxy-Authenticate", + 'basic realm="' + proxy_realm + '"', + true + ); + } + } else if (requestAuth) { + response.setStatusLine("1.0", 401, "Authentication required"); + for (let i = 0; i < authHeaderCount; ++i) { + response.setHeader( + "WWW-Authenticate", + 'basic realm="' + realm + '"', + true + ); + } + } else { + response.setStatusLine("1.0", 200, "OK"); + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write(""); + response.write( + "

Login: " + + (requestAuth ? "FAIL" : "PASS") + + "

\n" + ); + response.write( + "

Proxy: " + + (requestProxyAuth ? "FAIL" : "PASS") + + "

\n" + ); + response.write("

Auth: " + authHeader + "

\n"); + response.write("

User: " + actual_user + "

\n"); + response.write("

Pass: " + actual_pass + "

\n"); + + if (huge) { + response.write("
"); + for (let i = 0; i < 100000; i++) { + response.write("123456789\n"); + } + response.write("
"); + response.write( + "This is a footnote after the huge content fill" + ); + } + + if (plugin) { + response.write( + "\n" + ); + } + + response.write(""); +} diff --git a/toolkit/components/passwordmgr/test/mochitest/chrome_timeout.js b/toolkit/components/passwordmgr/test/mochitest/chrome_timeout.js new file mode 100644 index 0000000000..25a797e1d2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/chrome_timeout.js @@ -0,0 +1,14 @@ +/* eslint-env mozilla/chrome-script */ + +const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); +addMessageListener("setTimeout", msg => { + timer.init( + _ => { + sendAsyncMessage("timeout"); + }, + msg.delay, + Ci.nsITimer.TYPE_ONE_SHOT + ); +}); + +sendAsyncMessage("ready"); diff --git a/toolkit/components/passwordmgr/test/mochitest/file_history_back.html b/toolkit/components/passwordmgr/test/mochitest/file_history_back.html new file mode 100644 index 0000000000..4e3e071a71 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/file_history_back.html @@ -0,0 +1,14 @@ + + + + + + + This page should navigate back in history upon load. + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/form_basic_bfcache.html b/toolkit/components/passwordmgr/test/mochitest/form_basic_bfcache.html new file mode 100644 index 0000000000..e91a88d599 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/form_basic_bfcache.html @@ -0,0 +1,61 @@ + + + + + + + + + + +
+ + + +
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html new file mode 100644 index 0000000000..ab558c4955 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html @@ -0,0 +1,31 @@ + + + + + +
+ + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_each_field_in_its_own_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_each_field_in_its_own_shadow_root.html new file mode 100644 index 0000000000..19edd12330 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_each_field_in_its_own_shadow_root.html @@ -0,0 +1,31 @@ + + + + + +
+ + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html new file mode 100644 index 0000000000..225fb4e7f8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_both_fields_together_in_a_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_both_fields_together_in_a_shadow_root.html new file mode 100644 index 0000000000..876c7d3b85 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_both_fields_together_in_a_shadow_root.html @@ -0,0 +1,34 @@ + + + + + +
+ + + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_each_field_in_its_own_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_each_field_in_its_own_shadow_root.html new file mode 100644 index 0000000000..9a844f236a --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_each_field_in_its_own_shadow_root.html @@ -0,0 +1,38 @@ + + + + + +
+ + + + + + + +
+ + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_form_and_fields_together_in_a_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_form_and_fields_together_in_a_shadow_root.html new file mode 100644 index 0000000000..79481c4a3a --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_form_and_fields_together_in_a_shadow_root.html @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html new file mode 100644 index 0000000000..ed261af9aa --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_each_field_in_its_own_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_each_field_in_its_own_shadow_root.html new file mode 100644 index 0000000000..b0ea0dc486 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_each_field_in_its_own_shadow_root.html @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html b/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html new file mode 100644 index 0000000000..a93e5ea752 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/mochitest.ini b/toolkit/components/passwordmgr/test/mochitest/mochitest.ini new file mode 100644 index 0000000000..feb825d412 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/mochitest.ini @@ -0,0 +1,267 @@ +[DEFAULT] +prefs = + signon.rememberSignons=true + signon.autofillForms.http=true + signon.showAutoCompleteFooter=true + signon.showAutoCompleteImport="" + signon.testOnlyUserHasInteractedByPrefValue=true + signon.testOnlyUserHasInteractedWithDocument=true + network.auth.non-web-content-triggered-resources-http-auth-allow=true + # signon.relatedRealms.enabled pref needed until Bug 1699698 lands + signon.relatedRealms.enabled=true + signon.usernameOnlyForm.enabled=true + signon.usernameOnlyForm.lookupThreshold=100 + +support-files = + ../../../prompts/test/chromeScript.js + !/toolkit/components/prompts/test/prompt_common.js + ../../../satchel/test/parent_utils.js + !/toolkit/components/satchel/test/satchel_common.js + ../blank.html + ../browser/form_autofocus_js.html + ../browser/form_basic.html + ../browser/formless_basic.html + ../browser/form_cross_origin_secure_action.html + ../browser/form_same_origin_action.html + auth2/authenticate.sjs + file_history_back.html + form_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html + form_basic_shadow_DOM_each_field_in_its_own_shadow_root.html + form_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html + form_nested_shadow_DOM_both_fields_together_in_a_shadow_root.html + form_nested_shadow_DOM_each_field_in_its_own_shadow_root.html + form_nested_shadow_DOM_form_and_fields_together_in_a_shadow_root.html + formless_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html + formless_basic_shadow_DOM_each_field_in_its_own_shadow_root.html + formless_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html + multiple_forms_shadow_DOM_all_known_variants.html + pwmgr_common.js + pwmgr_common_parent.js + ../authenticate.sjs +skip-if = toolkit == 'android' # Don't run on GeckoView + +# Note: new tests should use scheme = https unless they have a specific reason not to + +[test_autocomplete_autofill_related_realms_no_dupes.html] +skip-if = + fission && xorigin # Bug 1716412 - New fission platform triage +scheme = https +[test_autocomplete_basic_form.html] +skip-if = + toolkit == 'android' # autocomplete + debug && (os == 'linux' || os == 'win') # Bug 1541945 + os == 'linux' && tsan # Bug 1590928 + fission && xorigin && (!debug || os == "mac") # Bug 1716412 - New fission platform triage +scheme = https +[test_autocomplete_basic_form_insecure.html] +skip-if = + toolkit == 'android' # autocomplete + os == 'linux' # bug 1325778 + fission && xorigin && (os == "win" || os == "mac") # Bug 1716412 - New fission platform triage + win11_2009 # Bug 1781648 +[test_autocomplete_basic_form_formActionOrigin.html] +skip-if = toolkit == 'android' # android:autocomplete. +scheme = https +[test_autocomplete_basic_form_related_realms.html] +skip-if = + fission && xorigin # Bug 1716412 - New fission platform triage +scheme = https +[test_autocomplete_basic_form_subdomain.html] +skip-if = toolkit == 'android' # android:autocomplete. +scheme = https +[test_autocomplete_hasBeenTypePassword.html] +scheme = https +skip-if = toolkit == 'android' # autocomplete +[test_autocomplete_highlight.html] +scheme = https +skip-if = toolkit == 'android' # autocomplete +[test_autocomplete_highlight_non_login.html] +scheme = https +skip-if = toolkit == 'android' # autocomplete +[test_autocomplete_highlight_username_only_form.html] +scheme = https +skip-if = toolkit == 'android' # autocomplete +[test_autocomplete_https_downgrade.html] +scheme = http # Tests downgrading +skip-if = + toolkit == 'android' # autocomplete + os == 'linux' && debug # Bug 1554959 + fission && xorigin # Bug 1716412 - New fission platform triage +[test_autocomplete_https_upgrade.html] +scheme = https +skip-if = verify || toolkit == 'android' || (os == 'linux' && debug) # autocomplete && Bug 1554959 for linux debug disable +[test_autocomplete_password_generation.html] +scheme = https +skip-if = xorigin || toolkit == 'android' # autocomplete +[test_autocomplete_password_generation_confirm.html] +scheme = https +skip-if = toolkit == 'android' # autocomplete +[test_autocomplete_password_open.html] +scheme = https +skip-if = toolkit == 'android' || verify # autocomplete +[test_autocomplete_sandboxed.html] +scheme = https +skip-if = toolkit == 'android' # autocomplete +[test_autocomplete_tab_between_fields.html] +scheme = https +skip-if = + xorigin || toolkit == 'android' # autocomplete +[test_autofill_autocomplete_types.html] +scheme = https +skip-if = toolkit == 'android' # bug 1533965 +[test_autofill_different_formActionOrigin.html] +scheme = https +skip-if = toolkit == 'android' # Bug 1259768 +[test_autofill_different_subdomain.html] +scheme = https +skip-if = + toolkit == 'android' # Bug 1259768 + http3 +[test_autofill_from_bfcache.html] +scheme = https +skip-if = toolkit == 'android' # bug 1527403 +support-files = form_basic_bfcache.html +[test_autofill_hasBeenTypePassword.html] +scheme = https +[test_autofill_highlight.html] +scheme = https +skip-if = toolkit == 'android' # Bug 1531185 +[test_autofill_highlight_empty_username.html] +scheme = https +[test_autofill_highlight_username_only_form.html] +scheme = https +[test_autofill_https_downgrade.html] +scheme = http # we need http to test handling of https logins on http forms +skip-if = + http3 +[test_autofill_https_upgrade.html] +skip-if = + toolkit == 'android' # Bug 1259768 + http3 +[test_autofill_sandboxed.html] +scheme = https +skip-if = toolkit == 'android' +[test_autofill_password-only.html] +[test_autofill_username-only.html] +[test_autofill_username-only_threshold.html] +[test_autofocus_js.html] +scheme = https +skip-if = toolkit == 'android' # autocomplete +[test_basic_form.html] +[test_basic_form_0pw.html] +[test_basic_form_1pw.html] +[test_basic_form_1pw_2.html] +[test_basic_form_2pw_1.html] +[test_basic_form_2pw_2.html] +[test_basic_form_3pw_1.html] +[test_basic_form_honor_autocomplete_off.html] +scheme = https +skip-if = xorigin || toolkit == 'android' # android:autocomplete. +[test_formless_submit_form_removal.html] +skip-if = + http3 +[test_formless_submit_form_removal_negative.html] +skip-if = + http3 +[test_password_field_autocomplete.html] +skip-if = toolkit == 'android' # android:autocomplete. +[test_insecure_form_field_no_saved_login.html] +skip-if = toolkit == 'android' # android:autocomplete. +[test_basic_form_html5.html] +[test_basic_form_pwevent.html] +skip-if = xorigin +[test_basic_form_pwonly.html] +[test_bug_627616.html] +skip-if = + toolkit == 'android' # Tests desktop prompts + http3 +[test_bug_776171.html] +[test_case_differences.html] +skip-if = toolkit == 'android' # autocomplete +scheme = https +[test_dismissed_doorhanger_in_shadow_DOM.html] +skip-if = toolkit == 'android' # Tests desktop prompt +scheme = https +[test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html] +scheme = https +support-files = + slow_image.sjs + slow_image.html +[test_form_action_1.html] +[test_form_action_2.html] +[test_form_action_javascript.html] +[test_formless_autofill.html] +skip-if = + xorigin + http3 +[test_formless_submit.html] +skip-if = + toolkit == 'android' && debug # bug 1397615 + http3 +[test_formless_submit_navigation.html] +skip-if = + toolkit == 'android' && debug # bug 1397615 + http3 +[test_formless_submit_navigation_negative.html] +skip-if = + toolkit == 'android' && debug # bug 1397615 + http3 +[test_formLike_rootElement_with_Shadow_DOM.html] +scheme = https +[test_input_events.html] +skip-if = xorigin +[test_input_events_for_identical_values.html] +[test_LoginManagerContent_passwordEditedOrGenerated.html] +scheme = https +skip-if = toolkit == 'android' # password generation +[test_primary_password.html] +scheme = https +skip-if = os != 'mac' || verify || xorigin # Tests desktop prompts and bug 1333264 +support-files = + chrome_timeout.js + subtst_primary_pass.html +[test_maxlength.html] +[test_munged_values.html] +scheme = https +skip-if = toolkit == 'android' # bug 1527403 +[test_one_doorhanger_per_un_pw.html] +scheme = https +skip-if = toolkit == 'android' # bug 1535505 +[test_onsubmit_value_change.html] +[test_passwords_in_type_password.html] +[test_prompt.html] +skip-if = os == "linux" || toolkit == 'android' # Tests desktop prompts +[test_prompt_async.html] +skip-if = + toolkit == 'android' # Tests desktop prompts + http3 +support-files = subtst_prompt_async.html +[test_prompt_http.html] +skip-if = + toolkit == 'android' # Tests desktop prompts + os == "linux" + fission && xorigin # Bug 1716412 - New fission platform triage +[test_prompt_noWindow.html] +skip-if = toolkit == 'android' # Tests desktop prompts. +[test_password_length.html] +scheme = https +skip-if = toolkit == 'android' # bug 1527403 +[test_prompt_promptAuth.html] +skip-if = os == "linux" || toolkit == 'android' # Tests desktop prompts +[test_prompt_promptAuth_proxy.html] +skip-if = os == "linux" || toolkit == 'android' # Tests desktop prompts +[test_recipe_login_fields.html] +skip-if = xorigin +[test_submit_without_field_modifications.html] +support-files = + subtst_prefilled_form.html +skip-if = + xorigin + http3 +[test_username_focus.html] +skip-if = xorigin || toolkit == 'android' # android:autocomplete. +[test_xhr.html] +skip-if = toolkit == 'android' # Tests desktop prompts +[test_xhr_2.html] +[test_autofill_tab_between_fields.html] +scheme = https diff --git a/toolkit/components/passwordmgr/test/mochitest/multiple_forms_shadow_DOM_all_known_variants.html b/toolkit/components/passwordmgr/test/mochitest/multiple_forms_shadow_DOM_all_known_variants.html new file mode 100644 index 0000000000..5ba547e671 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/multiple_forms_shadow_DOM_all_known_variants.html @@ -0,0 +1,111 @@ + + + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js new file mode 100644 index 0000000000..81a31b0f4e --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js @@ -0,0 +1,1063 @@ +/** + * Helpers for password manager mochitest-plain tests. + */ + +/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ + +const { LoginTestUtils } = SpecialPowers.ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); +const Services = SpecialPowers.Services; + +// Setup LoginTestUtils to report assertions to the mochitest harness. +LoginTestUtils.setAssertReporter( + SpecialPowers.wrapCallback((err, message, stack) => { + SimpleTest.record(!err, err ? err.message : message, null, stack); + }) +); + +const { LoginHelper } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); + +const { LENGTH: GENERATED_PASSWORD_LENGTH, REGEX: GENERATED_PASSWORD_REGEX } = + LoginTestUtils.generation; +const LOGIN_FIELD_UTILS = LoginTestUtils.loginField; +const TESTS_DIR = "/tests/toolkit/components/passwordmgr/test/"; + +// Depending on pref state we either show auth prompts as windows or on tab level. +let authPromptModalType = SpecialPowers.Services.prefs.getIntPref( + "prompts.modalType.httpAuth" +); + +// Whether the auth prompt is a commonDialog.xhtml or a TabModalPrompt +let authPromptIsCommonDialog = + authPromptModalType === SpecialPowers.Services.prompt.MODAL_TYPE_WINDOW || + (authPromptModalType === SpecialPowers.Services.prompt.MODAL_TYPE_TAB && + SpecialPowers.Services.prefs.getBoolPref( + "prompts.tabChromePromptSubDialog", + false + )); + +/** + * Recreate a DOM tree using the outerHTML to ensure that any event listeners + * and internal state for the elements are removed. + */ +function recreateTree(element) { + // eslint-disable-next-line no-unsanitized/property, no-self-assign + element.outerHTML = element.outerHTML; +} + +function _checkArrayValues(actualValues, expectedValues, msg) { + is( + actualValues.length, + expectedValues.length, + "Checking array values: " + msg + ); + for (let i = 0; i < expectedValues.length; i++) { + is(actualValues[i], expectedValues[i], msg + " Checking array entry #" + i); + } +} + +/** + * Check autocomplete popup results to ensure that expected + * *labels* are being shown correctly as items in the popup. + */ +function checkAutoCompleteResults(actualValues, expectedValues, hostname, msg) { + if (hostname === null) { + _checkArrayValues(actualValues, expectedValues, msg); + return; + } + + is( + typeof hostname, + "string", + "checkAutoCompleteResults: hostname must be a string" + ); + + isnot( + actualValues.length, + 0, + "There should be items in the autocomplete popup: " + + JSON.stringify(actualValues) + ); + + // Check the footer first. + let footerResult = actualValues[actualValues.length - 1]; + is(footerResult, "View Saved Logins", "the footer text is shown correctly"); + + if (actualValues.length == 1) { + is( + expectedValues.length, + 0, + "If only the footer is present then there should be no expectedValues" + ); + info("Only the footer is present in the popup"); + return; + } + + // Check the rest of the autocomplete item values. + _checkArrayValues(actualValues.slice(0, -1), expectedValues, msg); +} + +function getIframeBrowsingContext(window, iframeNumber = 0) { + let bc = SpecialPowers.wrap(window).windowGlobalChild.browsingContext; + return SpecialPowers.unwrap(bc.children[iframeNumber]); +} + +/** + * Set input values via setUserInput to emulate user input + * and distinguish them from declarative or script-assigned values + */ +function setUserInputValues(parentNode, selectorValues, userInput = true) { + for (let [selector, newValue] of Object.entries(selectorValues)) { + info(`setUserInputValues, selector: ${selector}`); + try { + let field = SpecialPowers.wrap(parentNode.querySelector(selector)); + if (field.value == newValue) { + // we don't get an input event if the new value == the old + field.value += "#"; + } + if (userInput) { + field.setUserInput(newValue); + } else { + field.value = newValue; + } + } catch (ex) { + info(ex.message); + info(ex.stack); + ok( + false, + `setUserInputValues: Couldn't set value of field: ${ex.message}` + ); + } + } +} + +/** + * @param {Function} [aFilterFn = undefined] Function to filter out irrelevant submissions. + * @return {Promise} resolving when a relevant form submission was processed. + */ +function getSubmitMessage(aFilterFn = undefined) { + info("getSubmitMessage"); + return new Promise((resolve, reject) => { + PWMGR_COMMON_PARENT.addMessageListener( + "formSubmissionProcessed", + function processed(...args) { + if (aFilterFn && !aFilterFn(...args)) { + // This submission isn't the one we're waiting for. + return; + } + + info("got formSubmissionProcessed"); + PWMGR_COMMON_PARENT.removeMessageListener( + "formSubmissionProcessed", + processed + ); + resolve(args[0]); + } + ); + }); +} + +/** + * @return {Promise} resolves when a onPasswordEditedOrGenerated message is received at the parent + */ +function getPasswordEditedMessage() { + info("getPasswordEditedMessage"); + return new Promise((resolve, reject) => { + PWMGR_COMMON_PARENT.addMessageListener( + "passwordEditedOrGenerated", + function listener(...args) { + info("got passwordEditedOrGenerated"); + PWMGR_COMMON_PARENT.removeMessageListener( + "passwordEditedOrGenerated", + listener + ); + resolve(args[0]); + } + ); + }); +} + +/** + * Create a login form and insert into contents dom (identified by id + * `content`). If the form (identified by its number) is already present in the + * dom, it gets replaced. + * + * @param {number} [num = 1] - number of the form, used as id, eg `form1` + * @param {string} [action = ""] - action attribute of the form + * @param {string} [autocomplete = null] - forms autocomplete attribute. Default is none + * @param {object} [username = {}] - object describing attributes to the username field: + * @param {string} [username.id = null] - id of the field + * @param {string} [username.name = "uname"] - name attribute + * @param {string} [username.type = "text"] - type of the field + * @param {string} [username.value = null] - initial value of the field + * @param {string} [username.autocomplete = null] - autocomplete attribute + * @param {object} [password = {}] - an object describing attributes to the password field. If falsy, do not create a password field + * @param {string} [password.id = null] - id of the field + * @param {string} [password.name = "pword"] - name attribute + * @param {string} [password.type = "password"] - type of the field + * @param {string} [password.value = null] - initial value of the field + * @param {string} [password.label = null] - if present, wrap field in a label containing its value + * @param {string} [password.autocomplete = null] - autocomplete attribute + * + * @return {HTMLDomElement} the form + */ +function createLoginForm({ + num = 1, + action = "", + autocomplete = null, + username = {}, + password = {}, +} = {}) { + username.id ||= null; + username.name ||= "uname"; + username.type ||= "text"; + username.value ||= null; + username.autocomplete ||= null; + password.id ||= null; + password.name ||= "pword"; + password.type ||= "password"; + password.value ||= null; + password.label ||= null; + password.autocomplete ||= null; + + info( + `Creating login form ${JSON.stringify({ num, action, username, password })}` + ); + + const form = document.createElement("form"); + form.id = `form${num}`; + form.action = action; + form.onsubmit = () => false; + + if (autocomplete != null) { + form.setAttribute("autocomplete", autocomplete); + } + + const usernameInput = document.createElement("input"); + if (username.id != null) { + usernameInput.id = username.id; + } + usernameInput.type = username.type; + usernameInput.name = username.name; + if (username.value != null) { + usernameInput.value = username.value; + } + if (username.autocomplete != null) { + usernameInput.setAttribute("autocomplete", username.autocomplete); + } + form.appendChild(usernameInput); + + if (password) { + const passwordInput = document.createElement("input"); + if (password.id != null) { + passwordInput.id = password.id; + } + passwordInput.type = password.type; + passwordInput.name = password.name; + if (password.value != null) { + passwordInput.value = password.value; + } + if (password.autocomplete != null) { + passwordInput.setAttribute("autocomplete", password.autocomplete); + } + if (password.label != null) { + const passwordLabel = document.createElement("label"); + passwordLabel.innerText = password.label; + passwordLabel.appendChild(passwordInput); + form.appendChild(passwordLabel); + } else { + form.appendChild(passwordInput); + } + } + + const submitButton = document.createElement("button"); + submitButton.type = "submit"; + submitButton.name = "submit"; + submitButton.innerText = "Submit"; + form.appendChild(submitButton); + + const content = document.getElementById("content"); + + const oldForm = document.getElementById(form.id); + if (oldForm) { + content.replaceChild(form, oldForm); + } else { + content.appendChild(form); + } + + return form; +} + +/** + * Check for expected username/password in form. + * @see `checkForm` below for a similar function. + */ +function checkLoginForm( + usernameField, + expectedUsername, + passwordField, + expectedPassword +) { + let formID = usernameField.parentNode.id; + is( + usernameField.value, + expectedUsername, + "Checking " + formID + " username is: " + expectedUsername + ); + is( + passwordField.value, + expectedPassword, + "Checking " + formID + " password is: " + expectedPassword + ); +} + +/** + * Check repeatedly for a while to see if a particular condition still applies. + * This function checks the return value of `condition` repeatedly until either + * the condition has a falsy return value, or `retryTimes` is exceeded. + */ + +function ensureCondition( + condition, + errorMsg = "Condition did not last.", + retryTimes = 10 +) { + return new Promise((resolve, reject) => { + let tries = 0; + let conditionFailed = false; + let interval = setInterval(async function () { + try { + const conditionPassed = await condition(); + conditionFailed ||= !conditionPassed; + } catch (e) { + ok(false, e + "\n" + e.stack); + conditionFailed = true; + } + if (conditionFailed || tries >= retryTimes) { + ok(!conditionFailed, errorMsg); + clearInterval(interval); + if (conditionFailed) { + reject(errorMsg); + } else { + resolve(); + } + } + tries++; + }, 100); + }); +} + +/** + * Wait a while to ensure login form stays filled with username and password + * @see `checkLoginForm` below for a similar function. + * @returns a promise, resolving when done + * + * TODO: eventually get rid of this time based check, and transition to an + * event based approach. See Bug 1811142. + * Filling happens by `_fillForm()` which can report it's decision and we can + * wait for it. One of the options is to have `didFillFormAsync()` from + * https://phabricator.services.mozilla.com/D167214#change-3njWgUgqswws + */ +function ensureLoginFormStaysFilledWith( + usernameField, + expectedUsername, + passwordField, + expectedPassword +) { + return ensureCondition(() => { + return ( + Object.is(usernameField.value, expectedUsername) && + Object.is(passwordField.value, expectedPassword) + ); + }, `Ensuring form ${usernameField.parentNode.id} stays filled with "${expectedUsername}:${expectedPassword}"`); +} + +function checkLoginFormInFrame( + iframeBC, + usernameFieldId, + expectedUsername, + passwordFieldId, + expectedPassword +) { + return SpecialPowers.spawn( + iframeBC, + [usernameFieldId, expectedUsername, passwordFieldId, expectedPassword], + ( + usernameFieldIdF, + expectedUsernameF, + passwordFieldIdF, + expectedPasswordF + ) => { + let usernameField = + this.content.document.getElementById(usernameFieldIdF); + let passwordField = + this.content.document.getElementById(passwordFieldIdF); + + let formID = usernameField.parentNode.id; + Assert.equal( + usernameField.value, + expectedUsernameF, + "Checking " + formID + " username is: " + expectedUsernameF + ); + Assert.equal( + passwordField.value, + expectedPasswordF, + "Checking " + formID + " password is: " + expectedPasswordF + ); + } + ); +} + +async function checkUnmodifiedFormInFrame(bc, formNum) { + return SpecialPowers.spawn(bc, [formNum], formNumF => { + let form = this.content.document.getElementById(`form${formNumF}`); + ok(form, "Locating form " + formNumF); + + for (var i = 0; i < form.elements.length; i++) { + var ele = form.elements[i]; + + // No point in checking form submit/reset buttons. + if (ele.type == "submit" || ele.type == "reset") { + continue; + } + + is( + ele.value, + ele.defaultValue, + "Test to default value of field " + ele.name + " in form " + formNumF + ); + } + }); +} + +/** + * Check a form for expected values even if it is in a different top level window + * or process. If an argument is null, a field's expected value will be the default + * value. + * + * Similar to the checkForm helper, but it works across (cross-origin) frames. + * + *
+ * checkLoginFormInFrameWithElementValues(#, "foo"); + */ +async function checkLoginFormInFrameWithElementValues( + browsingContext, + formNum, + ...values +) { + return SpecialPowers.spawn( + browsingContext, + [formNum, values], + function checkFormWithElementValues(formNumF, valuesF) { + let [val1F, val2F, val3F] = valuesF; + let doc = this.content.document; + let e; + let form = doc.getElementById("form" + formNumF); + ok(form, "Locating form " + formNumF); + + let numToCheck = arguments.length - 1; + + if (!numToCheck--) { + return; + } + e = form.elements[0]; + if (val1F == null) { + is( + e.value, + e.defaultValue, + "Test default value of field " + e.name + " in form " + formNumF + ); + } else { + is( + e.value, + val1F, + "Test value of field " + e.name + " in form " + formNumF + ); + } + + if (!numToCheck--) { + return; + } + + e = form.elements[1]; + if (val2F == null) { + is( + e.value, + e.defaultValue, + "Test default value of field " + e.name + " in form " + formNumF + ); + } else { + is( + e.value, + val2F, + "Test value of field " + e.name + " in form " + formNumF + ); + } + + if (!numToCheck--) { + return; + } + e = form.elements[2]; + if (val3F == null) { + is( + e.value, + e.defaultValue, + "Test default value of field " + e.name + " in form " + formNumF + ); + } else { + is( + e.value, + val3F, + "Test value of field " + e.name + " in form " + formNumF + ); + } + } + ); +} + +/** + * Check a form for expected values. If an argument is null, a field's + * expected value will be the default value. + * + * + * checkForm(#, "foo"); + */ +function checkForm(formNum, val1, val2, val3) { + var e, + form = document.getElementById("form" + formNum); + ok(form, "Locating form " + formNum); + + var numToCheck = arguments.length - 1; + + if (!numToCheck--) { + return; + } + e = form.elements[0]; + if (val1 == null) { + is( + e.value, + e.defaultValue, + "Test default value of field " + e.name + " in form " + formNum + ); + } else { + is(e.value, val1, "Test value of field " + e.name + " in form " + formNum); + } + + if (!numToCheck--) { + return; + } + e = form.elements[1]; + if (val2 == null) { + is( + e.value, + e.defaultValue, + "Test default value of field " + e.name + " in form " + formNum + ); + } else { + is(e.value, val2, "Test value of field " + e.name + " in form " + formNum); + } + + if (!numToCheck--) { + return; + } + e = form.elements[2]; + if (val3 == null) { + is( + e.value, + e.defaultValue, + "Test default value of field " + e.name + " in form " + formNum + ); + } else { + is(e.value, val3, "Test value of field " + e.name + " in form " + formNum); + } +} + +/** + * Check a form for unmodified values from when page was loaded. + * + * + * checkUnmodifiedForm(#); + */ +function checkUnmodifiedForm(formNum) { + var form = document.getElementById("form" + formNum); + ok(form, "Locating form " + formNum); + + for (var i = 0; i < form.elements.length; i++) { + var ele = form.elements[i]; + + // No point in checking form submit/reset buttons. + if (ele.type == "submit" || ele.type == "reset") { + continue; + } + + is( + ele.value, + ele.defaultValue, + "Test to default value of field " + ele.name + " in form " + formNum + ); + } +} + +/** + * Wait for the document to be ready and any existing password fields on + * forms to be processed. + * + * @param existingPasswordFieldsCount the number of password fields + * that begin on the test page. + */ +function registerRunTests(existingPasswordFieldsCount = 0) { + return new Promise(resolve => { + function onDOMContentLoaded() { + var form = document.createElement("form"); + form.id = "observerforcer"; + var username = document.createElement("input"); + username.name = "testuser"; + form.appendChild(username); + var password = document.createElement("input"); + password.name = "testpass"; + password.type = "password"; + form.appendChild(password); + + let foundForcer = false; + var observer = SpecialPowers.wrapCallback(function ( + subject, + topic, + data + ) { + if (data === "observerforcer") { + foundForcer = true; + } else { + existingPasswordFieldsCount--; + } + + if (!foundForcer || existingPasswordFieldsCount > 0) { + return; + } + + SpecialPowers.removeObserver(observer, "passwordmgr-processed-form"); + form.remove(); + SimpleTest.executeSoon(() => { + var runTestEvent = new Event("runTests"); + window.dispatchEvent(runTestEvent); + resolve(); + }); + }); + SpecialPowers.addObserver(observer, "passwordmgr-processed-form"); + + document.body.appendChild(form); + } + // We provide a general mechanism for our tests to know when they can + // safely run: we add a final form that we know will be filled in, wait + // for the login manager to tell us that it's filled in and then continue + // with the rest of the tests. + if ( + document.readyState == "complete" || + document.readyState == "interactive" + ) { + onDOMContentLoaded(); + } else { + window.addEventListener("DOMContentLoaded", onDOMContentLoaded); + } + }); +} + +function enablePrimaryPassword() { + setPrimaryPassword(true); +} + +function disablePrimaryPassword() { + setPrimaryPassword(false); +} + +function setPrimaryPassword(enable) { + PWMGR_COMMON_PARENT.sendAsyncMessage("setPrimaryPassword", { enable }); +} + +function isLoggedIn() { + return PWMGR_COMMON_PARENT.sendQuery("isLoggedIn"); +} + +function logoutPrimaryPassword() { + runInParent(function parent_logoutPrimaryPassword() { + var sdr = Cc["@mozilla.org/security/sdr;1"].getService( + Ci.nsISecretDecoderRing + ); + sdr.logoutAndTeardown(); + }); +} + +/** + * Resolves when a specified number of forms have been processed for (potential) filling. + * This relies on the observer service which only notifies observers within the same process. + */ +function promiseFormsProcessedInSameProcess(expectedCount = 1) { + var processedCount = 0; + return new Promise((resolve, reject) => { + function onProcessedForm(subject, topic, data) { + processedCount++; + if (processedCount == expectedCount) { + info(`${processedCount} form(s) processed`); + SpecialPowers.removeObserver( + onProcessedForm, + "passwordmgr-processed-form" + ); + resolve(SpecialPowers.Cu.waiveXrays(subject), data); + } + } + SpecialPowers.addObserver(onProcessedForm, "passwordmgr-processed-form"); + }); +} + +/** + * Resolves when a form has been processed for (potential) filling. + * This works across processes. + */ +async function promiseFormsProcessed(expectedCount = 1) { + info(`waiting for ${expectedCount} forms to be processed`); + var processedCount = 0; + return new Promise(resolve => { + PWMGR_COMMON_PARENT.addMessageListener( + "formProcessed", + function formProcessed() { + processedCount++; + info(`processed form ${processedCount} of ${expectedCount}`); + if (processedCount == expectedCount) { + info(`processing of ${expectedCount} forms complete`); + PWMGR_COMMON_PARENT.removeMessageListener( + "formProcessed", + formProcessed + ); + resolve(); + } + } + ); + }); +} + +async function loadFormIntoWindow(origin, html, win, expectedCount = 1, task) { + let loadedPromise = new Promise(resolve => { + win.addEventListener( + "load", + function (event) { + if (event.target.location.href.endsWith("blank.html")) { + resolve(); + } + }, + { once: true } + ); + }); + + let processedPromise = promiseFormsProcessed(expectedCount); + win.location = + origin + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"; + info(`Waiting for window to load for origin: ${origin}`); + await loadedPromise; + + await SpecialPowers.spawn( + win, + [html, task?.toString()], + function (contentHtml, contentTask = null) { + // eslint-disable-next-line no-unsanitized/property + this.content.document.documentElement.innerHTML = contentHtml; + // Similar to the invokeContentTask helper in accessible/tests/browser/shared-head.js + if (contentTask) { + // eslint-disable-next-line no-eval + const runnableTask = eval(` + (() => { + return (${contentTask}); + })();`); + runnableTask.call(this); + } + } + ); + + info("Waiting for the form to be processed"); + await processedPromise; +} + +function getTelemetryEvents(options) { + return new Promise(resolve => { + PWMGR_COMMON_PARENT.addMessageListener( + "getTelemetryEvents", + function gotResult(events) { + info( + "CONTENT: getTelemetryEvents gotResult: " + JSON.stringify(events) + ); + PWMGR_COMMON_PARENT.removeMessageListener( + "getTelemetryEvents", + gotResult + ); + resolve(events); + } + ); + PWMGR_COMMON_PARENT.sendAsyncMessage("getTelemetryEvents", options); + }); +} + +function loadRecipes(recipes) { + info("Loading recipes"); + return new Promise(resolve => { + PWMGR_COMMON_PARENT.addMessageListener("loadedRecipes", function loaded() { + PWMGR_COMMON_PARENT.removeMessageListener("loadedRecipes", loaded); + resolve(recipes); + }); + PWMGR_COMMON_PARENT.sendAsyncMessage("loadRecipes", recipes); + }); +} + +function resetRecipes() { + info("Resetting recipes"); + return new Promise(resolve => { + PWMGR_COMMON_PARENT.addMessageListener("recipesReset", function reset() { + PWMGR_COMMON_PARENT.removeMessageListener("recipesReset", reset); + resolve(); + }); + PWMGR_COMMON_PARENT.sendAsyncMessage("resetRecipes"); + }); +} + +function resetWebsitesWithSharedCredential() { + info("Resetting the 'websites-with-shared-credential-backend' collection"); + return new Promise(resolve => { + PWMGR_COMMON_PARENT.addMessageListener( + "resetWebsitesWithSharedCredential", + function reset() { + PWMGR_COMMON_PARENT.removeMessageListener( + "resetWebsitesWithSharedCredential", + reset + ); + resolve(); + } + ); + PWMGR_COMMON_PARENT.sendAsyncMessage("resetWebsitesWithSharedCredential"); + }); +} + +function promiseStorageChanged(expectedChangeTypes) { + return new Promise((resolve, reject) => { + function onStorageChanged({ topic, data }) { + let changeType = expectedChangeTypes.shift(); + is(data, changeType, "Check expected passwordmgr-storage-changed type"); + if (expectedChangeTypes.length === 0) { + PWMGR_COMMON_PARENT.removeMessageListener( + "storageChanged", + onStorageChanged + ); + resolve(); + } + } + PWMGR_COMMON_PARENT.addMessageListener("storageChanged", onStorageChanged); + }); +} + +function promisePromptShown(expectedTopic) { + return new Promise((resolve, reject) => { + function onPromptShown({ topic, data }) { + is(topic, expectedTopic, "Check expected prompt topic"); + PWMGR_COMMON_PARENT.removeMessageListener("promptShown", onPromptShown); + resolve(); + } + PWMGR_COMMON_PARENT.addMessageListener("promptShown", onPromptShown); + }); +} + +/** + * Run a function synchronously in the parent process and destroy it in the test cleanup function. + * @param {Function|String} aFunctionOrURL - either a function that will be stringified and run + * or the URL to a JS file. + * @return {Object} - the return value of loadChromeScript providing message-related methods. + * @see loadChromeScript in specialpowersAPI.js + */ +function runInParent(aFunctionOrURL) { + let chromeScript = SpecialPowers.loadChromeScript(aFunctionOrURL); + SimpleTest.registerCleanupFunction(() => { + chromeScript.destroy(); + }); + return chromeScript; +} + +/** Manage logins in parent chrome process. + * */ +function manageLoginsInParent() { + return runInParent(function addLoginsInParentInner() { + /* eslint-env mozilla/chrome-script */ + addMessageListener("removeAllUserFacingLogins", () => { + Services.logins.removeAllUserFacingLogins(); + }); + + /* eslint-env mozilla/chrome-script */ + addMessageListener("addLogins", async logins => { + let nsLoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + + const loginInfos = logins.map(login => new nsLoginInfo(...login)); + try { + await Services.logins.addLogins(loginInfos); + } catch (e) { + assert.ok(false, "addLogins threw: " + e); + } + }); + }); +} + +/** Initialize with a list of logins. The logins are added within the parent chrome process. + * @param {array} aLogins - a list of logins to add. Each login is an array of the arguments + * that would be passed to nsLoginInfo.init(). + */ +async function addLoginsInParent(...aLogins) { + const script = manageLoginsInParent(); + await script.sendQuery("addLogins", aLogins); + return script; +} + +/** Initialize with a list of logins, after removing all user facing logins. + * The logins are added within the parent chrome process. + * @param {array} aLogins - a list of logins to add. Each login is an array of the arguments + * that would be passed to nsLoginInfo.init(). + */ +async function setStoredLoginsAsync(...aLogins) { + const script = manageLoginsInParent(); + script.sendQuery("removeAllUserFacingLogins"); + await script.sendQuery("addLogins", aLogins); + return script; +} + +/* + * gTestDependsOnDeprecatedLogin Set this global to true if your test relies + * on the testuser/testpass login that is created in pwmgr_common.js. New tests + * should not rely on this login. + */ +var gTestDependsOnDeprecatedLogin = false; + +/** + * Replace the content innerHTML with the provided form and wait for autofill to fill in the form. + * + * @param {string} form The form to be appended to the #content element. + * @param {string} fieldSelector The CSS selector for the field to-be-filled + * @param {string} fieldValue The value expected to be filled + * @param {string} formId The ID (excluding the # character) of the form + */ +function setFormAndWaitForFieldFilled( + form, + { fieldSelector, fieldValue, formId } +) { + // eslint-disable-next-line no-unsanitized/property + document.querySelector("#content").innerHTML = form; + return SimpleTest.promiseWaitForCondition(() => { + let ancestor = formId + ? document.querySelector("#" + formId) + : document.documentElement; + return ancestor.querySelector(fieldSelector).value == fieldValue; + }, "Wait for password manager to fill form"); +} + +/** + * Run commonInit synchronously in the parent then run the test function after the runTests event. + * + * @param {Function} aFunction The test function to run + */ +function runChecksAfterCommonInit(aFunction = null) { + SimpleTest.waitForExplicitFinish(); + if (aFunction) { + window.addEventListener("runTests", aFunction); + PWMGR_COMMON_PARENT.addMessageListener("registerRunTests", () => + registerRunTests() + ); + } + PWMGR_COMMON_PARENT.sendAsyncMessage("setupParent", { + testDependsOnDeprecatedLogin: gTestDependsOnDeprecatedLogin, + }); + return PWMGR_COMMON_PARENT; +} + +// Begin code that runs immediately for all tests that include this file. + +const PWMGR_COMMON_PARENT = runInParent( + SimpleTest.getTestFileURL("pwmgr_common_parent.js") +); + +SimpleTest.registerCleanupFunction(() => { + SpecialPowers.flushPrefEnv(); + + PWMGR_COMMON_PARENT.sendAsyncMessage("cleanup"); + + runInParent(function cleanupParent() { + /* eslint-env mozilla/chrome-script */ + // eslint-disable-next-line no-shadow + const { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" + ); + + // Remove all logins and disabled hosts + Services.logins.removeAllUserFacingLogins(); + + let disabledHosts = Services.logins.getAllDisabledHosts(); + disabledHosts.forEach(host => + Services.logins.setLoginSavingEnabled(host, true) + ); + + let authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService( + Ci.nsIHttpAuthManager + ); + authMgr.clearAll(); + + // Check that it's not null, instead of truthy to catch it becoming undefined + // in a refactoring. + if (LoginManagerParent._recipeManager !== null) { + LoginManagerParent._recipeManager.reset(); + } + + // Cleanup PopupNotifications (if on a relevant platform) + let chromeWin = Services.wm.getMostRecentWindow("navigator:browser"); + if (chromeWin && chromeWin.PopupNotifications) { + let notes = chromeWin.PopupNotifications._currentNotifications; + if (notes.length) { + dump("Removing " + notes.length + " popup notifications.\n"); + } + for (let note of notes) { + note.remove(); + } + } + + // Clear events last in case the above cleanup records events. + Services.telemetry.clearEvents(); + }); +}); + +/** + * Proxy for Services.logins (nsILoginManager). + * Only supports arguments which support structured clone plus {nsILoginInfo} + * Assumes properties are methods. + */ +this.LoginManager = new Proxy( + {}, + { + get(target, prop, receiver) { + return (...args) => { + let loginInfoIndices = []; + let cloneableArgs = args.map((val, index) => { + if ( + SpecialPowers.call_Instanceof(val, SpecialPowers.Ci.nsILoginInfo) + ) { + loginInfoIndices.push(index); + return LoginHelper.loginToVanillaObject(val); + } + + return val; + }); + + return PWMGR_COMMON_PARENT.sendQuery("proxyLoginManager", { + args: cloneableArgs, + loginInfoIndices, + methodName: prop, + }); + }; + }, + } +); diff --git a/toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js new file mode 100644 index 0000000000..00173cf323 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js @@ -0,0 +1,249 @@ +/** + * Loaded as a frame script to do privileged things in mochitest-plain tests. + * See pwmgr_common.js for the content process companion. + */ + +/* eslint-env mozilla/chrome-script */ + +"use strict"; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); +var { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" +); +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); +if (LoginHelper.relatedRealmsEnabled) { + let rsPromise = + LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials(); + async () => { + await rsPromise; + }; +} +if (LoginHelper.improvedPasswordRulesEnabled) { + let rsPromise = LoginTestUtils.remoteSettings.setupImprovedPasswordRules({ + rules: "", + }); + async () => { + await rsPromise; + }; +} + +/** + * Init with a common login + * If selfFilling is true or non-undefined, fires an event at the page so that + * the test can start checking filled-in values. Tests that check observer + * notifications might be confused by this. + */ +async function commonInit(selfFilling, testDependsOnDeprecatedLogin) { + var pwmgr = Services.logins; + assert.ok(pwmgr != null, "Access LoginManager"); + + // Check that initial state has no logins + var logins = pwmgr.getAllLogins(); + assert.equal(logins.length, 0, "Not expecting logins to be present"); + var disabledHosts = pwmgr.getAllDisabledHosts(); + if (disabledHosts.length) { + assert.ok(false, "Warning: wasn't expecting disabled hosts to be present."); + for (var host of disabledHosts) { + pwmgr.setLoginSavingEnabled(host, true); + } + } + + if (testDependsOnDeprecatedLogin) { + // Add a login that's used in multiple tests + var login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + login.init( + "http://mochi.test:8888", + "http://mochi.test:8888", + null, + "testuser", + "testpass", + "uname", + "pword" + ); + await pwmgr.addLoginAsync(login); + } + + // Last sanity check + logins = pwmgr.getAllLogins(); + assert.equal( + logins.length, + testDependsOnDeprecatedLogin ? 1 : 0, + "Checking for successful init login" + ); + disabledHosts = pwmgr.getAllDisabledHosts(); + assert.equal(disabledHosts.length, 0, "Checking for no disabled hosts"); + + if (selfFilling) { + return; + } + + // Notify the content side that initialization is done and tests can start. + sendAsyncMessage("registerRunTests"); +} + +function dumpLogins() { + let logins = Services.logins.getAllLogins(); + assert.ok(true, "----- dumpLogins: have " + logins.length + " logins. -----"); + for (var i = 0; i < logins.length; i++) { + dumpLogin("login #" + i + " --- ", logins[i]); + } +} + +function dumpLogin(label, login) { + var loginText = ""; + loginText += "origin: "; + loginText += login.origin; + loginText += " / formActionOrigin: "; + loginText += login.formActionOrigin; + loginText += " / realm: "; + loginText += login.httpRealm; + loginText += " / user: "; + loginText += login.username; + loginText += " / pass: "; + loginText += login.password; + loginText += " / ufield: "; + loginText += login.usernameField; + loginText += " / pfield: "; + loginText += login.passwordField; + assert.ok(true, label + loginText); +} + +function onStorageChanged(subject, topic, data) { + sendAsyncMessage("storageChanged", { + topic, + data, + }); +} +Services.obs.addObserver(onStorageChanged, "passwordmgr-storage-changed"); + +function onPrompt(subject, topic, data) { + sendAsyncMessage("promptShown", { + topic, + data, + }); +} +Services.obs.addObserver(onPrompt, "passwordmgr-prompt-change"); +Services.obs.addObserver(onPrompt, "passwordmgr-prompt-save"); + +addMessageListener("cleanup", () => { + Services.obs.removeObserver(onStorageChanged, "passwordmgr-storage-changed"); + Services.obs.removeObserver(onPrompt, "passwordmgr-prompt-change"); + Services.obs.removeObserver(onPrompt, "passwordmgr-prompt-save"); + Services.logins.removeAllUserFacingLogins(); +}); + +// Begin message listeners + +addMessageListener( + "setupParent", + async ({ + selfFilling = false, + testDependsOnDeprecatedLogin = false, + } = {}) => { + await commonInit(selfFilling, testDependsOnDeprecatedLogin); + sendAsyncMessage("doneSetup"); + } +); + +addMessageListener("loadRecipes", async function (recipes) { + var recipeParent = await LoginManagerParent.recipeParentPromise; + await recipeParent.load(recipes); + sendAsyncMessage("loadedRecipes", recipes); +}); + +addMessageListener("resetRecipes", async function () { + let recipeParent = await LoginManagerParent.recipeParentPromise; + await recipeParent.reset(); + sendAsyncMessage("recipesReset"); +}); + +addMessageListener("getTelemetryEvents", options => { + options = Object.assign( + { + filterProps: {}, + clear: false, + }, + options + ); + let snapshots = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + options.clear + ); + let events = options.process in snapshots ? snapshots[options.process] : []; + + // event is array of values like: [22476,"pwmgr","autocomplete_field","generatedpassword"] + let keys = ["id", "category", "method", "object", "value"]; + events = events.filter(entry => { + for (let idx = 0; idx < keys.length; idx++) { + let key = keys[idx]; + if ( + key in options.filterProps && + options.filterProps[key] !== entry[idx] + ) { + return false; + } + } + return true; + }); + sendAsyncMessage("getTelemetryEvents", events); +}); + +addMessageListener("proxyLoginManager", msg => { + // Recreate nsILoginInfo objects from vanilla JS objects. + let recreatedArgs = msg.args.map((arg, index) => { + if (msg.loginInfoIndices.includes(index)) { + return LoginHelper.vanillaObjectToLogin(arg); + } + + return arg; + }); + + let rv = Services.logins[msg.methodName](...recreatedArgs); + if (rv instanceof Ci.nsILoginInfo) { + rv = LoginHelper.loginToVanillaObject(rv); + } else if ( + Array.isArray(rv) && + !!rv.length && + rv[0] instanceof Ci.nsILoginInfo + ) { + rv = rv.map(login => LoginHelper.loginToVanillaObject(login)); + } + return rv; +}); + +addMessageListener("isLoggedIn", () => { + // This can't use the LoginManager proxy in pwmgr_common.js since it's not a method. + return Services.logins.isLoggedIn; +}); + +addMessageListener("setPrimaryPassword", ({ enable }) => { + if (enable) { + LoginTestUtils.primaryPassword.enable(); + } else { + LoginTestUtils.primaryPassword.disable(); + } +}); + +LoginManagerParent.setListenerForTests((msg, { origin, data }) => { + if (msg == "ShowDoorhanger") { + sendAsyncMessage("formSubmissionProcessed", { origin, data }); + } else if (msg == "PasswordEditedOrGenerated") { + sendAsyncMessage("passwordEditedOrGenerated", { origin, data }); + } else if (msg == "FormProcessed") { + sendAsyncMessage("formProcessed", {}); + } +}); + +addMessageListener("cleanup", () => { + LoginManagerParent.setListenerForTests(null); +}); diff --git a/toolkit/components/passwordmgr/test/mochitest/slow_image.html b/toolkit/components/passwordmgr/test/mochitest/slow_image.html new file mode 100644 index 0000000000..172d592633 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/slow_image.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/slow_image.sjs b/toolkit/components/passwordmgr/test/mochitest/slow_image.sjs new file mode 100644 index 0000000000..b955e43f5d --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/slow_image.sjs @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// The delay time will not impact test running time, as the test does +// not wait for the "load" event. We just need to ensure the pwmgr code +// e.g. autofill happens before the delay time elapses. +const DELAY_MS = "5000"; + +let timer; + +function handleRequest(req, resp) { + resp.processAsync(); + resp.setHeader("Cache-Control", "no-cache", false); + resp.setHeader("Content-Type", "image/png", false); + + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + resp.write(""); + resp.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/toolkit/components/passwordmgr/test/mochitest/subtst_prefilled_form.html b/toolkit/components/passwordmgr/test/mochitest/subtst_prefilled_form.html new file mode 100644 index 0000000000..8a3e0b0240 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/subtst_prefilled_form.html @@ -0,0 +1,18 @@ + + + + + + + + + + +
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/subtst_primary_pass.html b/toolkit/components/passwordmgr/test/mochitest/subtst_primary_pass.html new file mode 100644 index 0000000000..dc892c50e2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/subtst_primary_pass.html @@ -0,0 +1,8 @@ +

MP subtest

+This form triggers a MP and gets filled in.
+
+Username:
+Password:
+
+ diff --git a/toolkit/components/passwordmgr/test/mochitest/subtst_prompt_async.html b/toolkit/components/passwordmgr/test/mochitest/subtst_prompt_async.html new file mode 100644 index 0000000000..39a72add56 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/subtst_prompt_async.html @@ -0,0 +1,12 @@ + + + + + Multiple auth request + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html b/toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html new file mode 100644 index 0000000000..4f4f0fedc2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html @@ -0,0 +1,73 @@ + + + + + Test the password manager code called on DOMInputPasswordAdded runs when it occurs between DOMContentLoaded and load events + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_LoginManagerContent_passwordEditedOrGenerated.html b/toolkit/components/passwordmgr/test/mochitest/test_LoginManagerContent_passwordEditedOrGenerated.html new file mode 100644 index 0000000000..3c8c92bd97 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_LoginManagerContent_passwordEditedOrGenerated.html @@ -0,0 +1,160 @@ + + + + + Test behavior of unmasking in LMC._passwordEditedOrGenerated + + + + + + + +

+
+

+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html
new file mode 100644
index 0000000000..243ea9f052
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html
@@ -0,0 +1,92 @@
+
+
+
+  
+  Test no duplicate logins using autofill/autocomplete with related realms
+  
+  
+  
+  
+  
+
+
+Login Manager test: no duplicate logins when using autofill and autocomplete with related realms
+

+
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form.html new file mode 100644 index 0000000000..a061171279 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form.html @@ -0,0 +1,894 @@ + + + + + Test basic login autocomplete + + + + + + + +Login Manager test: multiple login autocomplete + +

+ + +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_formActionOrigin.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_formActionOrigin.html new file mode 100644 index 0000000000..587d57215b --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_formActionOrigin.html @@ -0,0 +1,75 @@ + + + + + Test that logins with non-matching formActionOrigin appear in autocomplete dropdown + + + + + + + +Login Manager test: logins with non-matching formActionOrigin appear in autocomplete dropdown + + +

+ +
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_insecure.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_insecure.html new file mode 100644 index 0000000000..13b4e2170b --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_insecure.html @@ -0,0 +1,849 @@ + + + + + Test insecure form field autocomplete + + + + + + + +

+ + +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html new file mode 100644 index 0000000000..988cd05954 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html @@ -0,0 +1,106 @@ + + + + + Test login autocomplete with related realms + + + + + + + +Login Manager test: related realms autocomplete +

+
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_subdomain.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_subdomain.html new file mode 100644 index 0000000000..685c2044c8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_subdomain.html @@ -0,0 +1,117 @@ + + + + + Test that logins with non-exact match origin appear in autocomplete dropdown + + + + + + + +Login Manager test: logins with non-exact match origin appear in autocomplete dropdown +

+ + +
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_hasBeenTypePassword.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_hasBeenTypePassword.html new file mode 100644 index 0000000000..6e520952b2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_hasBeenTypePassword.html @@ -0,0 +1,101 @@ + + + + + Test that passwords are autocompleted into fields that were previously type=password + + + + + + + +Login Manager test: Test that passwords are autocompleted into fields that were previously type=password +

+ + +
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight.html new file mode 100644 index 0000000000..dfde95e1e4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight.html @@ -0,0 +1,87 @@ + + + + + Test form field autofill highlight + + + + + + + +

+
+
+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_non_login.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_non_login.html
new file mode 100644
index 0000000000..eb82e90656
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_non_login.html
@@ -0,0 +1,91 @@
+
+
+
+  
+  Test form field autofill highlight
+  
+  
+  
+  
+  
+
+
+

+
+
+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_username_only_form.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_username_only_form.html
new file mode 100644
index 0000000000..884bdc66d3
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_username_only_form.html
@@ -0,0 +1,56 @@
+
+
+
+  
+  Test form field autofill highlight
+  
+  
+  
+  
+  
+
+
+

+
+
+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_downgrade.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_downgrade.html
new file mode 100644
index 0000000000..109b3e91c6
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_downgrade.html
@@ -0,0 +1,105 @@
+
+
+
+  
+  Test autocomplete on an HTTPS page using upgraded HTTP logins
+  
+  
+  
+  
+  
+
+
+

+ + +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html new file mode 100644 index 0000000000..a0a81a8c88 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html @@ -0,0 +1,191 @@ + + + + + Test autocomplete on an HTTPS page using upgraded HTTP logins + + + + + + + +

+ + +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation.html new file mode 100644 index 0000000000..3555b9173d --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation.html @@ -0,0 +1,574 @@ + + + + + Test autofill and autocomplete on autocomplete=new-password fields + + + + + + + + +Login Manager test: autofill with autocomplete=new-password fields + +

+ + +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html new file mode 100644 index 0000000000..cd5d5952f6 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html @@ -0,0 +1,518 @@ + + + + + Test filling generated passwords into confirm password fields + + + + + + + +Login Manager test: filling generated passwords into confirm password fields + +

+ + +
+ + +
+ + + + +
+ + +
+ + + + + +
+ + +
+ + + + + + + + + +
+ + +
+ + + + + +
+
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_open.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_open.html new file mode 100644 index 0000000000..e792aa19af --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_open.html @@ -0,0 +1,90 @@ + + + + + Test password field autocomplete footer with and without logins + + + + + + + +

+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_sandboxed.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_sandboxed.html new file mode 100644 index 0000000000..229791109a --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_sandboxed.html @@ -0,0 +1,70 @@ + + + + + Test form field autocomplete in sandboxed documents (null principal) + + + + + + + +

+ + +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_tab_between_fields.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_tab_between_fields.html new file mode 100644 index 0000000000..9df3467621 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_tab_between_fields.html @@ -0,0 +1,167 @@ + + + + + Test autocomplete behavior when tabbing between form fields + + + + + + + +

+ + +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_autocomplete_types.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_autocomplete_types.html new file mode 100644 index 0000000000..c44d5a25ef --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_autocomplete_types.html @@ -0,0 +1,112 @@ + + + + + Test autofilling with autocomplete types (username, off, cc-type, etc.) + + + + + +Test autofilling with autocomplete types (username, off, cc-type, etc.) + +

+
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_different_formActionOrigin.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_different_formActionOrigin.html new file mode 100644 index 0000000000..240e250a19 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_different_formActionOrigin.html @@ -0,0 +1,91 @@ + + + + + Test autofill on an HTTPS page using upgraded HTTP logins with different formActionOrigin + + + + + + + +

+ + +
+
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_different_subdomain.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_different_subdomain.html new file mode 100644 index 0000000000..b914968d43 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_different_subdomain.html @@ -0,0 +1,150 @@ +xcod + + + + Test autofill on an HTTPS page using logins with different eTLD+1 + + + + + + + +

+ + +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_from_bfcache.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_from_bfcache.html new file mode 100644 index 0000000000..d0fcb16e18 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_from_bfcache.html @@ -0,0 +1,58 @@ + + + + + Test autofilling documents restored from the back/forward cache (bfcache) + + + + +

+ +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_hasBeenTypePassword.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_hasBeenTypePassword.html new file mode 100644 index 0000000000..2139d30e61 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_hasBeenTypePassword.html @@ -0,0 +1,64 @@ + + + + + Test no autofill into a password field that is no longer type=password + + + + + +Login Manager test: Test no autofill into a password field that is no longer type=password + + + +

+ +
+ +

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight.html
new file mode 100644
index 0000000000..3ca214841c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight.html
@@ -0,0 +1,58 @@
+
+
+
+  
+  Test form field autofill highlight
+  
+  
+  
+  
+
+
+

+
+
+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_empty_username.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_empty_username.html
new file mode 100644
index 0000000000..36fffb480b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_empty_username.html
@@ -0,0 +1,60 @@
+
+
+
+  
+  Test that filling an empty username into a form does not highlight the username element
+  
+  
+  
+  
+
+
+
+

+
+
+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_username_only_form.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_username_only_form.html
new file mode 100644
index 0000000000..67892a9ea4
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_username_only_form.html
@@ -0,0 +1,50 @@
+
+
+
+  
+  Test that filling a username into a username-only form does highlight the username element
+  
+  
+  
+  
+
+
+

+
+
+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_downgrade.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_downgrade.html
new file mode 100644
index 0000000000..cf7c8ca450
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_downgrade.html
@@ -0,0 +1,118 @@
+
+
+
+  
+  Test we don't autofill on an HTTP page using HTTPS logins
+  
+  
+  
+  
+
+
+
+

+ + +
+
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html new file mode 100644 index 0000000000..104f4ef144 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html @@ -0,0 +1,148 @@ + + + + + Test autofill on an HTTPS page using upgraded HTTP logins + + + + + + + +

+ + +
+
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html new file mode 100644 index 0000000000..755a1f3f1d --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html @@ -0,0 +1,135 @@ + + + + + Test password-only forms should prefer a password-only login when present + + + + + +Login Manager test: Bug 444968 + + +

+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_sandboxed.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_sandboxed.html new file mode 100644 index 0000000000..8fd6debd5c --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_sandboxed.html @@ -0,0 +1,100 @@ + + + + + Test form field autofill in sandboxed documents (null principal) + + + + + + + +

+ +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_tab_between_fields.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_tab_between_fields.html new file mode 100644 index 0000000000..53eb959d7b --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_tab_between_fields.html @@ -0,0 +1,154 @@ + + + + + Test autocomplete behavior when tabbing between form fields + + + + + + + + +

+ + +
+
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only.html new file mode 100644 index 0000000000..860c317409 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only.html @@ -0,0 +1,107 @@ + + + + + Test autofill on username-form + + + + + +Test autofill on username-form + + + +

+
+

+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only_threshold.html b/toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only_threshold.html new file mode 100644 index 0000000000..d9a2e08095 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only_threshold.html @@ -0,0 +1,83 @@ + + + + + Test autofill on username-form when the number of form exceeds the lookup threshold + + + + + +Test not autofill on username-form when the number of form exceeds the lookup threshold + + + +

+
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html b/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html
new file mode 100644
index 0000000000..803197c2a2
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html
@@ -0,0 +1,114 @@
+
+
+
+  
+  Test login autocomplete is activated when focused by js on load
+  
+  
+  
+  
+  
+
+
+

+ +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form.html new file mode 100644 index 0000000000..d13fd1369f --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form.html @@ -0,0 +1,48 @@ + + + + + Test basic autofill + + + + + +Login Manager test: simple form fill + + + +

+ + + +

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_0pw.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_0pw.html
new file mode 100644
index 0000000000..64450300d6
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_0pw.html
@@ -0,0 +1,70 @@
+
+
+
+  
+  Test forms with no password fields
+  
+  
+  
+  
+
+
+Login Manager test: forms with no password fields
+

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw.html new file mode 100644 index 0000000000..bb91d9cf3e --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw.html @@ -0,0 +1,171 @@ + + + + + Test autofill for forms with 1 password field + + + + + +Login Manager test: forms with 1 password field + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw_2.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw_2.html new file mode 100644 index 0000000000..d7aaadc895 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw_2.html @@ -0,0 +1,115 @@ + + + + + Test forms with 1 password field, part 2 + + + + + +Login Manager test: forms with 1 password field, part 2 + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_1.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_1.html new file mode 100644 index 0000000000..1d9224a819 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_1.html @@ -0,0 +1,190 @@ + + + + + Test autofill for forms with 2 password fields + + + + + +Login Manager test: forms with 2 password fields + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html new file mode 100644 index 0000000000..a2c60e5964 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html @@ -0,0 +1,111 @@ + + + + + Test for form fill with 2 password fields + + + + + + +Login Manager test: form fill, 2 password fields +

+ +
+
+
+ + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_3pw_1.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_3pw_1.html new file mode 100644 index 0000000000..59aec20612 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_3pw_1.html @@ -0,0 +1,259 @@ + + + + + Test autofill for forms with 3 password fields + + + + + +Login Manager test: forms with 3 password fields (form filling) + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_honor_autocomplete_off.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_honor_autocomplete_off.html new file mode 100644 index 0000000000..746c6cc923 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_honor_autocomplete_off.html @@ -0,0 +1,153 @@ + + + + + Test login autofill autocomplete when signon.autofillForms.autocompleteOff is false + + + + + + + +Login Manager test: autofilling when autocomplete=off +

+ + +
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_html5.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_html5.html new file mode 100644 index 0000000000..12aeb0bab3 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_html5.html @@ -0,0 +1,165 @@ + + + + + Test for html5 input types (email, tel, url, etc.) + + + + + +Login Manager test: html5 input types (email, tel, url, etc.) + + +

+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwevent.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwevent.html new file mode 100644 index 0000000000..4a202a7c05 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwevent.html @@ -0,0 +1,50 @@ + + + + + + Test for Bug 355063 + + + + + + +Mozilla Bug 355063 +

+
+forms go here! +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html new file mode 100644 index 0000000000..05e07983f1 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html @@ -0,0 +1,212 @@ + + + + + Test forms and logins without a username + + + + + +Login Manager test: forms and logins without a username. + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html b/toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html new file mode 100644 index 0000000000..63ff775aab --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html @@ -0,0 +1,161 @@ + + + + + Test bug 627616 related to proxy authentication + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_bug_776171.html b/toolkit/components/passwordmgr/test/mochitest/test_bug_776171.html new file mode 100644 index 0000000000..f7de66a01d --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_bug_776171.html @@ -0,0 +1,57 @@ + + + + + + Test for Bug 776171 related to HTTP auth + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_case_differences.html b/toolkit/components/passwordmgr/test/mochitest/test_case_differences.html new file mode 100644 index 0000000000..fd0d5b39f8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_case_differences.html @@ -0,0 +1,100 @@ + + + + + Test autocomplete due to multiple matching logins + + + + + + + +Login Manager test: autocomplete due to multiple matching logins +

+ +
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_dismissed_doorhanger_in_shadow_DOM.html b/toolkit/components/passwordmgr/test/mochitest/test_dismissed_doorhanger_in_shadow_DOM.html new file mode 100644 index 0000000000..37ddbaae42 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_dismissed_doorhanger_in_shadow_DOM.html @@ -0,0 +1,112 @@ + + + + + Test the password manager dismissed doorhanger can detect username and password fields in a Shadow DOM. + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formLike_rootElement_with_Shadow_DOM.html b/toolkit/components/passwordmgr/test/mochitest/test_formLike_rootElement_with_Shadow_DOM.html new file mode 100644 index 0000000000..2e9b0039ca --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_formLike_rootElement_with_Shadow_DOM.html @@ -0,0 +1,151 @@ + + + + + Test that FormLike.rootElement points to right element when the page has Shadow DOM + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_form_action_1.html b/toolkit/components/passwordmgr/test/mochitest/test_form_action_1.html new file mode 100644 index 0000000000..21f5f18904 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_form_action_1.html @@ -0,0 +1,140 @@ + + + + + Test for considering form action + + + + + +Login Manager test: Bug 360493 + +

+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_form_action_2.html b/toolkit/components/passwordmgr/test/mochitest/test_form_action_2.html new file mode 100644 index 0000000000..2eae0958ca --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_form_action_2.html @@ -0,0 +1,173 @@ + + + + + Test for considering form action + + + + + +Login Manager test: Bug 360493 + +

+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html b/toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html new file mode 100644 index 0000000000..3bb52ef8df --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html @@ -0,0 +1,47 @@ + + + + + Test forms with a JS submit action + + + + + +Login Manager test: form with JS submit action + + +

+ + + +

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html
new file mode 100644
index 0000000000..3422fa893a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html
@@ -0,0 +1,144 @@
+
+
+
+  
+  Test autofilling of fields outside of a form
+  
+  
+  
+
+
+
+
+

+ +
+ +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html
new file mode 100644
index 0000000000..cf1e82a85e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html
@@ -0,0 +1,243 @@
+
+
+
+  
+  Test capturing of fields outside of a form
+  
+  
+  
+
+
+
+
+

+ +
+ +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal.html
new file mode 100644
index 0000000000..56fc428ec3
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal.html
@@ -0,0 +1,292 @@
+
+
+
+  
+  Test capturing of fields due to form removal
+  
+  
+  
+
+
+
+
+

+ +
+ +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal_negative.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal_negative.html
new file mode 100644
index 0000000000..8a91553c85
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal_negative.html
@@ -0,0 +1,205 @@
+
+
+
+  
+  Test no capturing of fields outside of a form due to navigation
+  
+  
+  
+
+
+
+
+

+ +
+ +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html
new file mode 100644
index 0000000000..22fce4561e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html
@@ -0,0 +1,272 @@
+
+
+
+  
+  Test capturing of fields outside of a form due to navigation
+  
+  
+  
+
+
+
+
+

+ +
+ +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html
new file mode 100644
index 0000000000..4b437ced1b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html
@@ -0,0 +1,149 @@
+
+
+
+  
+  Test no capturing of fields outside of a form due to navigation
+  
+  
+  
+
+
+
+
+

+ +
+ +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_input_events.html b/toolkit/components/passwordmgr/test/mochitest/test_input_events.html
new file mode 100644
index 0000000000..2560c212d8
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_input_events.html
@@ -0,0 +1,56 @@
+
+
+
+  
+  Test for input events in Login Manager
+  
+  
+  
+  
+
+
+Login Manager test: input events should fire.
+

+ +

+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_input_events_for_identical_values.html b/toolkit/components/passwordmgr/test/mochitest/test_input_events_for_identical_values.html
new file mode 100644
index 0000000000..c6e378a516
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_input_events_for_identical_values.html
@@ -0,0 +1,52 @@
+
+
+
+  
+  Test for input events in Login Manager when username/password are filled in already
+  
+  
+  
+  
+  
+
+
+Login Manager test: input events should fire.
+
+
+
+

+ +
+ +
+

This is form 1.

+ + + + + +
+ +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_no_saved_login.html b/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_no_saved_login.html
new file mode 100644
index 0000000000..8fcb9df6f6
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_no_saved_login.html
@@ -0,0 +1,91 @@
+
+
+
+  
+  Test basic login, contextual inscure password warning without saved logins
+  
+  
+  
+  
+  
+
+
+Login Manager test: contextual inscure password warning without saved logins
+
+
+

+ + +
+ +
+ + + +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_maxlength.html b/toolkit/components/passwordmgr/test/mochitest/test_maxlength.html new file mode 100644 index 0000000000..a61812f6d3 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_maxlength.html @@ -0,0 +1,144 @@ + + + + + Test for maxlength attributes + + + + + +Login Manager test: Bug 391514 + +

+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_munged_values.html b/toolkit/components/passwordmgr/test/mochitest/test_munged_values.html new file mode 100644 index 0000000000..5afac7348b --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_munged_values.html @@ -0,0 +1,364 @@ + + + + + Test handling of possibly-manipulated username values + + + + + + + + +

+ +
+
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_one_doorhanger_per_un_pw.html b/toolkit/components/passwordmgr/test/mochitest/test_one_doorhanger_per_un_pw.html
new file mode 100644
index 0000000000..4d8dfd1fee
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_one_doorhanger_per_un_pw.html
@@ -0,0 +1,59 @@
+
+
+
+  
+  Don't repeatedly prompt to save the same username and password
+    combination in the same document
+  
+  
+  
+
+
+
+

+ + + +

+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_onsubmit_value_change.html b/toolkit/components/passwordmgr/test/mochitest/test_onsubmit_value_change.html
new file mode 100644
index 0000000000..f23940d34b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_onsubmit_value_change.html
@@ -0,0 +1,70 @@
+
+
+
+  
+  Test input value change right after onsubmit event
+  
+  
+  
+  
+
+
+Login Manager test: input value change right after onsubmit event
+
+
+

+ + + +

+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html b/toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html
new file mode 100644
index 0000000000..2db691b1bf
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html
@@ -0,0 +1,198 @@
+
+
+
+  
+  Test basic login autocomplete
+  
+  
+  
+  
+  
+
+
+Login Manager test: multiple login autocomplete
+
+
+

+ + +
+ + +
+

Sign in

+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_password_length.html b/toolkit/components/passwordmgr/test/mochitest/test_password_length.html new file mode 100644 index 0000000000..3edfc1a00a --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_password_length.html @@ -0,0 +1,150 @@ + + + + + Test handling of different password length + + + + + + + +

+ +
+ +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_passwords_in_type_password.html b/toolkit/components/passwordmgr/test/mochitest/test_passwords_in_type_password.html
new file mode 100644
index 0000000000..fcaeb0e455
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_passwords_in_type_password.html
@@ -0,0 +1,114 @@
+
+
+
+  
+  Test that passwords only get filled in type=password
+  
+  
+  
+
+
+Login Manager test: Bug 242956
+
+

+ +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_primary_password.html b/toolkit/components/passwordmgr/test/mochitest/test_primary_password.html new file mode 100644 index 0000000000..f8ffec57d0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_primary_password.html @@ -0,0 +1,298 @@ + + + + + Test for primary password + + + + + + +Login Manager test: primary password. + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt.html new file mode 100644 index 0000000000..115039c706 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt.html @@ -0,0 +1,669 @@ + + + + + Test prompter.{prompt,asyncPromptPassword,asyncPromptUsernameAndPassword} + + + + + + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_async.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_async.html new file mode 100644 index 0000000000..d9934a3d28 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_async.html @@ -0,0 +1,621 @@ + + + + + Test for Async Auth Prompt + + + + + + + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html new file mode 100644 index 0000000000..effe438d1c --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html @@ -0,0 +1,319 @@ + + + + + Test HTTP auth prompts by loading authenticate.sjs + + + + + + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html new file mode 100644 index 0000000000..5b7584e4fa --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html @@ -0,0 +1,72 @@ + + + + + Test HTTP auth prompts by loading authenticate.sjs with no window + + + + + + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html new file mode 100644 index 0000000000..08cbedab88 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html @@ -0,0 +1,370 @@ + + + + + Test promptAuth prompts + + + + + + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html new file mode 100644 index 0000000000..a7cec2d0b9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html @@ -0,0 +1,269 @@ + + + + + Test promptAuth proxy prompts + + + + + + +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_recipe_login_fields.html b/toolkit/components/passwordmgr/test/mochitest/test_recipe_login_fields.html new file mode 100644 index 0000000000..818a4e15bf --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_recipe_login_fields.html @@ -0,0 +1,212 @@ + + + + + Test for recipes overriding login fields + + + + + + + +

+ +
+ // Forms are inserted dynamically +
+

+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_submit_without_field_modifications.html b/toolkit/components/passwordmgr/test/mochitest/test_submit_without_field_modifications.html
new file mode 100644
index 0000000000..db06561b9d
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_submit_without_field_modifications.html
@@ -0,0 +1,313 @@
+
+
+
+  
+  Don't send onFormSubmit message on navigation if the user did not interact
+    with the login fields
+  
+  
+  
+
+
+

+ +
+ +
+ +

+
+
+
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html b/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html
new file mode 100644
index 0000000000..510cb2e1f1
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html
@@ -0,0 +1,166 @@
+
+
+
+
+  
+  Test interaction between autocomplete and focus on username fields
+  
+  
+  
+  
+  
+
+
+

+
+
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_xhr.html b/toolkit/components/passwordmgr/test/mochitest/test_xhr.html new file mode 100644 index 0000000000..ac573d2b02 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_xhr.html @@ -0,0 +1,164 @@ + + + + + Test for XHR prompts + + + + + + +Login Manager test: XHR prompt +

+ + + +
+
+
+ + diff --git a/toolkit/components/passwordmgr/test/mochitest/test_xhr_2.html b/toolkit/components/passwordmgr/test/mochitest/test_xhr_2.html new file mode 100644 index 0000000000..16d5e786e0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/mochitest/test_xhr_2.html @@ -0,0 +1,56 @@ + + + + + + Test XHR auth with user and pass arguments + + + + + + + + diff --git a/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite new file mode 100644 index 0000000000..b234246cac Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/key4.db b/toolkit/components/passwordmgr/test/unit/data/key4.db new file mode 100644 index 0000000000..b75a14aa8e Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/key4.db differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite new file mode 100644 index 0000000000..fe030b61fd Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v1.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite new file mode 100644 index 0000000000..729512a12b Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v1v2.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite new file mode 100644 index 0000000000..a6c72b31e8 Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v2.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite new file mode 100644 index 0000000000..359df5d311 Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v2v3.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite new file mode 100644 index 0000000000..918f4142fe Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v3.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite new file mode 100644 index 0000000000..e06c33aae3 Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v3v4.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite new file mode 100644 index 0000000000..227c09c816 Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v4.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite new file mode 100644 index 0000000000..4534cf2553 Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v4v5.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite new file mode 100644 index 0000000000..eb4ee6d01e Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v5v6.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite new file mode 100644 index 0000000000..e09c4f7100 Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v999-2.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite b/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite new file mode 100644 index 0000000000..0328a1a02a Binary files /dev/null and b/toolkit/components/passwordmgr/test/unit/data/signons-v999.sqlite differ diff --git a/toolkit/components/passwordmgr/test/unit/head.js b/toolkit/components/passwordmgr/test/unit/head.js new file mode 100644 index 0000000000..8c2dc53f66 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/head.js @@ -0,0 +1,134 @@ +/** + * Provides infrastructure for automated login components tests. + */ + +"use strict"; + +// Globals + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { LoginRecipesContent, LoginRecipesParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginRecipes.sys.mjs" +); +const { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); +const { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +const { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); +const { MockDocument } = ChromeUtils.importESModule( + "resource://testing-common/MockDocument.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(this, { + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" +); + +const TestData = LoginTestUtils.testData; +const newPropertyBag = LoginHelper.newPropertyBag; + +const NEW_PASSWORD_HEURISTIC_ENABLED_PREF = + "signon.generation.confidenceThreshold"; +const RELATED_REALMS_ENABLED_PREF = "signon.relatedRealms.enabled"; +const IMPROVED_PASSWORD_RULES_PREF = "signon.improvedPasswordRules.enabled"; +/** + * All the tests are implemented with add_task, this starts them automatically. + */ +function run_test() { + do_get_profile(); + run_next_test(); +} + +// Global helpers + +/** + * Returns a reference to a temporary file that is guaranteed not to exist and + * is cleaned up later. See FileTestUtils.getTempFile for details. + */ +function getTempFile(leafName) { + return FileTestUtils.getTempFile(leafName); +} + +const RecipeHelpers = { + initNewParent() { + return new LoginRecipesParent({ defaults: null }).initializationPromise; + }, +}; + +// Initialization functions common to all tests + +add_setup(async function test_common_initialize() { + // Before initializing the service for the first time, we should copy the key + // file required to decrypt the logins contained in the SQLite databases used + // by migration tests. This file is not required for the other tests. + const keyDBName = "key4.db"; + await IOUtils.copy( + do_get_file(`data/${keyDBName}`).path, + PathUtils.join(PathUtils.profileDir, keyDBName) + ); + + // Ensure that the service and the storage module are initialized. + await Services.logins.initializationPromise; + Services.prefs.setBoolPref(RELATED_REALMS_ENABLED_PREF, true); + if (LoginHelper.relatedRealmsEnabled) { + // Ensure that there is a mocked Remote Settings database for the + // "websites-with-shared-credential-backends" collection + await LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials(); + } +}); + +add_setup(async function test_common_prefs() { + Services.prefs.setStringPref(NEW_PASSWORD_HEURISTIC_ENABLED_PREF, "0.75"); +}); + +/** + * Compare two FormLike to see if they represent the same information. Elements + * are compared using their @id attribute. + */ +function formLikeEqual(a, b) { + Assert.strictEqual( + Object.keys(a).length, + Object.keys(b).length, + "Check the formLikes have the same number of properties" + ); + + for (let propName of Object.keys(a)) { + if (propName == "elements") { + Assert.strictEqual( + a.elements.length, + b.elements.length, + "Check element count" + ); + for (let i = 0; i < a.elements.length; i++) { + Assert.strictEqual( + a.elements[i].id, + b.elements[i].id, + "Check element " + i + " id" + ); + } + continue; + } + Assert.strictEqual( + a[propName], + b[propName], + "Compare formLike " + propName + " property" + ); + } +} diff --git a/toolkit/components/passwordmgr/test/unit/test_CSVParser.js b/toolkit/components/passwordmgr/test/unit/test_CSVParser.js new file mode 100644 index 0000000000..d680d8daf2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_CSVParser.js @@ -0,0 +1,254 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CSV } = ChromeUtils.importESModule( + "resource://gre/modules/CSV.sys.mjs" +); + +const TEST_CASES = [ + { + description: + "string with fields with no special characters gets parsed correctly", + csvString: ` +url,username,password +https://example.com/,testusername,testpassword +`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "testusername", + password: "testpassword", + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: + "string with fields enclosed in quotes with no special characters gets parsed correctly", + csvString: ` +"url","username","password" +"https://example.com/","testusername","testpassword" +`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "testusername", + password: "testpassword", + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: "empty fields gets parsed correctly", + csvString: ` +"url","username","password" +"https://example.com/","","" +`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "", + password: "", + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: "string with commas in fields gets parsed correctly", + csvString: ` +url,username,password +https://example.com/,"test,usern,ame","tes,,tpassword" +`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "test,usern,ame", + password: "tes,,tpassword", + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: "string with line break in fields gets parsed correctly", + csvString: ` +url,username,password +https://example.com/,"test\nusername","\ntestpass\n\nword" +`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "test\nusername", + password: "\ntestpass\n\nword", + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: "string with quotation mark in fields gets parsed correctly", + csvString: ` +url,username,password +https://example.com/,"testusern""ame","test""""pass""word" +`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: 'testusern"ame', + password: 'test""pass"word', + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: "tsv string with tab as delimiter gets parsed correctly", + csvString: ` +url\tusername\tpassword +https://example.com/\ttestusername\ttestpassword +`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "testusername", + password: "testpassword", + }, + ], + delimiter: "\t", + throwsError: false, + }, + { + description: "string with CR LF as line breaks gets parsed correctly", + csvString: + "url,username,password\r\nhttps://example.com/,testusername,testpassword\r\n", + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "testusername", + password: "testpassword", + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: + "string without line break at the end of the file gets parsed correctly", + csvString: ` +url,username,password +https://example.com/,testusername,testpassword`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "testusername", + password: "testpassword", + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: + "multiple line breaks at the beginning, in the middle or at the end of a string are trimmed and not parsed as empty rows", + csvString: ` +\r\r +url,username,password +\n\n +https://example.com/,testusername,testpassword +\n\r +`, + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [ + { + url: "https://example.com/", + username: "testusername", + password: "testpassword", + }, + ], + delimiter: ",", + throwsError: false, + }, + { + description: + "throws error when after a field, that is enclosed in quotes, follow any invalid characters (doesn't follow csv standard - RFC 4180)", + csvString: ` + url,username,password + https://example.com/,"testusername"outside,testpassword + `, + delimiter: ",", + throwsError: true, + }, + { + description: + "throws error when the closing quotation mark for a field is missing (doesn't follow csv standard - RFC 4180)", + csvString: ` +url,"username,password +https://example.com/,testusername,testpassword +`, + delimiter: ",", + throwsError: true, + }, + { + description: + "parsing empty csv file results in empty header line and empty parsedLines", + csvString: "", + expectedHeaderLine: [], + expectedParsedLines: [], + delimiter: ",", + throwsError: false, + }, + { + description: + "parsing csv file with only header line results in empty parsedLines", + csvString: "url,username,password\n", + expectedHeaderLine: ["url", "username", "password"], + expectedParsedLines: [], + delimiter: ",", + throwsError: false, + }, +]; + +async function parseCSVStringAndValidateResult(test) { + info(`Test case: ${test.description}`); + if (test.throwsError) { + Assert.throws( + () => CSV.parse(test.csvString, test.delimiter), + /Stopped parsing because of wrong csv format/ + ); + } else { + let [resultHeaderLine, resultParsedLines] = CSV.parse( + test.csvString, + test.delimiter + ); + Assert.deepEqual( + resultHeaderLine, + test.expectedHeaderLine, + "Header line check" + ); + Assert.deepEqual( + resultParsedLines, + test.expectedParsedLines, + "Parsed lines check" + ); + } +} + +add_task(function test_csv_parsing_results() { + TEST_CASES.forEach(testCase => { + parseCSVStringAndValidateResult(testCase); + }); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js new file mode 100644 index 0000000000..2e182a7064 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js @@ -0,0 +1,148 @@ +/** + * Test LoginManagerParent.doAutocompleteSearch() + */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" +); + +// new-password to the happy path +const NEW_PASSWORD_TEMPLATE_ARG = { + actionOrigin: "https://mozilla.org", + searchString: "", + previousResult: null, + requestId: "foo", + hasBeenTypePassword: true, + isSecure: true, + isProbablyANewPasswordField: true, +}; + +add_setup(async () => { + Services.prefs.setBoolPref("signon.generation.available", true); + Services.prefs.setBoolPref("signon.generation.enabled", true); + + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(); + + sinon + .stub(LoginManagerParent._browsingContextGlobal, "get") + .withArgs(123) + .callsFake(() => { + return { + currentWindowGlobal: { + documentPrincipal: + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + "https://www.example.com^userContextId=1" + ), + documentURI: Services.io.newURI("https://www.example.com"), + }, + }; + }); +}); + +add_task(async function test_generated_noLogins() { + let LMP = new LoginManagerParent(); + LMP.useBrowsingContext(123); + + Assert.ok(LMP.doAutocompleteSearch, "doAutocompleteSearch exists"); + + let result1 = await LMP.doAutocompleteSearch( + "https://example.com", + NEW_PASSWORD_TEMPLATE_ARG + ); + equal(result1.logins.length, 0, "no logins"); + Assert.ok(result1.generatedPassword, "has a generated password"); + equal(result1.generatedPassword.length, 15, "generated password length"); + Assert.ok( + result1.willAutoSaveGeneratedPassword, + "will auto-save when storage is empty" + ); + + info("repeat the search and ensure the same password was used"); + let result2 = await LMP.doAutocompleteSearch( + "https://example.com", + NEW_PASSWORD_TEMPLATE_ARG + ); + equal(result2.logins.length, 0, "no logins"); + equal( + result2.generatedPassword, + result1.generatedPassword, + "same generated password" + ); + Assert.ok( + result1.willAutoSaveGeneratedPassword, + "will auto-save when storage is still empty" + ); + + info("Check cases where a password shouldn't be generated"); + + let result3 = await LMP.doAutocompleteSearch("https://example.com", { + ...NEW_PASSWORD_TEMPLATE_ARG, + ...{ + hasBeenTypePassword: false, + isProbablyANewPasswordField: false, + }, + }); + equal( + result3.generatedPassword, + null, + "no generated password when not a pw. field" + ); + + let result4 = await LMP.doAutocompleteSearch("https://example.com", { + ...NEW_PASSWORD_TEMPLATE_ARG, + ...{ + // This is false when there is no autocomplete="new-password" attribute && + // LoginAutoComplete.isProbablyANewPasswordField returns false + isProbablyANewPasswordField: false, + }, + }); + equal( + result4.generatedPassword, + null, + "no generated password when isProbablyANewPasswordField is false" + ); + + LMP.useBrowsingContext(999); + let result5 = await LMP.doAutocompleteSearch("https://example.com", { + ...NEW_PASSWORD_TEMPLATE_ARG, + }); + equal( + result5.generatedPassword, + null, + "no generated password with a missing browsingContextId" + ); +}); + +add_task(async function test_generated_emptyUsernameSavedLogin() { + info("Test with a login that will prevent auto-saving"); + await LoginTestUtils.addLogin({ + username: "", + password: "my-saved-password", + origin: "https://example.com", + formActionOrigin: NEW_PASSWORD_TEMPLATE_ARG.actionOrigin, + }); + + let LMP = new LoginManagerParent(); + LMP.useBrowsingContext(123); + + Assert.ok(LMP.doAutocompleteSearch, "doAutocompleteSearch exists"); + + let result1 = await LMP.doAutocompleteSearch( + "https://example.com", + NEW_PASSWORD_TEMPLATE_ARG + ); + equal(result1.logins.length, 1, "1 login"); + Assert.ok(result1.generatedPassword, "has a generated password"); + equal(result1.generatedPassword.length, 15, "generated password length"); + Assert.ok( + !result1.willAutoSaveGeneratedPassword, + "won't auto-save when an empty-username match is found" + ); + + LoginTestUtils.clearData(); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js new file mode 100644 index 0000000000..72503723b8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js @@ -0,0 +1,176 @@ +/** + * Test LoginManagerParent.getGeneratedPassword() + */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" +); + +function simulateNavigationInTheFrame(newOrigin) { + LoginManagerParent._browsingContextGlobal.get.restore(); + sinon + .stub(LoginManagerParent._browsingContextGlobal, "get") + .withArgs(99) + .callsFake(() => { + return { + currentWindowGlobal: { + documentPrincipal: + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + `https://${newOrigin}^userContextId=2` + ), + documentURI: Services.io.newURI("https://www.example.com"), + }, + }; + }); +} + +add_task(async function test_getGeneratedPassword() { + // Force the feature to be enabled. + Services.prefs.setBoolPref("signon.generation.available", true); + Services.prefs.setBoolPref("signon.generation.enabled", true); + + // Setup the improved rules collection since the improved password rules + // pref is on by default. Otherwise any interaciton with LMP.getGeneratedPassword() + // will take a long time to complete + if (LoginHelper.improvedPasswordRulesEnabled) { + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(); + } + + let LMP = new LoginManagerParent(); + LMP.useBrowsingContext(99); + + Assert.ok(LMP.getGeneratedPassword, "LMP.getGeneratedPassword exists"); + equal( + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size, + 0, + "Empty cache to start" + ); + + equal(await LMP.getGeneratedPassword(), null, "Null with no BrowsingContext"); + + Assert.ok( + LoginManagerParent._browsingContextGlobal, + "Check _browsingContextGlobal exists" + ); + Assert.ok( + !LoginManagerParent._browsingContextGlobal.get(99), + "BrowsingContext 99 shouldn't exist yet" + ); + info("Stubbing BrowsingContext.get(99)"); + sinon + .stub(LoginManagerParent._browsingContextGlobal, "get") + .withArgs(99) + .callsFake(() => { + return { + currentWindowGlobal: { + documentPrincipal: + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + "https://www.example.com^userContextId=6" + ), + documentURI: Services.io.newURI("https://www.example.com"), + }, + }; + }); + Assert.ok( + LoginManagerParent._browsingContextGlobal.get(99), + "Checking BrowsingContext.get(99) stub" + ); + let password1 = await LMP.getGeneratedPassword(); + notEqual(password1, null, "Check password was returned"); + equal( + password1.length, + LoginTestUtils.generation.LENGTH, + "Check password length" + ); + equal( + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size, + 1, + "1 added to cache" + ); + equal( + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ).value, + password1, + "Cache key and value" + ); + let password2 = await LMP.getGeneratedPassword(); + equal( + password1, + password2, + "Same password should be returned for the same origin" + ); + + // Updating autosaved login to have username will reset generated password + const autoSavedLogin = await LoginTestUtils.addLogin({ + origin: "https://www.example.com^userContextId=6", + username: "", + password: password1, + }); + const updatedLogin = autoSavedLogin.clone(); + updatedLogin.username = "anyone"; + await LoginTestUtils.modifyLogin(autoSavedLogin, updatedLogin); + password2 = await LMP.getGeneratedPassword(); + notEqual( + password1, + password2, + "New password should be returned for the same origin after login saved" + ); + + simulateNavigationInTheFrame("www.mozilla.org"); + let password3 = await LMP.getGeneratedPassword(); + notEqual( + password2, + password3, + "Different password for a different origin for the same BC" + ); + equal( + password3.length, + LoginTestUtils.generation.LENGTH, + "Check password3 length" + ); + + simulateNavigationInTheFrame("bank.biz"); + let password4 = await LMP.getGeneratedPassword({ inputMaxLength: 5 }); + notEqual( + password4, + password2, + "Different password for a different origin for the same BC" + ); + notEqual( + password4, + password3, + "Different password for a different origin for the same BC" + ); + equal(password4.length, 5, "password4 length is limited by input.maxLength"); + + info("Now checks cases where null should be returned"); + + Services.prefs.setBoolPref("signon.rememberSignons", false); + equal( + await LMP.getGeneratedPassword(), + null, + "Prevented when pwmgr disabled" + ); + Services.prefs.setBoolPref("signon.rememberSignons", true); + + Services.prefs.setBoolPref("signon.generation.available", false); + equal(await LMP.getGeneratedPassword(), null, "Prevented when unavailable"); + Services.prefs.setBoolPref("signon.generation.available", true); + + Services.prefs.setBoolPref("signon.generation.enabled", false); + equal(await LMP.getGeneratedPassword(), null, "Prevented when disabled"); + Services.prefs.setBoolPref("signon.generation.enabled", true); + + LMP.useBrowsingContext(123); + equal( + await LMP.getGeneratedPassword(), + null, + "Prevented when browsingContext is missing" + ); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js new file mode 100644 index 0000000000..2573dcd4af --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js @@ -0,0 +1,1141 @@ +/** + * Test LoginManagerParent._onPasswordEditedOrGenerated() + */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" +); +const { LoginManagerPrompter } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerPrompter.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +const loginTemplate = Object.freeze({ + origin: "https://www.example.com", + formActionOrigin: "https://www.mozilla.org", +}); + +let LMP = new LoginManagerParent(); + +function stubPrompter() { + let fakePromptToSavePassword = sinon.stub(); + let fakePromptToChangePassword = sinon.stub(); + sinon.stub(LMP, "_getPrompter").callsFake(() => { + return { + promptToSavePassword: fakePromptToSavePassword, + promptToChangePassword: fakePromptToChangePassword, + }; + }); + LMP._getPrompter().promptToSavePassword(); + LMP._getPrompter().promptToChangePassword(); + Assert.ok(LMP._getPrompter.calledTwice, "Checking _getPrompter stub"); + Assert.ok( + fakePromptToSavePassword.calledOnce, + "Checking fakePromptToSavePassword stub" + ); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking fakePromptToChangePassword stub" + ); + function resetPrompterHistory() { + LMP._getPrompter.resetHistory(); + fakePromptToSavePassword.resetHistory(); + fakePromptToChangePassword.resetHistory(); + } + function restorePrompter() { + LMP._getPrompter.restore(); + } + resetPrompterHistory(); + return { + fakePromptToSavePassword, + fakePromptToChangePassword, + resetPrompterHistory, + restorePrompter, + }; +} + +async function stubGeneratedPasswordForBrowsingContextId(id) { + Assert.ok( + LoginManagerParent._browsingContextGlobal, + "Check _browsingContextGlobal exists" + ); + Assert.ok( + !LoginManagerParent._browsingContextGlobal.get(id), + `BrowsingContext ${id} shouldn't exist yet` + ); + info(`Stubbing BrowsingContext.get(${id})`); + let stub = sinon + .stub(LoginManagerParent._browsingContextGlobal, "get") + .withArgs(id) + .callsFake(() => { + return { + currentWindowGlobal: { + documentPrincipal: + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + "https://www.example.com^userContextId=6" + ), + documentURI: Services.io.newURI("https://www.example.com"), + }, + get embedderElement() { + info("returning embedderElement"); + let browser = MockDocument.createTestDocument( + "chrome://browser/content/browser.xhtml", + ` + + `, + "application/xml", + true + ).querySelector("browser"); + MockDocument.mockBrowsingContextProperty(browser, this); + return browser; + }, + get top() { + return this; + }, + }; + }); + Assert.ok( + LoginManagerParent._browsingContextGlobal.get(id), + `Checking BrowsingContext.get(${id}) stub` + ); + + const generatedPassword = await LMP.getGeneratedPassword(); + notEqual(generatedPassword, null, "Check password was returned"); + equal( + generatedPassword.length, + LoginTestUtils.generation.LENGTH, + "Check password length" + ); + equal( + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size, + 1, + "1 added to cache" + ); + equal( + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ).value, + generatedPassword, + "Cache key and value" + ); + LoginManagerParent._browsingContextGlobal.get.resetHistory(); + + return { + stub, + generatedPassword, + }; +} + +function checkEditTelemetryRecorded(expectedCount, msg) { + info("Check that expected telemetry event was recorded"); + const snapshot = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + let resultsCount = 0; + if ("parent" in snapshot) { + const telemetryProps = Object.freeze({ + category: "pwmgr", + method: "filled_field_edited", + object: "generatedpassword", + }); + const results = snapshot.parent.filter( + ([time, category, method, object]) => { + return ( + category === telemetryProps.category && + method === telemetryProps.method && + object === telemetryProps.object + ); + } + ); + resultsCount = results.length; + } + equal( + resultsCount, + expectedCount, + "Check count of pwmgr.filled_field_edited for generatedpassword: " + msg + ); +} + +async function startTestConditions(contextId) { + LMP.useBrowsingContext(contextId); + + Assert.ok( + LMP._onPasswordEditedOrGenerated, + "LMP._onPasswordEditedOrGenerated exists" + ); + equal(await LMP.getGeneratedPassword(), null, "Null with no BrowsingContext"); + equal( + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().size, + 0, + "Empty cache to start" + ); + equal( + Services.logins.getAllLogins().length, + 0, + "Should have no saved logins at the start of the test" + ); +} + +/* + * Compare login details excluding usernameField and passwordField + */ +function assertLoginProperties(actualLogin, expected) { + equal(actualLogin.origin, expected.origin, "Compare origin"); + equal( + actualLogin.formActionOrigin, + expected.formActionOrigin, + "Compare formActionOrigin" + ); + equal(actualLogin.httpRealm, expected.httpRealm, "Compare httpRealm"); + equal(actualLogin.username, expected.username, "Compare username"); + equal(actualLogin.password, expected.password, "Compare password"); +} + +add_setup(async () => { + // Get a profile for storage. + do_get_profile(); + + // Force the feature to be enabled. + Services.prefs.setBoolPref("signon.generation.available", true); + Services.prefs.setBoolPref("signon.generation.enabled", true); + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(); +}); + +add_task(async function test_onPasswordEditedOrGenerated_generatedPassword() { + await startTestConditions(99); + let { generatedPassword } = await stubGeneratedPasswordForBrowsingContextId( + 99 + ); + let { fakePromptToChangePassword, restorePrompter } = stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + equal( + Services.logins.getAllLogins().length, + 0, + "Should have no saved logins at the start of the test" + ); + + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: generatedPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + + let [login] = await storageChangedPromised; + let expected = new LoginInfo( + "https://www.example.com", + "https://www.mozilla.org", + null, + "", // verify we don't include the username when auto-saving a login + generatedPassword + ); + + Assert.ok(login.equals(expected), "Check added login"); + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[4], + "promptToChangePassword had a truthy 'notifySaved' argument" + ); + + info("Edit the password"); + const newPassword = generatedPassword + "🔥"; + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: newPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + let generatedPW = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ); + Assert.ok(generatedPW.edited, "Cached edited boolean should be true"); + equal(generatedPW.value, newPassword, "Cached password should be updated"); + // login metadata should be updated + let [dataArray] = await storageChangedPromised; + login = dataArray.queryElementAt(1, Ci.nsILoginInfo); + expected.password = newPassword; + Assert.ok(login.equals(expected), "Check updated login"); + equal( + Services.logins.getAllLogins().length, + 1, + "Should have 1 saved login still" + ); + + info( + "Simulate a second edit to check that the telemetry event for the first edit is not recorded twice" + ); + const newerPassword = newPassword + "🦊"; + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: newerPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ); + Assert.ok(generatedPW.edited, "Cached edited state should remain true"); + equal(generatedPW.value, newerPassword, "Cached password should be updated"); + [dataArray] = await storageChangedPromised; + login = dataArray.queryElementAt(1, Ci.nsILoginInfo); + expected.password = newerPassword; + Assert.ok(login.equals(expected), "Check updated login"); + equal( + Services.logins.getAllLogins().length, + 1, + "Should have 1 saved login still" + ); + + checkEditTelemetryRecorded(1, "with auto-save"); + + LoginManagerParent._browsingContextGlobal.get.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.removeAllUserFacingLogins(); + Services.telemetry.clearEvents(); +}); + +add_task( + async function test_onPasswordEditedOrGenerated_editToEmpty_generatedPassword() { + await startTestConditions(99); + let { generatedPassword } = await stubGeneratedPasswordForBrowsingContextId( + 99 + ); + let { fakePromptToChangePassword, restorePrompter } = stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + equal( + Services.logins.getAllLogins().length, + 0, + "Should have no saved logins at the start of the test" + ); + + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: generatedPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + + let [login] = await storageChangedPromised; + let expected = new LoginInfo( + "https://www.example.com", + "https://www.mozilla.org", + null, + "", // verify we don't include the username when auto-saving a login + generatedPassword + ); + + Assert.ok(login.equals(expected), "Check added login"); + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[4], + "promptToChangePassword had a truthy 'notifySaved' argument" + ); + + info("Edit the password to be empty"); + const newPassword = ""; + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: newPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + let generatedPW = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ); + Assert.ok(!generatedPW.edited, "Cached edited boolean should be false"); + equal( + generatedPW.value, + generatedPassword, + "Cached password shouldn't be updated" + ); + + checkEditTelemetryRecorded(0, "Blanking doesn't count as an edit"); + + LoginManagerParent._browsingContextGlobal.get.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.removeAllUserFacingLogins(); + Services.telemetry.clearEvents(); + } +); + +add_task(async function test_addUsernameBeforeAutoSaveEdit() { + await startTestConditions(99); + let { generatedPassword } = await stubGeneratedPasswordForBrowsingContextId( + 99 + ); + let { fakePromptToChangePassword, restorePrompter, resetPrompterHistory } = + stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + let fakePopupNotifications = { + getNotification: sinon.stub().returns({ dismissed: true }), + }; + sinon.stub(LoginHelper, "getBrowserForPrompt").callsFake(() => { + return { + ownerGlobal: { + PopupNotifications: fakePopupNotifications, + }, + }; + }); + + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + equal( + Services.logins.getAllLogins().length, + 0, + "Should have no saved logins at the start of the test" + ); + + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: generatedPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + + let [login] = await storageChangedPromised; + let expected = new LoginInfo( + "https://www.example.com", + "https://www.mozilla.org", + null, + "", // verify we don't include the username when auto-saving a login + generatedPassword + ); + + Assert.ok(login.equals(expected), "Check added login"); + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[4], + "promptToChangePassword had a truthy 'notifySaved' argument" + ); + + info("Checking the getNotification stub"); + Assert.ok( + !fakePopupNotifications.getNotification.called, + "getNotification didn't get called yet" + ); + resetPrompterHistory(); + + info("Add a username to the auto-saved login in storage"); + let loginWithUsername = login.clone(); + loginWithUsername.username = "added_username"; + LoginManagerPrompter._updateLogin(login, loginWithUsername); + + info("Edit the password"); + const newPassword = generatedPassword + "🔥"; + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + // will update the doorhanger with changed password + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: newPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + let generatedPW = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ); + Assert.ok(generatedPW.edited, "Cached edited boolean should be true"); + equal(generatedPW.value, newPassword, "Cached password should be updated"); + let [dataArray] = await storageChangedPromised; + login = dataArray.queryElementAt(1, Ci.nsILoginInfo); + loginWithUsername.password = newPassword; + // the password should be updated in storage, but not the username (until the user confirms the doorhanger) + assertLoginProperties(login, loginWithUsername); + Assert.ok(login.matches(loginWithUsername, false), "Check updated login"); + equal( + Services.logins.getAllLogins().length, + 1, + "Should have 1 saved login still" + ); + + Assert.ok( + fakePopupNotifications.getNotification.calledOnce, + "getNotification was called" + ); + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + // The generated password changed, so we expect notifySaved to be true + Assert.ok( + fakePromptToChangePassword.getCall(0).args[4], + "promptToChangePassword should have a falsey 'notifySaved' argument" + ); + resetPrompterHistory(); + + info( + "Simulate a second edit to check that the telemetry event for the first edit is not recorded twice" + ); + const newerPassword = newPassword + "🦊"; + storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + info("Calling _onPasswordEditedOrGenerated again"); + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: newerPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + generatedPW = LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ); + Assert.ok(generatedPW.edited, "Cached edited state should remain true"); + equal(generatedPW.value, newerPassword, "Cached password should be updated"); + [dataArray] = await storageChangedPromised; + login = dataArray.queryElementAt(1, Ci.nsILoginInfo); + loginWithUsername.password = newerPassword; + assertLoginProperties(login, loginWithUsername); + Assert.ok(login.matches(loginWithUsername, false), "Check updated login"); + equal( + Services.logins.getAllLogins().length, + 1, + "Should have 1 saved login still" + ); + + checkEditTelemetryRecorded(1, "with auto-save"); + + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + equal( + fakePromptToChangePassword.getCall(0).args[2].password, + newerPassword, + "promptToChangePassword had the updated password" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + + LoginManagerParent._browsingContextGlobal.get.restore(); + LoginHelper.getBrowserForPrompt.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.removeAllUserFacingLogins(); + Services.telemetry.clearEvents(); +}); + +add_task(async function test_editUsernameOfFilledSavedLogin() { + await startTestConditions(99); + await stubGeneratedPasswordForBrowsingContextId(99); + let { + fakePromptToChangePassword, + fakePromptToSavePassword, + restorePrompter, + resetPrompterHistory, + } = stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + let fakePopupNotifications = { + getNotification: sinon.stub().returns({ dismissed: true }), + }; + sinon.stub(LoginHelper, "getBrowserForPrompt").callsFake(() => { + return { + ownerGlobal: { + PopupNotifications: fakePopupNotifications, + }, + }; + }); + + let login0Props = Object.assign({}, loginTemplate, { + username: "someusername", + password: "qweqweq", + }); + info("Adding initial login: " + JSON.stringify(login0Props)); + let savedLogin = await LoginTestUtils.addLogin(login0Props); + + info( + "Saved initial login: " + JSON.stringify(Services.logins.getAllLogins()[0]) + ); + + equal( + Services.logins.getAllLogins().length, + 1, + "Should have 1 saved login at the start of the test" + ); + + // first prompt to save a new login + let newUsername = "differentuser"; + let newPassword = login0Props.password + "🔥"; + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + autoFilledLoginGuid: savedLogin.guid, + newPasswordField: { value: newPassword }, + usernameField: { value: newUsername }, + triggeredByFillingGenerated: false, + } + ); + + let expected = new LoginInfo( + login0Props.origin, + login0Props.formActionOrigin, + null, + newUsername, + newPassword + ); + + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + info("Checking the getNotification stub"); + Assert.ok( + !fakePopupNotifications.getNotification.called, + "getNotification was not called" + ); + Assert.ok( + fakePromptToSavePassword.calledOnce, + "Checking promptToSavePassword was called" + ); + Assert.ok( + fakePromptToSavePassword.getCall(0).args[2], + "promptToSavePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + !fakePromptToSavePassword.getCall(0).args[3], + "promptToSavePassword had a falsey 'notifySaved' argument" + ); + assertLoginProperties(fakePromptToSavePassword.getCall(0).args[1], expected); + resetPrompterHistory(); + + // then prompt with matching username/password + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + autoFilledLoginGuid: savedLogin.guid, + newPasswordField: { value: login0Props.password }, + usernameField: { value: login0Props.username }, + triggeredByFillingGenerated: false, + } + ); + + expected = new LoginInfo( + login0Props.origin, + login0Props.formActionOrigin, + null, + login0Props.username, + login0Props.password + ); + + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + info("Checking the getNotification stub"); + Assert.ok( + fakePopupNotifications.getNotification.called, + "getNotification was called" + ); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + !fakePromptToChangePassword.getCall(0).args[4], + "promptToChangePassword had a falsey 'notifySaved' argument" + ); + assertLoginProperties( + fakePromptToChangePassword.getCall(0).args[2], + expected + ); + resetPrompterHistory(); + + LoginManagerParent._browsingContextGlobal.get.restore(); + LoginHelper.getBrowserForPrompt.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.removeAllUserFacingLogins(); + Services.telemetry.clearEvents(); +}); + +add_task( + async function test_onPasswordEditedOrGenerated_generatedPassword_withDisabledLogin() { + await startTestConditions(99); + let { generatedPassword } = await stubGeneratedPasswordForBrowsingContextId( + 99 + ); + let { restorePrompter } = stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + + info("Disable login saving for the site"); + Services.logins.setLoginSavingEnabled("https://www.example.com", false); + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: generatedPassword }, + triggeredByFillingGenerated: true, + } + ); + equal( + Services.logins.getAllLogins().length, + 0, + "Should have no saved logins since saving is disabled" + ); + Assert.ok( + LMP._getPrompter.notCalled, + "Checking _getPrompter wasn't called" + ); + + // Clean up + LoginManagerParent._browsingContextGlobal.get.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.setLoginSavingEnabled("https://www.example.com", true); + Services.logins.removeAllUserFacingLogins(); + } +); + +add_task( + async function test_onPasswordEditedOrGenerated_generatedPassword_withSavedEmptyUsername() { + await startTestConditions(99); + let login0Props = Object.assign({}, loginTemplate, { + username: "", + password: "qweqweq", + }); + info("Adding initial login: " + JSON.stringify(login0Props)); + let expected = await LoginTestUtils.addLogin(login0Props); + + info( + "Saved initial login: " + + JSON.stringify(Services.logins.getAllLogins()[0]) + ); + + let { generatedPassword: password1 } = + await stubGeneratedPasswordForBrowsingContextId(99); + let { restorePrompter, fakePromptToChangePassword } = stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: password1 }, + triggeredByFillingGenerated: true, + } + ); + equal( + Services.logins.getAllLogins().length, + 1, + "Should just have the previously-saved login with empty username" + ); + assertLoginProperties(Services.logins.getAllLogins()[0], login0Props); + + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + !fakePromptToChangePassword.getCall(0).args[4], + "promptToChangePassword had a falsey 'notifySaved' argument" + ); + + info("Edit the password"); + const newPassword = password1 + "🔥"; + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: newPassword }, + usernameField: { value: "someusername" }, + triggeredByFillingGenerated: true, + } + ); + let generatedPW = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ); + Assert.ok(generatedPW.edited, "Cached edited boolean should be true"); + equal(generatedPW.storageGUID, null, "Should have no storageGUID"); + equal(generatedPW.value, newPassword, "Cached password should be updated"); + assertLoginProperties(Services.logins.getAllLogins()[0], login0Props); + Assert.ok( + Services.logins.getAllLogins()[0].equals(expected), + "Ensure no changes" + ); + equal( + Services.logins.getAllLogins().length, + 1, + "Should have 1 saved login still" + ); + + checkEditTelemetryRecorded(1, "Updating cache, not storage (no auto-save)"); + + LoginManagerParent._browsingContextGlobal.get.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.removeAllUserFacingLogins(); + Services.telemetry.clearEvents(); + } +); + +add_task( + async function test_onPasswordEditedOrGenerated_generatedPassword_withSavedEmptyUsernameAndUsernameValue() { + // Save as the above task but with a non-empty username field value. + await startTestConditions(99); + let login0Props = Object.assign({}, loginTemplate, { + username: "", + password: "qweqweq", + }); + info("Adding initial login: " + JSON.stringify(login0Props)); + let expected = await LoginTestUtils.addLogin(login0Props); + + info( + "Saved initial login: " + + JSON.stringify(Services.logins.getAllLogins()[0]) + ); + + let { generatedPassword: password1 } = + await stubGeneratedPasswordForBrowsingContextId(99); + let { + restorePrompter, + fakePromptToChangePassword, + fakePromptToSavePassword, + } = stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: password1 }, + usernameField: { value: "non-empty-username" }, + triggeredByFillingGenerated: true, + } + ); + equal( + Services.logins.getAllLogins().length, + 1, + "Should just have the previously-saved login with empty username" + ); + assertLoginProperties(Services.logins.getAllLogins()[0], login0Props); + + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + Assert.ok( + fakePromptToChangePassword.notCalled, + "Checking promptToChangePassword wasn't called" + ); + Assert.ok( + fakePromptToSavePassword.calledOnce, + "Checking promptToSavePassword was called" + ); + Assert.ok( + fakePromptToSavePassword.getCall(0).args[2], + "promptToSavePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + !fakePromptToSavePassword.getCall(0).args[3], + "promptToSavePassword had a falsey 'notifySaved' argument" + ); + + info("Edit the password"); + const newPassword = password1 + "🔥"; + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: newPassword }, + usernameField: { value: "non-empty-username" }, + triggeredByFillingGenerated: true, + } + ); + Assert.ok( + fakePromptToChangePassword.notCalled, + "Checking promptToChangePassword wasn't called" + ); + Assert.ok( + fakePromptToSavePassword.calledTwice, + "Checking promptToSavePassword was called again" + ); + Assert.ok( + fakePromptToSavePassword.getCall(1).args[2], + "promptToSavePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + !fakePromptToSavePassword.getCall(1).args[3], + "promptToSavePassword had a falsey 'notifySaved' argument" + ); + + let generatedPW = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://www.example.com^userContextId=6" + ); + Assert.ok(generatedPW.edited, "Cached edited boolean should be true"); + equal(generatedPW.storageGUID, null, "Should have no storageGUID"); + equal(generatedPW.value, newPassword, "Cached password should be updated"); + assertLoginProperties(Services.logins.getAllLogins()[0], login0Props); + Assert.ok( + Services.logins.getAllLogins()[0].equals(expected), + "Ensure no changes" + ); + equal( + Services.logins.getAllLogins().length, + 1, + "Should have 1 saved login still" + ); + + checkEditTelemetryRecorded( + 1, + "Updating cache, not storage (no auto-save) with username in field" + ); + + LoginManagerParent._browsingContextGlobal.get.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.removeAllUserFacingLogins(); + Services.telemetry.clearEvents(); + } +); + +add_task( + async function test_onPasswordEditedOrGenerated_generatedPassword_withEmptyUsernameDifferentFormActionOrigin() { + await startTestConditions(99); + let login0Props = Object.assign({}, loginTemplate, { + username: "", + password: "qweqweq", + }); + await LoginTestUtils.addLogin(login0Props); + + let { generatedPassword: password1 } = + await stubGeneratedPasswordForBrowsingContextId(99); + let { restorePrompter, fakePromptToChangePassword } = stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.elsewhere.com", + newPasswordField: { value: password1 }, + triggeredByFillingGenerated: true, + } + ); + + let savedLogins = Services.logins.getAllLogins(); + equal( + savedLogins.length, + 2, + "Should have saved the generated-password login" + ); + + assertLoginProperties(savedLogins[0], login0Props); + assertLoginProperties( + savedLogins[1], + Object.assign({}, loginTemplate, { + formActionOrigin: "https://www.elsewhere.com", + username: "", + password: password1, + }) + ); + + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[2], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'notifySaved' argument" + ); + + LoginManagerParent._browsingContextGlobal.get.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.removeAllUserFacingLogins(); + } +); + +add_task( + async function test_onPasswordEditedOrGenerated_generatedPassword_withSavedUsername() { + await startTestConditions(99); + let login0Props = Object.assign({}, loginTemplate, { + username: "previoususer", + password: "qweqweq", + }); + await LoginTestUtils.addLogin(login0Props); + + let { generatedPassword: password1 } = + await stubGeneratedPasswordForBrowsingContextId(99); + let { restorePrompter, fakePromptToChangePassword } = stubPrompter(); + let rootBrowser = LMP.getRootBrowser(); + + await LMP._onPasswordEditedOrGenerated( + rootBrowser, + "https://www.example.com", + { + browsingContextId: 99, + formActionOrigin: "https://www.mozilla.org", + newPasswordField: { value: password1 }, + triggeredByFillingGenerated: true, + } + ); + + let savedLogins = Services.logins.getAllLogins(); + equal( + savedLogins.length, + 2, + "Should have saved the generated-password login" + ); + assertLoginProperties(Services.logins.getAllLogins()[0], login0Props); + assertLoginProperties( + savedLogins[1], + Object.assign({}, loginTemplate, { + username: "", + password: password1, + }) + ); + + Assert.ok(LMP._getPrompter.calledOnce, "Checking _getPrompter was called"); + Assert.ok( + fakePromptToChangePassword.calledOnce, + "Checking promptToChangePassword was called" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[2], + "promptToChangePassword had a truthy 'dismissed' argument" + ); + Assert.ok( + fakePromptToChangePassword.getCall(0).args[3], + "promptToChangePassword had a truthy 'notifySaved' argument" + ); + + LoginManagerParent._browsingContextGlobal.get.restore(); + restorePrompter(); + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); + Services.logins.removeAllUserFacingLogins(); + } +); diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js new file mode 100644 index 0000000000..33ee5bd04c --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js @@ -0,0 +1,207 @@ +/** + * Test LoginManagerParent._searchAndDedupeLogins() + */ + +"use strict"; + +const { LoginManagerParent: LMP } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" +); + +const DOMAIN1_HTTP_ORIGIN = "http://www3.example.com"; +const DOMAIN1_HTTPS_ORIGIN = "https://www3.example.com"; + +const DOMAIN1_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({}); +const DOMAIN1_HTTP_TO_HTTP_U2_P1 = TestData.formLogin({ + username: "user2", +}); +const DOMAIN1_HTTP_TO_HTTP_U3_P1 = TestData.formLogin({ + username: "user3", +}); +const DOMAIN1_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({ + origin: DOMAIN1_HTTPS_ORIGIN, + formActionOrigin: "https://login.example.com", +}); +const DOMAIN1_HTTPS_TO_HTTPS_U2_P1 = TestData.formLogin({ + origin: DOMAIN1_HTTPS_ORIGIN, + formActionOrigin: "https://login.example.com", + username: "user2", +}); +const DOMAIN1_HTTPS_TO_HTTPS_U1_P2 = TestData.formLogin({ + origin: DOMAIN1_HTTPS_ORIGIN, + formActionOrigin: "https://login.example.com", + password: "password two", +}); +const DOMAIN1_HTTPS_TO_HTTPS_U1_P2_DIFFERENT_PORT = TestData.formLogin({ + origin: "https://www3.example.com:8080", + password: "password two", +}); +const DOMAIN1_HTTP_TO_HTTP_U1_P2 = TestData.formLogin({ + password: "password two", +}); +const DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT = TestData.formLogin({ + origin: "http://www3.example.com:8080", +}); +const DOMAIN2_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({ + origin: "http://different.example.com", +}); +const DOMAIN2_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({ + origin: "https://different.example.com", + formActionOrigin: "https://login.example.com", +}); + +add_task(function setup() { + // Not enabled by default in all.js: + Services.prefs.setBoolPref("signon.schemeUpgrades", true); +}); + +add_task(async function test_searchAndDedupeLogins_acceptDifferentSubdomains() { + let testcases = [ + { + description: "HTTPS form, same hostPort, same username, different scheme", + formActionOrigin: DOMAIN1_HTTPS_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1], + }, + { + description: "HTTP form, same hostPort, same username, different scheme", + formActionOrigin: DOMAIN1_HTTP_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + expected: [DOMAIN1_HTTP_TO_HTTP_U1_P1], + }, + { + description: "HTTPS form, different passwords, different scheme", + formActionOrigin: DOMAIN1_HTTPS_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1], + }, + { + description: "HTTP form, different passwords, different scheme", + formActionOrigin: DOMAIN1_HTTP_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2], + expected: [DOMAIN1_HTTP_TO_HTTP_U1_P2], + }, + { + description: "HTTPS form, same origin, different port, both schemes", + formActionOrigin: DOMAIN1_HTTPS_ORIGIN, + logins: [ + DOMAIN1_HTTPS_TO_HTTPS_U1_P1, + DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT, + DOMAIN1_HTTPS_TO_HTTPS_U1_P2_DIFFERENT_PORT, + ], + expected: [ + DOMAIN1_HTTPS_TO_HTTPS_U1_P1, + DOMAIN1_HTTPS_TO_HTTPS_U1_P2_DIFFERENT_PORT, + ], + }, + { + description: "HTTP form, same origin, different port, both schemes", + formActionOrigin: DOMAIN1_HTTP_ORIGIN, + logins: [ + DOMAIN1_HTTPS_TO_HTTPS_U1_P1, + DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT, + DOMAIN1_HTTPS_TO_HTTPS_U1_P2_DIFFERENT_PORT, + ], + expected: [DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT], + }, + { + description: "HTTPS form, different origin, different scheme", + formActionOrigin: DOMAIN1_HTTPS_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTP_TO_HTTP_U1_P1], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1], + }, + { + description: + "HTTPS form, different origin, different scheme, same password, same hostPort preferred", + formActionOrigin: DOMAIN1_HTTPS_ORIGIN, + logins: [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN2_HTTPS_TO_HTTPS_U1_P1], + expected: [DOMAIN1_HTTP_TO_HTTP_U1_P1], + }, + { + description: "HTTP form, different origin, different scheme", + formActionOrigin: DOMAIN1_HTTP_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTP_TO_HTTP_U1_P1], + expected: [DOMAIN2_HTTP_TO_HTTP_U1_P1], + }, + { + description: "HTTPS form, different username, different scheme", + formActionOrigin: DOMAIN1_HTTPS_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1], + }, + { + description: "HTTP form, different username, different scheme", + formActionOrigin: DOMAIN1_HTTP_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1], + expected: [DOMAIN1_HTTP_TO_HTTP_U2_P1], + }, + { + description: "HTTPS form, different usernames, different schemes", + formActionOrigin: DOMAIN1_HTTPS_ORIGIN, + logins: [ + DOMAIN1_HTTPS_TO_HTTPS_U1_P2, + DOMAIN1_HTTPS_TO_HTTPS_U2_P1, + DOMAIN1_HTTP_TO_HTTP_U1_P1, + DOMAIN1_HTTP_TO_HTTP_U3_P1, + ], + expected: [ + DOMAIN1_HTTPS_TO_HTTPS_U1_P2, + DOMAIN1_HTTPS_TO_HTTPS_U2_P1, + DOMAIN1_HTTP_TO_HTTP_U3_P1, + ], + }, + ]; + + for (let tc of testcases) { + info(tc.description); + + let guids = await Services.logins.addLogins(tc.logins); + Assert.strictEqual( + guids.length, + tc.logins.length, + "Check length of added logins" + ); + + let actual = await LMP.searchAndDedupeLogins(tc.formActionOrigin, { + formActionOrigin: tc.formActionOrigin, + looseActionOriginMatch: true, + acceptDifferentSubdomains: true, + }); + info(`actual:\n ${JSON.stringify(actual, null, 2)}`); + info(`expected:\n ${JSON.stringify(tc.expected, null, 2)}`); + Assert.strictEqual( + actual.length, + tc.expected.length, + `Check result length` + ); + for (let [i, login] of tc.expected.entries()) { + Assert.ok(actual[i].equals(login), `Check index ${i}`); + } + + Services.logins.removeAllUserFacingLogins(); + } +}); + +add_task(async function test_reject_duplicates() { + const testcases = [ + { + description: "HTTPS form, both https, same username, different password", + formActionOrigin: DOMAIN1_HTTPS_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2], + }, + { + description: "HTTP form, both https, same username, different password", + formActionOrigin: DOMAIN1_HTTP_ORIGIN, + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2], + }, + ]; + + for (const tc of testcases) { + info(tc.description); + + const result = await Services.logins.addLogins(tc.logins); + Assert.equal(result.length, 1, "only single login added"); + + Services.logins.removeAllUserFacingLogins(); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js b/toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js new file mode 100644 index 0000000000..d3a94ba08b --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js @@ -0,0 +1,188 @@ +const { LoginManagerPrompter } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerPrompter.sys.mjs" +); + +const TEST_CASES = [ + { + description: "page values should appear before saved values", + savedLogins: [{ username: "savedUsername", password: "savedPassword" }], + possibleUsernames: ["pageUsername"], + expectedSuggestions: [ + { text: "pageUsername", style: "possible-username" }, + { text: "savedUsername", style: "login" }, + ], + isLoggedIn: true, + }, + { + description: "duplicate page values should be deduped", + savedLogins: [], + possibleUsernames: ["pageUsername", "pageUsername", "pageUsername2"], + expectedSuggestions: [ + { text: "pageUsername", style: "possible-username" }, + { text: "pageUsername2", style: "possible-username" }, + ], + isLoggedIn: true, + }, + { + description: "page values should dedupe and win over saved values", + savedLogins: [{ username: "username", password: "savedPassword" }], + possibleUsernames: ["username"], + expectedSuggestions: [{ text: "username", style: "possible-username" }], + isLoggedIn: true, + }, + { + description: "empty usernames should be filtered out", + savedLogins: [{ username: "", password: "savedPassword" }], + possibleUsernames: [""], + expectedSuggestions: [], + isLoggedIn: true, + }, + { + description: "auth logins should be displayed alongside normal ones", + savedLogins: [ + { username: "normalUsername", password: "normalPassword" }, + { isAuth: true, username: "authUsername", password: "authPassword" }, + ], + possibleUsernames: [""], + expectedSuggestions: [ + { text: "normalUsername", style: "login" }, + { text: "authUsername", style: "login" }, + ], + isLoggedIn: true, + }, + { + description: "saved logins from subdomains should be displayed", + savedLogins: [ + { + username: "savedUsername", + password: "savedPassword", + origin: "https://subdomain.example.com", + }, + ], + possibleUsernames: [], + expectedSuggestions: [{ text: "savedUsername", style: "login" }], + isLoggedIn: true, + }, + { + description: "usernames from different subdomains should be deduped", + savedLogins: [ + { + username: "savedUsername", + password: "savedPassword", + origin: "https://subdomain.example.com", + }, + { + username: "savedUsername", + password: "savedPassword", + origin: "https://example.com", + }, + ], + possibleUsernames: [], + expectedSuggestions: [{ text: "savedUsername", style: "login" }], + isLoggedIn: true, + }, + { + description: "No results with saved login when Primary Password is locked", + savedLogins: [ + { + username: "savedUsername", + password: "savedPassword", + origin: "https://example.com", + }, + ], + possibleUsernames: [], + expectedSuggestions: [], + isLoggedIn: false, + }, +]; + +const LOGIN = TestData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "LOGIN is used only for its origin", + password: "LOGIN is used only for its origin", +}); + +function _setPrefs() { + Services.prefs.setBoolPref("signon.capture.inputChanges.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.capture.inputChanges.enabled"); + }); +} + +async function _saveLogins(loginDatas) { + const logins = loginDatas.map(loginData => { + let login; + if (loginData.isAuth) { + login = TestData.authLogin({ + origin: loginData.origin ?? "https://example.com", + httpRealm: "example-realm", + username: loginData.username, + password: loginData.password, + }); + } else { + login = TestData.formLogin({ + origin: loginData.origin ?? "https://example.com", + formActionOrigin: "https://example.com", + username: loginData.username, + password: loginData.password, + }); + } + return login; + }); + await Services.logins.addLogins(logins); +} + +function _compare(expectedArr, actualArr) { + Assert.ok(!!expectedArr, "Expect expectedArr to be truthy"); + Assert.ok(!!actualArr, "Expect actualArr to be truthy"); + Assert.ok( + expectedArr.length == actualArr.length, + "Expect expectedArr and actualArr to be the same length" + ); + for (let i = 0; i < expectedArr.length; i++) { + const expected = expectedArr[i]; + const actual = actualArr[i]; + + Assert.ok( + expected.text == actual.text, + `Expect element #${i} text to match. Expected: '${expected.text}', Actual '${actual.text}'` + ); + Assert.ok( + expected.style == actual.style, + `Expect element #${i} text to match. Expected: '${expected.style}', Actual '${actual.style}'` + ); + } +} + +async function _test(testCase) { + info(`Starting test case: ${testCase.description}`); + info(`Storing saved logins: ${JSON.stringify(testCase.savedLogins)}`); + await _saveLogins(testCase.savedLogins); + + if (!testCase.isLoggedIn) { + // Primary Password should be enabled and locked + LoginTestUtils.primaryPassword.enable(); + } + + info("Computing results"); + const result = await LoginManagerPrompter._getUsernameSuggestions( + LOGIN, + testCase.possibleUsernames + ); + + _compare(testCase.expectedSuggestions, result); + + info("Cleaning up state"); + if (!testCase.isLoggedIn) { + LoginTestUtils.primaryPassword.disable(); + } + LoginTestUtils.clearData(); +} + +add_task(async function test_LoginManagerPrompter_getUsernameSuggestions() { + _setPrefs(); + for (const tc of TEST_CASES) { + await _test(tc); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js b/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js new file mode 100644 index 0000000000..9545f5ea02 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js @@ -0,0 +1,138 @@ +/** + * Tests the OSCrypto object. + */ + +"use strict"; + +// Globals + +ChromeUtils.defineESModuleGetters(this, { + OSCrypto: "resource://gre/modules/OSCrypto_win.sys.mjs", +}); + +let crypto = new OSCrypto(); + +// Tests + +add_task(function test_getIELoginHash() { + Assert.equal( + crypto.getIELoginHash("https://bugzilla.mozilla.org/page.cgi"), + "4A66FE96607885790F8E67B56EEE52AB539BAFB47D" + ); + + Assert.equal( + crypto.getIELoginHash("https://github.com/login"), + "0112F7DCE67B8579EA01367678AA44AB9868B5A143" + ); + + Assert.equal( + crypto.getIELoginHash("https://login.live.com/login.srf"), + "FBF92E5D804C82717A57856533B779676D92903688" + ); + + Assert.equal( + crypto.getIELoginHash("https://preview.c9.io/riadh/w1/pass.1.html"), + "6935CF27628830605927F86AB53831016FC8973D1A" + ); + + Assert.equal( + crypto.getIELoginHash("https://reviewboard.mozilla.org/account/login/"), + "09141FD287E2E59A8B1D3BB5671537FD3D6B61337A" + ); + + Assert.equal( + crypto.getIELoginHash("https://www.facebook.com/"), + "EF44D3E034009CB0FD1B1D81A1FF3F3335213BD796" + ); +}); + +add_task(function test_decryptData_encryptData() { + function encryptDecrypt(value, key) { + Assert.ok(true, `Testing value='${value}' with key='${key}'`); + let encrypted = crypto.encryptData(value, key); + Assert.ok(!!encrypted, "Encrypted value returned"); + let decrypted = crypto.decryptData(encrypted, key); + Assert.equal(decrypted, value, "Decrypted value matches initial value"); + return decrypted; + } + + let values = [ + "", + "secret", + "https://www.mozilla.org", + "https://reviewboard.mozilla.org", + "https://bugzilla.mozilla.org/page.cgi", + "新年快樂新年快樂", + ]; + let keys = [ + null, + "a", + "keys", + "abcdedf", + "pass", + "https://bugzilla.mozilla.org/page.cgi", + "https://login.live.com/login.srf", + ]; + for (let value of values) { + for (let key of keys) { + Assert.equal( + encryptDecrypt(value, key), + value, + `'${value}' encrypted then decrypted with entropy of '${key}' should match original value.` + ); + } + } + + let url = "https://twitter.com/"; + let value = [ + 1, 0, 0, 0, 208, 140, 157, 223, 1, 21, 209, 17, 140, 122, 0, 192, 79, 194, + 151, 235, 1, 0, 0, 0, 254, 58, 230, 75, 132, 228, 181, 79, 184, 160, 37, + 106, 201, 29, 42, 152, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 16, 102, 0, 0, 0, 1, 0, + 0, 32, 0, 0, 0, 90, 136, 17, 124, 122, 57, 178, 24, 34, 86, 209, 198, 184, + 107, 58, 58, 32, 98, 61, 239, 129, 101, 56, 239, 114, 159, 139, 165, 183, + 40, 183, 85, 0, 0, 0, 0, 14, 128, 0, 0, 0, 2, 0, 0, 32, 0, 0, 0, 147, 170, + 34, 21, 53, 227, 191, 6, 201, 84, 106, 31, 57, 227, 46, 127, 219, 199, 80, + 142, 37, 104, 112, 223, 26, 165, 223, 55, 176, 89, 55, 37, 112, 0, 0, 0, 98, + 70, 221, 109, 5, 152, 46, 11, 190, 213, 226, 58, 244, 20, 180, 217, 63, 155, + 227, 132, 7, 151, 235, 6, 37, 232, 176, 182, 141, 191, 251, 50, 20, 123, 53, + 11, 247, 233, 112, 121, 130, 27, 168, 68, 92, 144, 192, 7, 12, 239, 53, 217, + 253, 155, 54, 109, 236, 216, 225, 245, 79, 234, 165, 225, 104, 36, 77, 13, + 195, 237, 143, 165, 100, 107, 230, 70, 54, 19, 179, 35, 8, 101, 93, 202, + 121, 210, 222, 28, 93, 122, 36, 84, 185, 249, 238, 3, 102, 149, 248, 94, + 137, 16, 192, 22, 251, 220, 22, 223, 16, 58, 104, 187, 64, 0, 0, 0, 70, 72, + 15, 119, 144, 66, 117, 203, 190, 82, 131, 46, 111, 130, 238, 191, 170, 63, + 186, 117, 46, 88, 171, 3, 94, 146, 75, 86, 243, 159, 63, 195, 149, 25, 105, + 141, 42, 217, 108, 18, 63, 62, 98, 182, 241, 195, 12, 216, 152, 230, 176, + 253, 202, 129, 41, 185, 135, 111, 226, 92, 27, 78, 27, 198, + ]; + + let arr1 = crypto.arrayToString(value); + let arr2 = crypto.stringToArray( + crypto.decryptData(crypto.encryptData(arr1, url), url) + ); + for (let i = 0; i < arr1.length; i++) { + Assert.equal(arr2[i], value[i], "Checking index " + i); + } +}); + +add_task(function test_decryptDataOutput() { + const testString = "2 { + const code = c.charCodeAt(0); + Assert.equal( + decryptedBytes[i], + code, + `Decrypted bytes matches ${c} charCode (${code})` + ); + }); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js b/toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js new file mode 100644 index 0000000000..a5537a4289 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js @@ -0,0 +1,122 @@ +"use strict"; + +const { PasswordGenerator } = ChromeUtils.importESModule( + "resource://gre/modules/PasswordGenerator.sys.mjs" +); + +add_task(async function test_shuffleString() { + let original = "1234567890"; + let shuffled = PasswordGenerator._shuffleString(original); + notEqual(original, shuffled, "String should have been shuffled"); +}); + +add_task(async function test_randomUInt8Index() { + throws( + () => PasswordGenerator._randomUInt8Index(256), + /uint8/, + "Should throw for larger than uint8" + ); + Assert.ok( + Number.isSafeInteger(PasswordGenerator._randomUInt8Index(255)), + "Check integer returned" + ); +}); + +add_task(async function test_generatePassword_classes() { + let password = PasswordGenerator.generatePassword( + /* REQUIRED_CHARACTER_CLASSES */ + + { length: 4 } + ); + info(password); + equal(password.length, 4, "Check length is correct"); + Assert.ok( + password.match(/[a-km-np-z]/), + "Minimal password should include at least one lowercase character" + ); + Assert.ok( + password.match(/[A-HJ-NP-Z]/), + "Minimal password should include at least one uppercase character" + ); + Assert.ok( + password.match(/[2-9]/), + "Minimal password should include at least one digit" + ); + Assert.ok( + password.match(/[-~!@#$%^&*_+=)}:;"'>,.?\]]/), + "Minimal password should include at least one special character" + ); + Assert.ok( + password.match(/^[a-km-np-zA-HJ-NP-Z2-9-~!@#$%^&*_+=)}:;"'>,.?\]]+$/), + "All characters should be in the expected set" + ); +}); + +add_task(async function test_generatePassword_length() { + let password = PasswordGenerator.generatePassword({ length: 5 }); + info(password); + equal(password.length, 5, "Check length is correct"); + + password = PasswordGenerator.generatePassword({ length: 3 }); + equal(password.length, 4, "Minimum generated length is 4"); + + password = PasswordGenerator.generatePassword({ length: Math.pow(2, 8) }); + equal( + password.length, + Math.pow(2, 8) - 1, + "Maximum generated length is Math.pow(2, 8) - 1 " + ); + + Assert.ok( + password.match(/^[a-km-np-zA-HJ-NP-Z2-9-~!@#$%^&*_+=)}:;"'>,.?\]]+$/), + "All characters should be in the expected set" + ); +}); + +add_task(async function test_generatePassword_defaultLength() { + let password = PasswordGenerator.generatePassword({}); + info(password); + equal(password.length, 15, "Check default length is correct"); + Assert.ok( + password.match(/^[a-km-np-zA-HJ-NP-Z2-9-~!@#$%^&*_+=)}:;"'>,.?\]]{15}$/), + "All characters should be in the expected set" + ); +}); + +add_task( + async function test_generatePassword_immutableDefaultRequiredClasses() { + // We need to escape our special characters since some of them + // have special meaning in regex. + let specialCharacters = PasswordGenerator._getSpecialCharacters(); + let escapedSpecialCharacters = specialCharacters.replace( + /[.*\-+?^${}()|[\]\\]/g, + "\\$&" + ); + specialCharacters = new RegExp(`[${escapedSpecialCharacters}]`); + let rules = new Map(); + rules.set("required", ["special"]); + let password = PasswordGenerator.generatePassword({ rules }); + equal(password.length, 15, "Check default length is correct"); + Assert.ok( + password.match(specialCharacters), + "Password should include special character." + ); + let allCharacters = new RegExp( + `[a-km-np-zA-HJ-NP-Z2-9 ${escapedSpecialCharacters}]{15}` + ); + Assert.ok( + password.match(allCharacters), + "All characters should be in the expected set" + ); + password = PasswordGenerator.generatePassword({}); + equal(password.length, 15, "Check default length is correct"); + Assert.ok( + password.match(specialCharacters), + "Password should include special character." + ); + Assert.ok( + password.match(/^[a-km-np-zA-HJ-NP-Z2-9-~!@#$%^&*_+=)}:;"'>,.?\]]{15}$/), + "All characters, minus special characters, should be in the expected set" + ); + } +); diff --git a/toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js b/toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js new file mode 100644 index 0000000000..88520769cf --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js @@ -0,0 +1,523 @@ +/** + * Test PasswordRulesManager.generatePassword() + */ + +"use strict"; +const { PasswordGenerator } = ChromeUtils.importESModule( + "resource://gre/modules/PasswordGenerator.sys.mjs" +); +const { PasswordRulesManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/PasswordRulesManager.sys.mjs" +); +const { PasswordRulesParser } = ChromeUtils.importESModule( + "resource://gre/modules/PasswordRulesParser.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +const IMPROVED_RULES_COLLECTION = "password-rules"; + +function getRulesForRecord(records, baseOrigin) { + let rules; + for (let record of records) { + if (record.Domain === baseOrigin) { + rules = record["password-rules"]; + break; + } + } + return rules; +} + +add_task(async function test_verify_password_rules() { + const testCases = [ + { maxlength: 12 }, + { minlength: 4, maxlength: 32 }, + { required: ["lower"] }, + { required: ["upper"] }, + { required: ["digit"] }, + { required: ["special"] }, + { required: ["*", "$", "@", "_", "B", "Q"] }, + { required: ["lower", "upper", "special"] }, + { "max-consecutive": 2 }, + { + minlength: 8, + required: [ + "digit", + [ + "-", + " ", + "!", + '"', + "#", + "$", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + ".", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "[", + "^", + "_", + "`", + "{", + "|", + "}", + "~", + "]", + ], + ], + }, + { minlength: 8, maxlength: 16, required: ["lower", "upper", "digit"] }, + { + minlength: 8, + maxlength: 20, + required: ["lower", "upper", "digit"], + "max-consecutive": 2, + }, + ]; + + for (let test of testCases) { + let mapOfRules = new Map(); + let rules = ``; + for (let testRules in test) { + mapOfRules.set(testRules, test[testRules]); + if (test[testRules] === "required" && test[testRules].includes("*$")) { + rules += `${testRules}: ${test[testRules].join("")}`; + } else { + rules += `${testRules}: ${test[testRules]};`; + } + } + let generatedPassword = PasswordGenerator.generatePassword({ + rules: mapOfRules, + }); + verifyPassword(rules, generatedPassword); + } +}); + +/** + * Note: We do not test the "allowed" property in these tests. + * This is because a password can still be valid even if there is not a character from + * the "allowed" list. + * If a character, or character class, is required, then it should be marked as such. + * */ + +add_task(async function test_generatePassword_many_rules() { + // Force password generation to be enabled. + Services.prefs.setBoolPref("signon.generation.available", true); + Services.prefs.setBoolPref("signon.generation.enabled", true); + Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true); + // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent + // and so it should always be a correctly formed URI when working with + // the PasswordRulesParser and PasswordRulesManager modules + const TEST_ORIGIN = Services.io.newURI("https://example.com"); + + // TEST_BASE_ORIGIN is how each domain is stored in RemoteSettings, and so + // we need this in order to parse out the particular password rules we're verifying + const TEST_BASE_ORIGIN = "example.com"; + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(); + const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get(); + + let rules = getRulesForRecord(records, TEST_BASE_ORIGIN); + + rules = PasswordRulesParser.parsePasswordRules(rules); + Assert.ok(rules.length, "Rules should exist after parsing"); + + let PRMP = new PasswordRulesManagerParent(); + Assert.ok(PRMP.generatePassword, "PRMP.generatePassword exists"); + + let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN); + Assert.ok(generatedPassword, "A password was generated"); + + verifyPassword(rules, generatedPassword); + + await LoginTestUtils.remoteSettings.cleanImprovedPasswordRules(); +}); + +add_task(async function test_generatePassword_all_characters_allowed() { + // Force password generation to be enabled. + Services.prefs.setBoolPref("signon.generation.available", true); + Services.prefs.setBoolPref("signon.generation.enabled", true); + Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true); + // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent + // and so it should always be a correctly formed URI when working with + // the PasswordRulesParser and PasswordRulesManager modules + const TEST_ORIGIN = Services.io.newURI("https://example.com"); + + // TEST_BASE_ORIGIN is how each domain is stored in RemoteSettings, and so + // we need this in order to parse out the particular password rules we're verifying + const TEST_BASE_ORIGIN = "example.com"; + const TEST_RULES = "minlength: 6; maxlength: 12;"; + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules( + TEST_BASE_ORIGIN, + TEST_RULES + ); + const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get(); + + let rules = getRulesForRecord(records, TEST_BASE_ORIGIN); + + rules = PasswordRulesParser.parsePasswordRules(rules); + Assert.ok(rules.length, "Rules should exist after parsing"); + + let PRMP = new PasswordRulesManagerParent(); + Assert.ok(PRMP.generatePassword, "PRMP.generatePassword exists"); + + let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN); + Assert.ok(generatedPassword, "A password was generated"); + + verifyPassword(rules, generatedPassword); + + await LoginTestUtils.remoteSettings.cleanImprovedPasswordRules(); +}); + +add_task(async function test_generatePassword_required_special_character() { + // Force password generation to be enabled. + Services.prefs.setBoolPref("signon.generation.available", true); + Services.prefs.setBoolPref("signon.generation.enabled", true); + Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true); + // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent + // and so it should always be a correctly formed URI when working with + // the PasswordRulesParser and PasswordRulesManager modules + const TEST_ORIGIN = Services.io.newURI("https://example.com"); + + // TEST_BASE_ORIGIN is how each domain is stored in RemoteSettings, and so + // we need this in order to parse out the particular password rules we're verifying + const TEST_BASE_ORIGIN = "example.com"; + const TEST_RULES = "required: special"; + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules( + TEST_BASE_ORIGIN, + TEST_RULES + ); + const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get(); + + let rules = getRulesForRecord(records, TEST_BASE_ORIGIN); + + rules = PasswordRulesParser.parsePasswordRules(rules); + Assert.ok(rules.length, "Rules should exist after parsing"); + + let PRMP = new PasswordRulesManagerParent(); + Assert.ok(PRMP.generatePassword, "PRMP.generatePassword exists"); + + let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN); + Assert.ok(generatedPassword, "A password was generated"); + + verifyPassword(TEST_RULES, generatedPassword); +}); + +add_task( + async function test_generatePassword_with_arbitrary_required_characters() { + // Force password generation to be enabled. + Services.prefs.setBoolPref("signon.generation.available", true); + Services.prefs.setBoolPref("signon.generation.enabled", true); + Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true); + // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent + // and so it should always be a correctly formed URI when working with + // the PasswordRulesParser and PasswordRulesManager modules + const TEST_ORIGIN = Services.io.newURI("https://example.com"); + + // TEST_BASE_ORIGIN is how each domain is stored in RemoteSettings, and so + // we need this in order to parse out the particular password rules we're verifying + const TEST_BASE_ORIGIN = "example.com"; + const REQUIRED_ARBITRARY_CHARACTERS = "!#$@*()_+="; + // We use an extremely long password to ensure there are no invalid characters generated in the password. + // This ensures we exhaust all of "allRequiredCharacters" in PasswordGenerator.jsm. + // Otherwise, there's a small chance a "," may have been added to "allRequiredCharacters" + // which will generate an invalid password in this case. + const TEST_RULES = `required: [${REQUIRED_ARBITRARY_CHARACTERS}], upper, lower; maxlength: 255; minlength: 255;`; + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules( + TEST_BASE_ORIGIN, + TEST_RULES + ); + const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get(); + + let rules = getRulesForRecord(records, TEST_BASE_ORIGIN); + + rules = PasswordRulesParser.parsePasswordRules(rules); + Assert.ok(rules.length, "Rules should exist after parsing"); + + let PRMP = new PasswordRulesManagerParent(); + Assert.ok(PRMP.generatePassword, "PRMP.generatePassword exists"); + + let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN); + generatedPassword = await PRMP.generatePassword(TEST_ORIGIN); + Assert.ok(generatedPassword, "A password was generated"); + + verifyPassword(TEST_RULES, generatedPassword); + + let specialCharacters = PasswordGenerator._getSpecialCharacters(); + let digits = PasswordGenerator._getDigits(); + // Additional verification for this password case since + // we want to ensure no extra special characters and no digits are generated. + let disallowedSpecialCharacters = ""; + for (let char of specialCharacters) { + if (!REQUIRED_ARBITRARY_CHARACTERS.includes(char)) { + disallowedSpecialCharacters += char; + } + } + for (let char of disallowedSpecialCharacters) { + Assert.ok( + !generatedPassword.includes(char), + "Password must not contain any disallowed special characters: " + char + ); + } + for (let char of digits) { + Assert.ok( + !generatedPassword.includes(char), + "Password must not contain any digits: " + char + ); + } + } +); + +// Checks the "www4.prepaid.bankofamerica.com" case to ensure the rules are found +add_task(async function test_generatePassword_subdomain_rule() { + const testCases = [ + { + uri: "https://www4.test.example.com", + rulesDomain: "example.com", + shouldApplyPWRule: true, + }, + { + uri: "https://test.example.com", + rulesDomain: "example.com", + shouldApplyPWRule: true, + }, + { + uri: "https://example.com", + rulesDomain: "example.com", + shouldApplyPWRule: true, + }, + { + uri: "https://www4.test.example.com", + rulesDomain: "test.example.com", + shouldApplyPWRule: true, + }, + { + uri: "https://test.example.com", + rulesDomain: "test.example.com", + shouldApplyPWRule: true, + }, + { + uri: "https://example.com", + rulesDomain: "test.example.com", + shouldApplyPWRule: false, + }, + { + uri: "https://evil.com", + rulesDomain: "example.com", + shouldApplyPWRule: false, + }, + { + uri: "https://evil.example.com", + rulesDomain: "test.example.com", + shouldApplyPWRule: false, + }, + { + uri: "https://test.example.com.cn", + rulesDomain: "test.example.com", + shouldApplyPWRule: false, + }, + { + uri: "https://eviltest.example.com", + rulesDomain: "test.example.com", + shouldApplyPWRule: false, + }, + ]; + const TEST_RULES = "required: special; maxlength: 12;"; + + for (let test of testCases) { + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules( + test.rulesDomain, + TEST_RULES + ); + const TEST_ORIGIN = Services.io.newURI(test.uri); + const TEST_BASE_ORIGIN = test.rulesDomain; + const records = await RemoteSettings(IMPROVED_RULES_COLLECTION).get(); + + let rules = getRulesForRecord(records, TEST_BASE_ORIGIN); + + rules = PasswordRulesParser.parsePasswordRules(rules); + Assert.ok(rules.length, "Rules should exist after parsing"); + + let PRMP = new PasswordRulesManagerParent(); + + let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN); + Assert.ok( + generatedPassword, + "A password was generated for URI: " + test.uri + ); + + // If a rule should be applied, we verify the password has all the required classes in the generated password. + if (test.shouldApplyPWRule) { + verifyPassword(rules, generatedPassword); + } + } +}); + +add_task(async function test_improved_password_rules_telemetry() { + // Force password generation to be enabled. + Services.prefs.setBoolPref("signon.generation.available", true); + Services.prefs.setBoolPref("signon.generation.enabled", true); + Services.prefs.setBoolPref("signon.improvedPasswordRules.enabled", true); + + const IMPROVED_PASSWORD_GENERATION_HISTOGRAM = + "PWMGR_NUM_IMPROVED_GENERATED_PASSWORDS"; + + // Clear out the previous pings from this test + let snapshot = TelemetryTestUtils.getAndClearHistogram( + IMPROVED_PASSWORD_GENERATION_HISTOGRAM + ); + + // TEST_ORIGIN emulates the browsingContext.currentWindowGlobal.documentURI variable in LoginManagerParent + // and so it should always be a correctly formed URI when working with + // the PasswordRulesParser and PasswordRulesManager modules + let TEST_ORIGIN = Services.io.newURI("https://example.com"); + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(); + + let PRMP = new PasswordRulesManagerParent(); + + // Generate a password with custom rules, + // so we should send a ping to the custom rules bucket (position 1). + let generatedPassword = await PRMP.generatePassword(TEST_ORIGIN); + Assert.ok(generatedPassword, "A password was generated"); + + TelemetryTestUtils.assertHistogram(snapshot, 1, 1); + + TEST_ORIGIN = Services.io.newURI("https://otherexample.com"); + // Generate a password with default rules, + // so we should send a ping to the default rules bucket (position 0). + snapshot = TelemetryTestUtils.getAndClearHistogram( + IMPROVED_PASSWORD_GENERATION_HISTOGRAM + ); + generatedPassword = await PRMP.generatePassword(TEST_ORIGIN); + Assert.ok(generatedPassword, "A password was generated"); + + TelemetryTestUtils.assertHistogram(snapshot, 0, 1); +}); + +function checkCharacters(password, _characters) { + let containsCharacters = false; + let testString = _characters.join(""); + for (let character of password) { + containsCharacters = testString.includes(character); + if (containsCharacters) { + return containsCharacters; + } + } + return containsCharacters; +} + +function checkConsecutiveCharacters(generatePassword, value) { + let findMaximumRepeating = str => { + let max = 0; + for (let start = 0, end = 1; end < str.length; ) { + if (str[end] === str[start]) { + if (max < end - start + 1) { + max = end - start + 1; + if (max > value) { + return max; + } + } + end++; + } else { + start = end++; + } + } + return max; + }; + let consecutiveCharacters = findMaximumRepeating(generatePassword); + if (consecutiveCharacters <= value) { + return true; + } + return false; +} + +function verifyPassword(rules, generatedPassword) { + const UPPER_CASE_ALPHA = PasswordGenerator._getUpperCaseCharacters(); + const LOWER_CASE_ALPHA = PasswordGenerator._getLowerCaseCharacters(); + const DIGITS = PasswordGenerator._getDigits(); + const SPECIAL_CHARACTERS = PasswordGenerator._getSpecialCharacters(); + for (let rule of rules) { + let { _name, value } = rule; + if (_name === "required") { + for (let required of value) { + if (required._name === "upper") { + let _checkUppercase = new RegExp(`[${UPPER_CASE_ALPHA}]`); + Assert.ok( + generatedPassword.match(_checkUppercase), + "Password must include upper case letter" + ); + } else if (required._name === "lower") { + let _checkLowercase = new RegExp(`[${LOWER_CASE_ALPHA}]`); + Assert.ok( + generatedPassword.match(_checkLowercase), + "Password must include lower case letter" + ); + } else if (required._name === "digit") { + let _checkDigits = new RegExp(`[${DIGITS}]`); + Assert.ok( + generatedPassword.match(_checkDigits), + "Password must include digits" + ); + generatedPassword.match(_checkDigits); + } else if (required._name === "special") { + // We need to escape our special characters since some of them + // have special meaning in regex. + let escapedSpecialCharacters = SPECIAL_CHARACTERS.replace( + /[.*\-+?^${}()|[\]\\]/g, + "\\$&" + ); + let _checkSpecial = new RegExp(`[${escapedSpecialCharacters}]`); + Assert.ok( + generatedPassword.match(_checkSpecial), + "Password must include special character" + ); + } else { + // Nested destructing of the value object in the characters case + let [{ _characters }] = value; + + // We can't use regex to do a quick check here since the + // required characters could be characters that need to be escaped + // in order for the regex to work properly ([]"^...etc) + Assert.ok( + checkCharacters(generatedPassword, _characters), + `Password must contain one of the following characters: ${_characters}` + ); + } + } + } else if (_name === "minlength") { + Assert.ok( + generatedPassword.length >= value, + `Password should have a minimum length of ${value}` + ); + } else if (_name === "maxlength") { + Assert.ok( + generatedPassword.length <= value, + `Password should have a maximum length of ${value}` + ); + } else if (_name === "max-consecutive") { + Assert.ok( + checkConsecutiveCharacters(generatedPassword, value), + `Password must not contain more than ${value} consecutive characters` + ); + } + } +} diff --git a/toolkit/components/passwordmgr/test/unit/test_context_menu.js b/toolkit/components/passwordmgr/test/unit/test_context_menu.js new file mode 100644 index 0000000000..56d4620338 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_context_menu.js @@ -0,0 +1,345 @@ +/** + * Test the password manager context menu. + */ + +"use strict"; + +const { LoginManagerContextMenu } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerContextMenu.sys.mjs" +); + +const dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, { + dateStyle: "medium", +}); + +const ORIGIN_HTTP_EXAMPLE_ORG = "http://example.org"; +const ORIGIN_HTTPS_EXAMPLE_ORG = "https://example.org"; +const ORIGIN_HTTPS_EXAMPLE_ORG_8080 = "https://example.org:8080"; + +const FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1 = formLogin({ + formActionOrigin: ORIGIN_HTTPS_EXAMPLE_ORG, + guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1", + origin: ORIGIN_HTTPS_EXAMPLE_ORG, +}); + +// HTTP version of the above +const FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1 = formLogin({ + formActionOrigin: ORIGIN_HTTP_EXAMPLE_ORG, + guid: "FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1", + origin: ORIGIN_HTTP_EXAMPLE_ORG, +}); + +// Same as above but with a different password +const FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2 = formLogin({ + formActionOrigin: ORIGIN_HTTP_EXAMPLE_ORG, + guid: "FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2", + origin: ORIGIN_HTTP_EXAMPLE_ORG, + password: "pass2", +}); + +// Non-default port + +const FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2 = formLogin({ + formActionOrigin: ORIGIN_HTTPS_EXAMPLE_ORG_8080, + guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2", + origin: ORIGIN_HTTPS_EXAMPLE_ORG_8080, + password: "pass2", +}); + +// HTTP Auth. + +const HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1 = authLogin({ + guid: "FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1", + origin: ORIGIN_HTTPS_EXAMPLE_ORG, +}); + +XPCOMUtils.defineLazyGetter(this, "_stringBundle", function () { + return Services.strings.createBundle( + "chrome://passwordmgr/locale/passwordmgr.properties" + ); +}); + +/** + * Prepare data for the following tests. + */ +add_task(async function test_initialize() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); +}); + +add_task(async function test_sameOriginBothHTTPAndHTTPSDeduped() { + await runTestcase({ + formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin, + savedLogins: [FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1], + expectedItems: [ + { + login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + }, + ], + }); +}); + +add_task(async function test_sameOriginOnlyHTTPS_noUsername() { + let loginWithoutUsername = FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.clone(); + loginWithoutUsername.QueryInterface(Ci.nsILoginMetaInfo).guid = "no-username"; + loginWithoutUsername.username = ""; + await runTestcase({ + formOrigin: loginWithoutUsername.origin, + savedLogins: [loginWithoutUsername], + expectedItems: [ + { + login: loginWithoutUsername, + time: true, + }, + ], + }); +}); + +add_task(async function test_sameOriginOnlyHTTP() { + await runTestcase({ + formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin, + savedLogins: [FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1], + expectedItems: [ + { + login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1, + }, + ], + }); +}); + +// Scheme upgrade/downgrade tasks + +add_task(async function test_sameOriginDedupeSchemeUpgrade() { + await runTestcase({ + formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin, + savedLogins: [ + FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1, + ], + expectedItems: [ + { + login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + }, + ], + }); +}); + +add_task(async function test_sameOriginSchemeDowngrade() { + // Should have no https: when formOrigin is https: + await runTestcase({ + formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin, + savedLogins: [ + FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1, + ], + expectedItems: [ + { + login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1, + }, + ], + }); +}); + +add_task(async function test_sameOriginNotShadowedSchemeUpgrade() { + await runTestcase({ + formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin, + savedLogins: [ + FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2, // Different password + ], + expectedItems: [ + { + login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + time: true, + }, + { + login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2, + time: true, + }, + ], + }); +}); + +add_task(async function test_sameOriginShadowedSchemeDowngrade() { + // Should have no https: when formOrigin is https: + await runTestcase({ + formOrigin: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P1.origin, + savedLogins: [ + FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2, // Different password + ], + expectedItems: [ + { + login: FORM_LOGIN_HTTP_EXAMPLE_ORG_U1_P2, + }, + ], + }); +}); + +// Non-default port tasks + +add_task(async function test_sameDomainDifferentPort_onDefault() { + await runTestcase({ + formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin, + savedLogins: [ + FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2, + ], + expectedItems: [ + { + login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + }, + ], + }); +}); + +add_task(async function test_sameDomainDifferentPort_onNonDefault() { + await runTestcase({ + // Swap the formOrigin compared to above + formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2.origin, + savedLogins: [ + FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2, + ], + expectedItems: [ + { + login: FORM_LOGIN_HTTPS_EXAMPLE_ORG_8080_U1_P2, + }, + ], + }); +}); + +// HTTP auth. suggestions + +add_task(async function test_sameOriginOnlyHTTPAuth() { + await runTestcase({ + formOrigin: FORM_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1.origin, + savedLogins: [HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1], + expectedItems: [ + { + login: HTTP_LOGIN_HTTPS_EXAMPLE_ORG_U1_P1, + }, + ], + }); +}); + +// Helpers + +function formLogin(modifications = {}) { + let mods = Object.assign( + {}, + { + timePasswordChanged: 1573821296000, + }, + modifications + ); + return TestData.formLogin(mods); +} + +function authLogin(modifications = {}) { + let mods = Object.assign( + {}, + { + timePasswordChanged: 1573821296000, + }, + modifications + ); + return TestData.authLogin(mods); +} + +/** + * Tests if the LoginManagerContextMenu returns the correct login items. + */ +async function runTestcase({ formOrigin, savedLogins, expectedItems }) { + const DOCUMENT_CONTENT = "
"; + + await Services.logins.addLogins(savedLogins); + + // Create the logins menuitems fragment. + let { fragment, document } = createLoginsFragment( + formOrigin, + DOCUMENT_CONTENT + ); + + if (!expectedItems.length) { + Assert.ok(fragment === null, "Null returned. No logins were found."); + return; + } + let actualItems = [...fragment.children]; + + // Check if the items are those expected to be listed. + checkLoginItems(actualItems, expectedItems); + + document.body.appendChild(fragment); + + // Try to clear the fragment. + LoginManagerContextMenu.clearLoginsFromMenu(document); + Assert.equal( + document.querySelectorAll("menuitem").length, + 0, + "All items correctly cleared." + ); + + Services.logins.removeAllUserFacingLogins(); +} + +/** + * Create a fragment with a menuitem for each login. + */ +function createLoginsFragment(url, content) { + const CHROME_URL = "chrome://mock-chrome/content/"; + + // Create a mock document. + let document = MockDocument.createTestDocument( + CHROME_URL, + content, + undefined, + true + ); + + // We also need a simple mock Browser object for this test. + let browser = { + ownerDocument: document, + }; + + let formOrigin = LoginHelper.getLoginOrigin(url); + return { + document, + fragment: LoginManagerContextMenu.addLoginsToMenu( + null, + browser, + formOrigin + ), + }; +} + +function checkLoginItems(actualItems, expectedDetails) { + for (let [i, expectedDetail] of expectedDetails.entries()) { + let actualElement = actualItems[i]; + + Assert.equal(actualElement.localName, "menuitem", "Check localName"); + + let expectedLabel = expectedDetail.login.username; + if (!expectedLabel) { + expectedLabel += _stringBundle.GetStringFromName("noUsername"); + } + if (expectedDetail.time) { + expectedLabel += + " (" + + dateAndTimeFormatter.format( + new Date(expectedDetail.login.timePasswordChanged) + ) + + ")"; + } + Assert.equal( + actualElement.getAttribute("label"), + expectedLabel, + `Check label ${i}` + ); + } + + Assert.equal( + actualItems.length, + expectedDetails.length, + "Should have the correct number of menu items" + ); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js new file mode 100644 index 0000000000..f0305e3c69 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js @@ -0,0 +1,411 @@ +/** + * Test LoginHelper.dedupeLogins + */ + +"use strict"; + +const DOMAIN1_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({ + timePasswordChanged: 3000, + timeLastUsed: 2000, +}); +const DOMAIN1_HTTP_TO_HTTP_U1_P2 = TestData.formLogin({ + password: "password two", +}); +const DOMAIN2_HTTP_TO_HTTP_U2_P2 = TestData.formLogin({ + origin: "http://www4.example.com", + formActionOrigin: "http://www4.example.com", + password: "password two", + username: "username two", +}); + +const DOMAIN1_HTTPS_TO_HTTP_U1_P1 = TestData.formLogin({ + formActionOrigin: "http://www.example.com", + origin: "https://www3.example.com", + timePasswordChanged: 4000, + timeLastUsed: 1000, +}); +const DOMAIN1_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({ + formActionOrigin: "https://www.example.com", + origin: "https://www3.example.com", + timePasswordChanged: 4000, + timeLastUsed: 1000, +}); + +const DOMAIN1_HTTPS_TO_EMPTY_U1_P1 = TestData.formLogin({ + formActionOrigin: "", + origin: "https://www3.example.com", +}); +const DOMAIN1_HTTPS_TO_EMPTYU_P1 = TestData.formLogin({ + origin: "https://www3.example.com", + username: "", +}); +const DOMAIN1_HTTP_AUTH = TestData.authLogin({ + origin: "http://www3.example.com", +}); +const DOMAIN1_HTTPS_AUTH = TestData.authLogin({ + origin: "https://www3.example.com", +}); +const DOMAIN1_HTTPS_LOGIN = TestData.formLogin({ + origin: "https://www3.example.com", + formActionOrigin: "https://www3.example.com", +}); +const DOMAIN1_HTTP_LOGIN = TestData.formLogin({ + origin: "http://www3.example.com", + formActionOrigin: "http://www3.example.com", +}); +const DOMAIN1_HTTPS_NONSTANDARD_PORT1 = TestData.formLogin({ + origin: "https://www3.example.com:8001", + formActionOrigin: "https://www3.example.com:8001", +}); +const DOMAIN1_HTTPS_NONSTANDARD_PORT2 = TestData.formLogin({ + origin: "https://www3.example.com:8008", + formActionOrigin: "https://www3.example.com:8008", +}); +const DOMAIN2_HTTPS_LOGIN = TestData.formLogin({ + origin: "https://www4.example.com", + formActionOrigin: "https://www4.example.com", +}); +const DOMAIN2_HTTPS_LOGIN_NEWER = TestData.formLogin({ + origin: "https://www4.example.com", + formActionOrigin: "https://www4.example.com", + timePasswordChanged: 4000, + timeLastUsed: 4000, +}); +const DOMAIN2_HTTPS_TO_HTTPS_U2_P2 = TestData.formLogin({ + origin: "https://www4.example.com", + formActionOrigin: "https://www4.example.com", + password: "password two", + username: "username two", +}); + +add_task(function test_dedupeLogins() { + // [description, expectedOutput, dedupe arg. 0, dedupe arg 1, ...] + let testcases = [ + [ + "exact dupes", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + [], // force no resolveBy logic to test behavior of preferring the first.. + ], + [ + "default uniqueKeys is un + pw", + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2], + undefined, + [], + ], + [ + "same usernames, different passwords, dedupe username only", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2], + ["username"], + [], + ], + [ + "same un+pw, different scheme", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + undefined, + [], + ], + [ + "same un+pw, different scheme, reverse order", + [DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + [], + ], + [ + "same un+pw, different scheme, include origin", + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + ["origin", "username", "password"], + [], + ], + [ + "empty username is not deduped with non-empty", + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_EMPTYU_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_EMPTYU_P1], + undefined, + [], + ], + [ + "empty username is deduped with same passwords", + [DOMAIN1_HTTPS_TO_EMPTYU_P1], + [DOMAIN1_HTTPS_TO_EMPTYU_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + ["password"], + [], + ], + [ + "mix of form and HTTP auth", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTP_AUTH], + undefined, + [], + ], + ]; + + for (let tc of testcases) { + let description = tc.shift(); + let expected = tc.shift(); + let actual = LoginHelper.dedupeLogins(...tc); + Assert.strictEqual(actual.length, expected.length, `Check: ${description}`); + for (let [i, login] of expected.entries()) { + Assert.strictEqual(actual[i], login, `Check index ${i}`); + } + } +}); + +add_task(async function test_dedupeLogins_resolveBy() { + Assert.ok( + DOMAIN1_HTTP_TO_HTTP_U1_P1.timeLastUsed > + DOMAIN1_HTTPS_TO_HTTP_U1_P1.timeLastUsed, + "Sanity check timeLastUsed difference" + ); + Assert.ok( + DOMAIN1_HTTP_TO_HTTP_U1_P1.timePasswordChanged < + DOMAIN1_HTTPS_TO_HTTP_U1_P1.timePasswordChanged, + "Sanity check timePasswordChanged difference" + ); + Assert.ok( + DOMAIN1_HTTPS_LOGIN.timePasswordChanged < + DOMAIN2_HTTPS_LOGIN_NEWER.timePasswordChanged, + "Sanity check timePasswordChanged difference" + ); + + let testcases = [ + [ + "default resolveBy is timeLastUsed", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + ], + [ + "default resolveBy is timeLastUsed, reversed input", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + ], + [ + "resolveBy timeLastUsed + timePasswordChanged", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + ["timeLastUsed", "timePasswordChanged"], + ], + [ + "resolveBy timeLastUsed + timePasswordChanged, reversed input", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + undefined, + ["timeLastUsed", "timePasswordChanged"], + ], + [ + "resolveBy timePasswordChanged", + [DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + ["timePasswordChanged"], + ], + [ + "resolveBy timePasswordChanged, reversed", + [DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + undefined, + ["timePasswordChanged"], + ], + [ + "resolveBy timePasswordChanged + timeLastUsed", + [DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + ["timePasswordChanged", "timeLastUsed"], + ], + [ + "resolveBy timePasswordChanged + timeLastUsed, reversed", + [DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + undefined, + ["timePasswordChanged", "timeLastUsed"], + ], + [ + "resolveBy scheme + timePasswordChanged, prefer HTTP", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + ["scheme", "timePasswordChanged"], + DOMAIN1_HTTP_TO_HTTP_U1_P1.origin, + ], + [ + "resolveBy scheme + timePasswordChanged, prefer HTTP, reversed input", + [DOMAIN1_HTTP_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + undefined, + ["scheme", "timePasswordChanged"], + DOMAIN1_HTTP_TO_HTTP_U1_P1.origin, + ], + [ + "resolveBy scheme + timePasswordChanged, prefer HTTPS", + [DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + ["scheme", "timePasswordChanged"], + DOMAIN1_HTTPS_TO_HTTP_U1_P1.origin, + ], + [ + "resolveBy scheme + timePasswordChanged, prefer HTTPS, reversed input", + [DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTP_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_HTTP_U1_P1], + undefined, + ["scheme", "timePasswordChanged"], + DOMAIN1_HTTPS_TO_HTTP_U1_P1.origin, + ], + [ + "resolveBy scheme HTTP auth", + [DOMAIN1_HTTPS_AUTH], + [DOMAIN1_HTTP_AUTH, DOMAIN1_HTTPS_AUTH], + undefined, + ["scheme"], + DOMAIN1_HTTPS_AUTH.origin, + ], + [ + "resolveBy scheme HTTP auth, reversed input", + [DOMAIN1_HTTPS_AUTH], + [DOMAIN1_HTTPS_AUTH, DOMAIN1_HTTP_AUTH], + undefined, + ["scheme"], + DOMAIN1_HTTPS_AUTH.origin, + ], + [ + "resolveBy scheme, empty form submit URL", + [DOMAIN1_HTTPS_TO_HTTP_U1_P1], + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTPS_TO_EMPTY_U1_P1], + undefined, + ["scheme"], + DOMAIN1_HTTPS_TO_HTTP_U1_P1.origin, + ], + [ + "resolveBy subdomain, different subdomains, same login, subdomain1 preferred", + [DOMAIN1_HTTPS_LOGIN], + [DOMAIN1_HTTPS_LOGIN, DOMAIN2_HTTPS_LOGIN], + undefined, + ["subdomain"], + DOMAIN1_HTTPS_LOGIN.origin, + ], + [ + "resolveBy subdomain, different subdomains, same login, subdomain2 preferred", + [DOMAIN2_HTTPS_LOGIN], + [DOMAIN1_HTTPS_LOGIN, DOMAIN2_HTTPS_LOGIN], + undefined, + ["subdomain"], + DOMAIN2_HTTPS_LOGIN.origin, + ], + [ + "resolveBy subdomain+timePasswordChanged, different subdomains, same login, subdomain1 preferred", + [DOMAIN1_HTTPS_LOGIN], + [DOMAIN1_HTTPS_LOGIN, DOMAIN2_HTTPS_LOGIN_NEWER], + undefined, + ["subdomain", "timePasswordChanged"], + DOMAIN1_HTTPS_LOGIN.origin, + ], + [ + "resolveBy subdomain, same subdomain, different schemes", + [DOMAIN1_HTTPS_LOGIN], + [DOMAIN1_HTTPS_LOGIN, DOMAIN1_HTTP_LOGIN], + undefined, + ["subdomain"], + DOMAIN1_HTTPS_LOGIN.origin, + ], + [ + "resolveBy subdomain, same subdomain, different ports", + [DOMAIN1_HTTPS_LOGIN], + [ + DOMAIN1_HTTPS_LOGIN, + DOMAIN1_HTTPS_NONSTANDARD_PORT1, + DOMAIN1_HTTPS_NONSTANDARD_PORT2, + ], + undefined, + ["subdomain"], + DOMAIN1_HTTPS_LOGIN.origin, + ], + [ + "resolveBy subdomain, same subdomain, different schemes, different ports", + [DOMAIN1_HTTPS_LOGIN], + [ + DOMAIN1_HTTPS_LOGIN, + DOMAIN1_HTTPS_NONSTANDARD_PORT1, + DOMAIN1_HTTPS_NONSTANDARD_PORT2, + ], + undefined, + ["subdomain"], + DOMAIN1_HTTPS_AUTH.origin, + ], + [ + "resolveBy matching searchAndDedupeLogins, prefer domain matches then https: scheme over http:", + // expected: + [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTPS_TO_HTTPS_U2_P2], + // logins: + [ + DOMAIN1_HTTP_TO_HTTP_U1_P1, + DOMAIN1_HTTPS_TO_HTTPS_U1_P1, + DOMAIN2_HTTP_TO_HTTP_U2_P2, + DOMAIN2_HTTPS_TO_HTTPS_U2_P2, + ], + // uniqueKeys: + undefined, + // resolveBy: + ["subdomain", "actionOrigin", "scheme", "timePasswordChanged"], + // preferredOrigin: + DOMAIN1_HTTPS_TO_HTTPS_U1_P1.origin, + ], + ]; + + for (let tc of testcases) { + let description = tc.shift(); + let expected = tc.shift(); + let actual = LoginHelper.dedupeLogins(...tc); + info(`'${description}' actual:\n ${JSON.stringify(actual, null, 2)}`); + Assert.strictEqual(actual.length, expected.length, `Check: ${description}`); + for (let [i, login] of expected.entries()) { + Assert.strictEqual(actual[i], login, `Check index ${i}`); + } + } +}); + +add_task(async function test_dedupeLogins_preferredOriginMissing() { + let testcases = [ + [ + "resolveBy scheme + timePasswordChanged, missing preferredOrigin", + /preferredOrigin/, + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + ["scheme", "timePasswordChanged"], + ], + [ + "resolveBy timePasswordChanged + scheme, missing preferredOrigin", + /preferredOrigin/, + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + ["timePasswordChanged", "scheme"], + ], + [ + "resolveBy scheme + timePasswordChanged, empty preferredOrigin", + /preferredOrigin/, + [DOMAIN1_HTTPS_TO_HTTP_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + undefined, + ["scheme", "timePasswordChanged"], + "", + ], + ]; + + for (let tc of testcases) { + let description = tc.shift(); + let expectedException = tc.shift(); + Assert.throws( + () => { + LoginHelper.dedupeLogins(...tc); + }, + expectedException, + `Check: ${description}` + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js b/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js new file mode 100644 index 0000000000..1d6e161149 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests getLoginSavingEnabled, setLoginSavingEnabled, and getAllDisabledHosts. + */ + +"use strict"; + +// Tests + +/** + * Tests setLoginSavingEnabled and getAllDisabledHosts. + */ +add_task(function test_setLoginSavingEnabled_getAllDisabledHosts() { + // Add some disabled hosts, and verify that different schemes for the same + // domain are considered different hosts. + let origin1 = "http://disabled1.example.com"; + let origin2 = "http://disabled2.example.com"; + let origin3 = "https://disabled2.example.com"; + Services.logins.setLoginSavingEnabled(origin1, false); + Services.logins.setLoginSavingEnabled(origin2, false); + Services.logins.setLoginSavingEnabled(origin3, false); + + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [origin1, origin2, origin3] + ); + + // Adding the same host twice should not result in an error. + Services.logins.setLoginSavingEnabled(origin2, false); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [origin1, origin2, origin3] + ); + + // Removing a disabled host should work. + Services.logins.setLoginSavingEnabled(origin2, true); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [origin1, origin3] + ); + + // Removing the last disabled host should work. + Services.logins.setLoginSavingEnabled(origin1, true); + Services.logins.setLoginSavingEnabled(origin3, true); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [] + ); +}); + +/** + * Tests setLoginSavingEnabled and getLoginSavingEnabled. + */ +add_task(function test_setLoginSavingEnabled_getLoginSavingEnabled() { + let origin1 = "http://disabled.example.com"; + let origin2 = "https://disabled.example.com"; + + // Hosts should not be disabled by default. + Assert.ok(Services.logins.getLoginSavingEnabled(origin1)); + Assert.ok(Services.logins.getLoginSavingEnabled(origin2)); + + // Test setting initial values. + Services.logins.setLoginSavingEnabled(origin1, false); + Services.logins.setLoginSavingEnabled(origin2, true); + Assert.ok(!Services.logins.getLoginSavingEnabled(origin1)); + Assert.ok(Services.logins.getLoginSavingEnabled(origin2)); + + // Test changing values. + Services.logins.setLoginSavingEnabled(origin1, true); + Services.logins.setLoginSavingEnabled(origin2, false); + Assert.ok(Services.logins.getLoginSavingEnabled(origin1)); + Assert.ok(!Services.logins.getLoginSavingEnabled(origin2)); + + // Clean up. + Services.logins.setLoginSavingEnabled(origin2, true); +}); + +/** + * Tests setLoginSavingEnabled with invalid NUL characters in the origin. + */ +add_task(function test_setLoginSavingEnabled_invalid_characters() { + let origin = "http://null\0X.example.com"; + Assert.throws( + () => Services.logins.setLoginSavingEnabled(origin, false), + /Invalid origin/ + ); + + // Verify that no data was stored by the previous call. + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [] + ); +}); + +/** + * Tests different values of the "signon.rememberSignons" property. + */ +add_task(function test_rememberSignons() { + let origin1 = "http://example.com"; + let origin2 = "http://localhost"; + + // The default value for the preference should be true. + Assert.ok(Services.prefs.getBoolPref("signon.rememberSignons")); + + // Hosts should not be disabled by default. + Services.logins.setLoginSavingEnabled(origin1, false); + Assert.ok(!Services.logins.getLoginSavingEnabled(origin1)); + Assert.ok(Services.logins.getLoginSavingEnabled(origin2)); + + // Disable storage of saved passwords globally. + Services.prefs.setBoolPref("signon.rememberSignons", false); + registerCleanupFunction(() => + Services.prefs.clearUserPref("signon.rememberSignons") + ); + + // All hosts should now appear disabled. + Assert.ok(!Services.logins.getLoginSavingEnabled(origin1)); + Assert.ok(!Services.logins.getLoginSavingEnabled(origin2)); + + // The list of disabled hosts should be unaltered. + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [origin1] + ); + + // Changing values with the preference set should work. + Services.logins.setLoginSavingEnabled(origin1, true); + Services.logins.setLoginSavingEnabled(origin2, false); + + // All hosts should still appear disabled. + Assert.ok(!Services.logins.getLoginSavingEnabled(origin1)); + Assert.ok(!Services.logins.getLoginSavingEnabled(origin2)); + + // The list of disabled hosts should have been changed. + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [origin2] + ); + + // Enable storage of saved passwords again. + Services.prefs.setBoolPref("signon.rememberSignons", true); + + // Hosts should now appear enabled as requested. + Assert.ok(Services.logins.getLoginSavingEnabled(origin1)); + Assert.ok(!Services.logins.getLoginSavingEnabled(origin2)); + + // Clean up. + Services.logins.setLoginSavingEnabled(origin2, true); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [] + ); +}); + +/** + * Tests storing disabled hosts with non-ASCII characters where IDN is supported. + */ +add_task( + async function test_storage_setLoginSavingEnabled_nonascii_IDN_is_supported() { + let origin = "http://大.net"; + let encoding = "http://xn--pss.net"; + + // Test adding disabled host with nonascii URL (http://大.net). + Services.logins.setLoginSavingEnabled(origin, false); + await LoginTestUtils.reloadData(); + Assert.equal(Services.logins.getLoginSavingEnabled(origin), false); + Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [origin] + ); + + LoginTestUtils.clearData(); + + // Test adding disabled host with IDN ("http://xn--pss.net"). + Services.logins.setLoginSavingEnabled(encoding, false); + await LoginTestUtils.reloadData(); + Assert.equal(Services.logins.getLoginSavingEnabled(origin), false); + Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [origin] + ); + + LoginTestUtils.clearData(); + } +); + +/** + * Tests storing disabled hosts with non-ASCII characters where IDN is not supported. + */ +add_task( + async function test_storage_setLoginSavingEnabled_nonascii_IDN_not_supported() { + let origin = "http://√.com"; + let encoding = "http://xn--19g.com"; + + // Test adding disabled host with nonascii URL (http://√.com). + Services.logins.setLoginSavingEnabled(origin, false); + await LoginTestUtils.reloadData(); + Assert.equal(Services.logins.getLoginSavingEnabled(origin), false); + Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [encoding] + ); + + LoginTestUtils.clearData(); + + // Test adding disabled host with IDN ("http://xn--19g.com"). + Services.logins.setLoginSavingEnabled(encoding, false); + await LoginTestUtils.reloadData(); + Assert.equal(Services.logins.getLoginSavingEnabled(origin), false); + Assert.equal(Services.logins.getLoginSavingEnabled(encoding), false); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + [encoding] + ); + + LoginTestUtils.clearData(); + } +); diff --git a/toolkit/components/passwordmgr/test/unit/test_displayOrigin.js b/toolkit/components/passwordmgr/test/unit/test_displayOrigin.js new file mode 100644 index 0000000000..a42f066b3e --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_displayOrigin.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test nsILoginInfo.displayOrigin + */ + +"use strict"; + +add_task(function test_displayOrigin() { + // Trying to access `displayOrigin` for each login shouldn't throw. + for (let loginInfo of TestData.loginList()) { + let { displayOrigin } = loginInfo; + info(loginInfo.origin); + info(displayOrigin); + Assert.equal(typeof displayOrigin, "string", "Check type"); + Assert.greater(displayOrigin.length, 0, "Check length"); + if (loginInfo.origin.startsWith("file://")) { + // Fails to create the URL + Assert.ok(displayOrigin.startsWith(loginInfo.origin), "Contains origin"); + } else { + Assert.ok( + displayOrigin.startsWith(loginInfo.origin.replace(/.+:\/\//, "")), + "Contains domain" + ); + } + let matches; + if ((matches = loginInfo.origin.match(/:([0-9]+)$/))) { + Assert.ok(displayOrigin.includes(matches[1]), "Check port is included"); + } + Assert.ok(!displayOrigin.includes("null"), "Doesn't contain `null`"); + Assert.ok( + !displayOrigin.includes("undefined"), + "Doesn't contain `undefined`" + ); + if (loginInfo.httpRealm !== null) { + Assert.ok( + displayOrigin.includes(loginInfo.httpRealm), + "Contains httpRealm" + ); + } + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js b/toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js new file mode 100644 index 0000000000..fb38f08cf7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js @@ -0,0 +1,57 @@ +/** + * Test LoginHelper.doLoginsMatch + */ + +add_task(function test_formActionOrigin_ignoreSchemes() { + let httpActionLogin = TestData.formLogin(); + let httpsActionLogin = TestData.formLogin({ + formActionOrigin: "https://www.example.com", + }); + let jsActionLogin = TestData.formLogin({ + formActionOrigin: "javascript:", + }); + let emptyActionLogin = TestData.formLogin({ + formActionOrigin: "", + }); + + Assert.notEqual( + httpActionLogin.formActionOrigin, + httpsActionLogin.formActionOrigin, + "Ensure actions differ" + ); + + const TEST_CASES = [ + [httpActionLogin, httpActionLogin, true], + [httpsActionLogin, httpsActionLogin, true], + [jsActionLogin, jsActionLogin, true], + [emptyActionLogin, emptyActionLogin, true], + // only differing by scheme: + [httpsActionLogin, httpActionLogin, true], + [httpActionLogin, httpsActionLogin, true], + + // empty matches everything + [httpsActionLogin, emptyActionLogin, true], + [emptyActionLogin, httpsActionLogin, true], + [jsActionLogin, emptyActionLogin, true], + [emptyActionLogin, jsActionLogin, true], + + // Begin false cases: + [httpsActionLogin, jsActionLogin, false], + [jsActionLogin, httpsActionLogin, false], + [httpActionLogin, jsActionLogin, false], + [jsActionLogin, httpActionLogin, false], + ]; + + for (let [login1, login2, expected] of TEST_CASES) { + Assert.strictEqual( + LoginHelper.doLoginsMatch(login1, login2, { + ignorePassword: false, + ignoreSchemes: true, + }), + expected, + `LoginHelper.doLoginsMatch: +\t${JSON.stringify(login1)} +\t${JSON.stringify(login2)}` + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_findRelatedRealms.js b/toolkit/components/passwordmgr/test/unit/test_findRelatedRealms.js new file mode 100644 index 0000000000..b79a0ab4c4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_findRelatedRealms.js @@ -0,0 +1,156 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { LoginRelatedRealmsParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginRelatedRealms.sys.mjs" +); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const REMOTE_SETTINGS_COLLECTION = "websites-with-shared-credential-backends"; + +add_task(async function test_related_domain_matching() { + const client = RemoteSettings(REMOTE_SETTINGS_COLLECTION); + const records = await client.get(); + console.log(records); + + // Assumes that the test collection is a 2D array with one subarray + let relatedRealms = records[0].relatedRealms; + relatedRealms = relatedRealms.flat(); + Assert.ok(relatedRealms); + + let LRR = new LoginRelatedRealmsParent(); + + // We should not return unrelated realms + let result = await LRR.findRelatedRealms("https://not-example.com"); + equal(result.length, 0, "Check that there were no related realms found"); + + // We should not return unrelated realms given an unrelated subdomain + result = await LRR.findRelatedRealms("https://sub.not-example.com"); + equal(result.length, 0, "Check that there were no related realms found"); + // We should return the related realms collection + result = await LRR.findRelatedRealms("https://sub.example.com"); + equal( + result.length, + relatedRealms.length, + "Ensure that three related realms were found" + ); + + // We should return the related realms collection minus the base domain that we searched with + result = await LRR.findRelatedRealms("https://example.co.uk"); + equal( + result.length, + relatedRealms.length - 1, + "Ensure that two related realms were found" + ); +}); + +add_task(async function test_newly_synced_collection() { + // Initialize LoginRelatedRealmsParent so the sync handler is enabled + let LRR = new LoginRelatedRealmsParent(); + await LRR.getSharedCredentialsCollection(); + + const client = RemoteSettings(REMOTE_SETTINGS_COLLECTION); + let records = await client.get(); + const record1 = { + id: records[0].id, + relatedRealms: records[0].relatedRealms, + }; + + // Assumes that the test collection is a 2D array with one subarray + let originalRelatedRealms = records[0].relatedRealms; + originalRelatedRealms = originalRelatedRealms.flat(); + Assert.ok(originalRelatedRealms); + + const updatedRelatedRealms = ["completely-different.com", "example.com"]; + const record2 = { + id: "some-other-ID", + relatedRealms: [updatedRelatedRealms], + }; + const payload = { + current: [record2], + created: [record2], + updated: [], + deleted: [record1], + }; + await RemoteSettings(REMOTE_SETTINGS_COLLECTION).emit("sync", { + data: payload, + }); + + let [{ id, relatedRealms }] = await LRR.getSharedCredentialsCollection(); + equal(id, record2.id, "internal collection ID should be updated"); + equal( + relatedRealms, + record2.relatedRealms, + "internal collection related realms should be updated" + ); + + // We should return only one result, and that result should be example.com + // NOT other-example.com or example.co.uk + let result = await LRR.findRelatedRealms("https://completely-different.com"); + equal( + result.length, + updatedRelatedRealms.length - 1, + "Check that there is only one related realm found" + ); + equal( + result[0], + "example.com", + "Ensure that the updated collection should only match example.com" + ); +}); + +add_task(async function test_no_related_domains() { + await LoginTestUtils.remoteSettings.cleanWebsitesWithSharedCredentials(); + + const client = RemoteSettings(REMOTE_SETTINGS_COLLECTION); + let records = await client.get(); + + equal(records.length, 0, "Check that there are no related realms"); + + let LRR = new LoginRelatedRealmsParent(); + + Assert.ok(LRR.findRelatedRealms, "Ensure findRelatedRealms exists"); + + let result = await LRR.findRelatedRealms("https://example.com"); + equal(result.length, 0, "Assert that there were no related realms found"); +}); + +add_task(async function test_unrelated_subdomains() { + await LoginTestUtils.remoteSettings.cleanWebsitesWithSharedCredentials(); + let testCollection = [ + ["slpl.bibliocommons.com", "slpl.overdrive.com"], + ["springfield.overdrive.com", "coolcat.org"], + ]; + await LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials( + testCollection + ); + + let LRR = new LoginRelatedRealmsParent(); + let result = await LRR.findRelatedRealms("https://evil.overdrive.com"); + equal(result.length, 0, "Assert that there were no related realms found"); + + result = await LRR.findRelatedRealms("https://abc.slpl.bibliocommons.com"); + equal(result.length, 2, "Assert that two related realms were found"); + equal(result[0], testCollection[0][0]); + equal(result[1], testCollection[0][1]); + + result = await LRR.findRelatedRealms("https://slpl.overdrive.com"); + console.log("what is result: " + result); + equal(result.length, 1, "Assert that one related realm was found"); + for (let item of result) { + notEqual( + item, + "coolcat.org", + "coolcat.org is not related to slpl.overdrive.com" + ); + notEqual( + item, + "springfield.overdrive.com", + "springfield.overdrive.com is not related to slpl.overdrive.com" + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_getFormFields.js b/toolkit/components/passwordmgr/test/unit/test_getFormFields.js new file mode 100644 index 0000000000..a04b497181 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_getFormFields.js @@ -0,0 +1,572 @@ +/** + * Test for LoginFormState._getFormFields. + */ + +"use strict"; + +const { LoginFormFactory } = ChromeUtils.importESModule( + "resource://gre/modules/LoginFormFactory.sys.mjs" +); + +const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" +); + +const TESTENVIRONMENTS = { + filledPW1WithGeneratedPassword: { + generatedPWFieldSelectors: ["#pw1"], + }, +}; + +const TESTCASES = [ + { + description: "1 password field outside of a
", + document: ``, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "1 text field outside of a without a password field", + document: ``, + returnedFieldIDs: { + usernameField: null, + newPasswordField: null, + oldPasswordField: null, + }, + skipEmptyFields: undefined, + // there is no password field to fill, so no sense testing with gen. passwords + extraTestEnvironments: [], + extraTestPreferences: [], + }, + { + description: "1 username & password field outside of a ", + document: ` + `, + returnedFieldIDs: { + usernameField: "un1", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + beforeGetFunction(doc, formLike) { + // Access the formLike.elements lazy getter to have it cached. + Assert.equal( + formLike.elements.length, + 2, + "Check initial elements length" + ); + doc.getElementById("un1").remove(); + }, + description: "1 username & password field outside of a , un1 removed", + document: ` + `, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "1 username & password field in a ", + document: ` + + +
`, + returnedFieldIDs: { + usernameField: "un1", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "5 empty password fields outside of a
", + document: ` + + + + `, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "6 empty password fields outside of a ", + document: ` + + + + + `, + returnedFieldIDs: { + usernameField: null, + newPasswordField: null, + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "4 password fields outside of a (1 empty, 3 full) with skipEmpty", + document: ` + + + `, + returnedFieldIDs: { + usernameField: null, + newPasswordField: null, + oldPasswordField: null, + }, + skipEmptyFields: true, + // This test assumes that pw1 has not been filled, so don't test prefilling it + extraTestEnvironments: [], + extraTestPreferences: [], + }, + { + description: "Form with 1 password field", + document: `
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "Form with 2 password fields", + document: `
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "1 password field in a form, 1 outside (not processed)", + document: `
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "1 password field in a form, 1 text field outside (not processed)", + document: `
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "1 text field in a form, 1 password field outside (not processed)", + document: `
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: null, + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "2 password fields outside of a
with 1 linked via @form", + document: ` +
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "2 password fields outside of a
with 1 linked via @form + skipEmpty", + document: ` +
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: null, + oldPasswordField: null, + }, + skipEmptyFields: true, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "2 password fields outside of a
with 1 linked via @form + skipEmpty with 1 empty", + document: ` +
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: true, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "3 password fields, 2nd and 3rd are filled with generated passwords", + document: ` + + `, + returnedFieldIDs: { + usernameField: null, + newPasswordField: "pw2", + confirmPasswordField: "pw3", + oldPasswordField: "pw1", + }, + skipEmptyFields: undefined, + generatedPWFieldSelectors: ["#pw2", "#pw3"], + // this test doesn't make sense to run with different filled generated password values + extraTestEnvironments: [], + extraTestPreferences: [], + }, + // begin of getusername heuristic tests + { + description: "multiple non-username like input fields in a
", + document: ` + + + + +
`, + returnedFieldIDs: { + usernameField: "un3", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "1 username input and multiple non-username like input in a
", + document: ` + + + + +
`, + returnedFieldIDs: { + usernameField: "un2", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "1 email input and multiple non-username like input in a
", + document: ` + + + + +
`, + returnedFieldIDs: { + usernameField: "un2", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "1 username & 1 email field, the email field is more close to the password", + document: `
+ + + +
`, + returnedFieldIDs: { + usernameField: "un1", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: + "1 username and 1 email field, the username field is more close to the password", + document: `
+ + + +
`, + returnedFieldIDs: { + usernameField: "un2", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "2 username fields in a
", + document: ` + + + + +
`, + returnedFieldIDs: { + usernameField: "un2", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "2 email fields in a
", + document: ` + + + + +
`, + returnedFieldIDs: { + usernameField: "un1", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + { + description: "the password field precedes the username field", + document: `
+ + + +
`, + returnedFieldIDs: { + usernameField: "un1", + newPasswordField: "pw1", + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword], + extraTestPreferences: [], + }, + // end of getusername heuristic tests + { + description: "1 username field in a
", + document: ` + +
`, + returnedFieldIDs: { + usernameField: "un1", + newPasswordField: null, + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [], + extraTestPreferences: [], + }, + { + description: "1 input field in a
", + document: ` + +
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: null, + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [], + extraTestPreferences: [], + }, + { + description: "1 username field in a
with usernameOnlyForm pref off", + document: ` + +
`, + returnedFieldIDs: { + usernameField: null, + newPasswordField: null, + oldPasswordField: null, + }, + skipEmptyFields: undefined, + extraTestEnvironments: [], + extraTestPreferences: [["signon.usernameOnlyForm.enabled", false]], + }, +]; + +const TEST_ENVIRONMENT_CASES = TESTCASES.flatMap(tc => { + let arr = [tc]; + // also run this test case with this different state + for (let env of tc.extraTestEnvironments) { + arr.push({ + ...tc, + ...env, + }); + } + return arr; +}); + +function _setPrefs() { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); +} + +_setPrefs(); + +for (let tc of TEST_ENVIRONMENT_CASES) { + info("Sanity checking the testcase: " + tc.description); + + (function () { + let testcase = tc; + add_task(async function () { + info("Starting testcase: " + testcase.description); + + for (let pref of testcase.extraTestPreferences) { + Services.prefs.setBoolPref(pref[0], pref[1]); + } + + info("Document string: " + testcase.document); + let document = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let input = document.querySelector("input"); + MockDocument.mockOwnerDocumentProperty( + input, + document, + "http://localhost:8080/test/" + ); + + let formLike = LoginFormFactory.createFromField(input); + + if (testcase.beforeGetFunction) { + await testcase.beforeGetFunction(document, formLike); + } + + let lmc = new LoginManagerChild(); + let loginFormState = lmc.stateForDocument(formLike.ownerDocument); + loginFormState.generatedPasswordFields = _generateDocStateFromTestCase( + testcase, + document + ); + + let actual = loginFormState._getFormFields( + formLike, + testcase.skipEmptyFields, + new Set() + ); + + [ + "usernameField", + "newPasswordField", + "oldPasswordField", + "confirmPasswordField", + ].forEach(fieldName => { + Assert.ok( + fieldName in actual, + "_getFormFields return value includes " + fieldName + ); + }); + for (let key of Object.keys(testcase.returnedFieldIDs)) { + let expectedID = testcase.returnedFieldIDs[key]; + if (expectedID === null) { + Assert.strictEqual( + actual[key], + expectedID, + "Check returned field " + key + " is null" + ); + } else { + Assert.strictEqual( + actual[key].id, + expectedID, + "Check returned field " + key + " ID" + ); + } + } + + for (let pref of tc.extraTestPreferences) { + Services.prefs.clearUserPref(pref[0]); + } + }); + })(); +} + +function _generateDocStateFromTestCase(stateProperties, document) { + // prepopulate the document form state LMC holds with + // any generated password fields defined in this testcase + let generatedPasswordFields = new Set(); + info( + "stateProperties has generatedPWFieldSelectors: " + + stateProperties.generatedPWFieldSelectors?.join(", ") + ); + + if (stateProperties.generatedPWFieldSelectors?.length) { + stateProperties.generatedPWFieldSelectors.forEach(sel => { + let field = document.querySelector(sel); + if (field) { + generatedPasswordFields.add(field); + } else { + info(`No password field: ${sel} found in this document`); + } + }); + } + return generatedPasswordFields; +} diff --git a/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js b/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js new file mode 100644 index 0000000000..eee38c30d8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js @@ -0,0 +1,308 @@ +/** + * Test for LoginFormState._getPasswordFields using LoginFormFactory. + */ + +/* globals todo_check_eq */ +"use strict"; + +const { LoginFormFactory } = ChromeUtils.importESModule( + "resource://gre/modules/LoginFormFactory.sys.mjs" +); +const { LoginFormState } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" +); +const TESTCASES = [ + { + description: "Empty document", + document: ``, + returnedFieldIDsByFormLike: [], + minPasswordLength: undefined, + }, + { + description: "Non-password input with no
present", + document: ``, + // Only the IDs of password fields should be in this array + returnedFieldIDsByFormLike: [[]], + minPasswordLength: undefined, + }, + { + description: "1 password field outside of a ", + document: ``, + returnedFieldIDsByFormLike: [["pw1"]], + minPasswordLength: undefined, + }, + { + description: "5 empty password fields outside of a ", + document: ` + + + + `, + returnedFieldIDsByFormLike: [["pw1", "pw2", "pw3", "pw4", "pw5"]], + minPasswordLength: undefined, + }, + { + description: "6 empty password fields outside of a ", + document: ` + + + + + `, + returnedFieldIDsByFormLike: [[]], + minPasswordLength: undefined, + }, + { + description: + "4 password fields outside of a (1 empty, 3 full) with minPasswordLength=2", + document: ` + + + `, + returnedFieldIDsByFormLike: [["pw2", "pw3", "pw4"]], + minPasswordLength: 2, + }, + { + description: "Form with 1 password field", + document: `
`, + returnedFieldIDsByFormLike: [["pw1"]], + minPasswordLength: undefined, + }, + { + description: "Form with 2 password fields", + document: `
`, + returnedFieldIDsByFormLike: [["pw1", "pw2"]], + minPasswordLength: undefined, + }, + { + description: "1 password field in a form, 1 outside", + document: `
`, + returnedFieldIDsByFormLike: [["pw1"], ["pw2"]], + minPasswordLength: undefined, + }, + { + description: + "2 password fields outside of a
with 1 linked via @form", + document: ` +
`, + returnedFieldIDsByFormLike: [["pw1"], ["pw2"]], + minPasswordLength: undefined, + }, + { + description: + "2 password fields outside of a
with 1 linked via @form + minPasswordLength", + document: ` +
`, + returnedFieldIDsByFormLike: [[], []], + minPasswordLength: 2, + }, + { + description: "minPasswordLength should also skip white-space only fields", + /* eslint-disable no-tabs */ + document: ` + + +
`, + /* eslint-enable no-tabs */ + returnedFieldIDsByFormLike: [[], []], + minPasswordLength: 2, + }, + { + description: "minPasswordLength should skip too-short field values", + document: `
+ + + +
`, + returnedFieldIDsByFormLike: [["pw"]], + minPasswordLength: 2, + }, + { + description: "minPasswordLength should allow matching-length field values", + document: `
+ + + +
`, + returnedFieldIDsByFormLike: [["pw-matchlen", "pw"]], + minPasswordLength: 2, + }, + { + description: + "2 password fields outside of a
with 1 linked via @form + minPasswordLength with 1 empty", + document: ` +
`, + returnedFieldIDsByFormLike: [["pw1"], []], + minPasswordLength: 2, + fieldOverrideRecipe: { + // Ensure a recipe without `notPasswordSelector` doesn't cause a problem. + hosts: ["localhost:8080"], + }, + }, + { + description: + "3 password fields outside of a
with 1 linked via @form + minPasswordLength", + document: ` +
`, + returnedFieldIDsByFormLike: [["pw3"], ["pw2"]], + minPasswordLength: 2, + fieldOverrideRecipe: { + hosts: ["localhost:8080"], + notPasswordSelector: "#pw1", + }, + }, + { + beforeGetFunction(doc) { + doc.getElementById("pw1").remove(); + }, + description: + "1 password field outside of a
which gets removed/disconnected", + document: ``, + returnedFieldIDsByFormLike: [[]], + minPasswordLength: undefined, + }, +]; + +for (let tc of TESTCASES) { + info("Sanity checking the testcase: " + tc.description); + + (function () { + let testcase = tc; + add_task(async function () { + info("Starting testcase: " + testcase.description); + let document = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let mapRootElementToFormLike = new Map(); + for (let input of document.querySelectorAll("input")) { + let formLike = LoginFormFactory.createFromField(input); + let existingFormLike = mapRootElementToFormLike.get( + formLike.rootElement + ); + if (!existingFormLike) { + mapRootElementToFormLike.set(formLike.rootElement, formLike); + continue; + } + + // If the formLike is already present, ensure that the properties are the same. + info( + "Checking if the new FormLike for the same root has the same properties" + ); + formLikeEqual(formLike, existingFormLike); + } + + if (testcase.beforeGetFunction) { + await testcase.beforeGetFunction(document); + } + + Assert.strictEqual( + mapRootElementToFormLike.size, + testcase.returnedFieldIDsByFormLike.length, + "Check the correct number of different formLikes were returned" + ); + + let formLikeIndex = -1; + for (let formLikeFromInput of mapRootElementToFormLike.values()) { + formLikeIndex++; + let pwFields = LoginFormState._getPasswordFields(formLikeFromInput, { + fieldOverrideRecipe: testcase.fieldOverrideRecipe, + minPasswordLength: testcase.minPasswordLength, + }); + + if ( + ChromeUtils.getClassName(formLikeFromInput.rootElement) === + "HTMLFormElement" + ) { + let formLikeFromForm = LoginFormFactory.createFromForm( + formLikeFromInput.rootElement + ); + info( + "Checking that the FormLike created for the matches" + + " the one from a password field" + ); + formLikeEqual(formLikeFromInput, formLikeFromForm); + } + + if (testcase.returnedFieldIDsByFormLike[formLikeIndex].length === 0) { + Assert.strictEqual( + pwFields, + null, + "If no password fields were found null should be returned" + ); + } else { + Assert.strictEqual( + pwFields.length, + testcase.returnedFieldIDsByFormLike[formLikeIndex].length, + "Check the # of password fields for formLike #" + formLikeIndex + ); + } + + for ( + let i = 0; + i < testcase.returnedFieldIDsByFormLike[formLikeIndex].length; + i++ + ) { + let expectedID = + testcase.returnedFieldIDsByFormLike[formLikeIndex][i]; + Assert.strictEqual( + pwFields[i].element.id, + expectedID, + "Check password field " + i + " ID" + ); + } + } + }); + })(); +} + +const EMOJI_TESTCASES = [ + { + description: + "Single characters composed of 2 code units should ideally fail minPasswordLength of 2", + document: ` + +
`, + returnedFieldIDsByFormLike: [["pw"]], + minPasswordLength: 2, + }, + { + description: + "Single characters composed of multiple code units should ideally fail minPasswordLength of 2", + document: `
+ +
`, + minPasswordLength: 2, + }, +]; + +// Note: Bug 780449 tracks our handling of emoji and multi-code-point characters in password fields +// and the .length we should expect when a password value includes them +for (let tc of EMOJI_TESTCASES) { + info("Sanity checking the testcase: " + tc.description); + + (function () { + let testcase = tc; + add_task(async function () { + info("Starting testcase: " + testcase.description); + let document = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let input = document.querySelector("input[type='password']"); + Assert.ok(input, "Found the password field"); + let formLike = LoginFormFactory.createFromField(input); + let pwFields = LoginFormState._getPasswordFields(formLike, { + minPasswordLength: testcase.minPasswordLength, + }); + info("Got password fields: " + pwFields.length); + todo_check_eq( + pwFields.length, + 0, + "Check a single-character (emoji) password is excluded from the password fields collection" + ); + }); + })(); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js b/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js new file mode 100644 index 0000000000..9df18d05cc --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js @@ -0,0 +1,35 @@ +/** + * Test for LoginHelper.getLoginOrigin + */ + +"use strict"; + +const TESTCASES = [ + ["javascript:void(0);", null], + ["javascript:void(0);", "javascript:", true], + ["chrome://MyAccount", "chrome://myaccount"], + ["data:text/html,example", null], + [ + "http://username:password@example.com:80/foo?bar=baz#fragment", + "http://example.com", + true, + ], + ["http://127.0.0.1:80/foo", "http://127.0.0.1"], + ["http://[::1]:80/foo", "http://[::1]"], + ["http://example.com:8080/foo", "http://example.com:8080"], + ["http://127.0.0.1:8080/foo", "http://127.0.0.1:8080", true], + ["http://[::1]:8080/foo", "http://[::1]:8080"], + ["https://example.com:443/foo", "https://example.com"], + ["https://[::1]:443/foo", "https://[::1]"], + ["https://[::1]:8443/foo", "https://[::1]:8443"], + ["ftp://username:password@[::1]:2121/foo", "ftp://[::1]:2121"], + [ + "moz-proxy://username:password@123.456.789.123:12345/foo", + "moz-proxy://123.456.789.123:12345", + ], +]; + +for (let [input, expected, allowJS] of TESTCASES) { + let actual = LoginHelper.getLoginOrigin(input, allowJS); + Assert.strictEqual(actual, expected, "Checking: " + input); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js b/toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js new file mode 100644 index 0000000000..86aae5d14c --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js @@ -0,0 +1,177 @@ +/** + * Test for LoginFormState.getUserNameAndPasswordFields + */ + +"use strict"; + +const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" +); +const TESTCASES = [ + { + description: "1 password field outside of a
", + document: ``, + returnedFieldIDs: [null, "pw1", null], + }, + { + description: "1 text field in a without a password field", + document: ` + +
`, + returnedFieldIDs: [null, null, null], + }, + { + description: "1 text field outside of a
without a password field", + document: ``, + returnedFieldIDs: [null, null, null], + }, + { + description: "1 username & password field outside of a ", + document: ` + `, + returnedFieldIDs: ["un1", "pw1", null], + }, + { + description: "1 username & password field in a ", + document: ` + + +
`, + returnedFieldIDs: ["un1", "pw1", null], + }, + { + description: "5 empty password fields outside of a
", + document: ` + + + + `, + returnedFieldIDs: [null, "pw1", null], + }, + { + description: "6 empty password fields outside of a ", + document: ` + + + + + `, + returnedFieldIDs: [null, null, null], + }, + { + description: "Form with 1 password field", + document: `
`, + returnedFieldIDs: [null, "pw1", null], + }, + { + description: "Form with 2 password fields", + document: `
`, + returnedFieldIDs: [null, "pw1", null], + }, + { + description: "1 password field in a form, 1 outside (not processed)", + document: `
`, + returnedFieldIDs: [null, "pw1", null], + }, + { + description: + "1 password field in a form, 1 text field outside (not processed)", + document: `
`, + returnedFieldIDs: [null, "pw1", null], + }, + { + description: + "1 text field in a form, 1 password field outside (not processed)", + document: `
`, + returnedFieldIDs: [null, null, null], + }, + { + description: + "2 password fields outside of a
with 1 linked via @form", + document: ` +
`, + returnedFieldIDs: [null, "pw1", null], + }, + { + description: "1 username field in a
", + document: ` + +
`, + returnedFieldIDs: ["un1", null, null], + }, + { + description: "1 username field outside of a
", + document: ``, + returnedFieldIDs: [null, null, null], + }, +]; + +function _setPrefs() { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); +} + +_setPrefs(); + +for (let tc of TESTCASES) { + info("Sanity checking the testcase: " + tc.description); + + (function () { + let testcase = tc; + add_task(async function () { + info("Starting testcase: " + testcase.description); + let document = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let input = document.querySelector("input"); + MockDocument.mockOwnerDocumentProperty( + input, + document, + "http://localhost:8080/test/" + ); + MockDocument.mockNodePrincipalProperty( + input, + "http://localhost:8080/test/" + ); + + // Additional mock to cache recipes + let win = {}; + Object.defineProperty(document, "defaultView", { + value: win, + }); + let formOrigin = LoginHelper.getLoginOrigin(document.documentURI); + LoginRecipesContent.cacheRecipes(formOrigin, win, new Set()); + + const loginManagerChild = new LoginManagerChild(); + const docState = loginManagerChild.stateForDocument(document); + let actual = docState.getUserNameAndPasswordFields(input); + + Assert.strictEqual( + testcase.returnedFieldIDs.length, + 3, + "getUserNameAndPasswordFields returns 3 elements" + ); + + for (let i = 0; i < testcase.returnedFieldIDs.length; i++) { + let expectedID = testcase.returnedFieldIDs[i]; + if (expectedID === null) { + Assert.strictEqual( + actual[i], + expectedID, + "Check returned field " + i + " is null" + ); + } else { + Assert.strictEqual( + actual[i].id, + expectedID, + "Check returned field " + i + " ID" + ); + } + } + }); + })(); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_getUsernameFieldFromUsernameOnlyForm.js b/toolkit/components/passwordmgr/test/unit/test_getUsernameFieldFromUsernameOnlyForm.js new file mode 100644 index 0000000000..1af2bb889c --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_getUsernameFieldFromUsernameOnlyForm.js @@ -0,0 +1,179 @@ +/** + * Test for LoginFormState.getUsernameFieldFromUsernameOnlyForm + */ + +"use strict"; + +const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" +); + +// expectation[0] tests cases when a form doesn't have a sign-in keyword. +// expectation[1] tests cases when a form has a sign-in keyword. +const TESTCASES = [ + { + description: "1 text input field", + document: ` + +
`, + expectations: [false, true], + }, + { + description: "1 text input field & 1 hidden input fields", + document: `
+ + +
`, + expectations: [false, true], + }, + { + description: "1 username field", + document: `
+ +
`, + expectations: [true, true], + }, + { + description: "1 username field & 1 hidden input fields", + document: `
+ + +
`, + expectations: [true, true], + }, + { + description: "1 username field, 1 hidden input field, & 1 password field", + document: `
+ + + +
`, + expectations: [false, false], + }, + { + description: "1 password field", + document: `
+ +
`, + expectations: [false, false], + }, + { + description: "1 username & password field", + document: `
+ + +
`, + expectations: [false, false], + }, + { + description: "1 username & text field", + document: `
+ + +
`, + expectations: [false, false], + }, + { + description: "2 text input fields", + document: `
+ + +
`, + expectations: [false, false], + }, + { + description: "2 username fields", + document: `
+ + +
`, + expectations: [false, false], + }, + { + description: "1 username field with search keyword", + document: `
+ +
`, + expectations: [false, false], + }, + { + description: "1 text input field with code keyword", + document: `
+ +
`, + expectations: [false, false], + }, + { + description: "Form with only a hidden field", + document: `
+ +
`, + expectations: [false, false], + }, + { + description: "Form with only a button", + document: `
+ +
`, + expectations: [false, false], + }, + { + description: "A username only form matches not username selector", + document: `
+ +
`, + fieldOverrideRecipe: { + hosts: ["localhost:8080"], + notUsernameSelector: 'input[name="secret_username"]', + }, + expectations: [false, false], + }, +]; + +function _setPrefs() { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); +} + +_setPrefs(); + +for (let tc of TESTCASES) { + info("Sanity checking the testcase: " + tc.description); + + // A form is considered a username-only form + for (let formHasSigninKeyword of [false, true]) { + (function () { + const testcase = tc; + add_task(async function () { + if (formHasSigninKeyword) { + testcase.decription += " (form has a login keyword)"; + } + info("Starting testcase: " + testcase.description); + info("Document string: " + testcase.document); + const document = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let form = document.querySelector("form"); + if (formHasSigninKeyword) { + form.setAttribute("name", "login"); + } + + const lmc = new LoginManagerChild(); + const docState = lmc.stateForDocument(form.ownerDocument); + const element = docState.getUsernameFieldFromUsernameOnlyForm( + form, + testcase.fieldOverrideRecipe + ); + Assert.strictEqual( + testcase.expectations[formHasSigninKeyword ? 1 : 0], + element != null, + `Return incorrect result when the layout is ${testcase.description}` + ); + }); + })(); + } +} diff --git a/toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js b/toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js new file mode 100644 index 0000000000..01bc3ff816 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js @@ -0,0 +1,98 @@ +/** + * Test for LoginHelper.isInferredLoginForm. + */ + +"use strict"; + +const attributeTestData = [ + { + testValues: ["", "form", "search", "signup", "sign-up", "sign/up"], + expectation: false, + }, + { + testValues: [ + "Login", + "Log in", + "Log on", + "Log-on", + "Sign in", + "Sigin", + "Sign/in", + "Sign-in", + "Sign on", + "Sign-on", + "loginForm", + "form-sign-in", + ], + expectation: true, + }, +]; + +const classNameTestData = [ + { + testValues: [ + "", + "inputTxt form-control", + "user-input form-name", + "text name mail", + "form signup", + ], + expectation: false, + }, + { + testValues: ["login form"], + expectation: true, + }, +]; + +const TESTCASES = [ + { + description: "Test id attribute", + update: (doc, v) => { + doc.querySelector("form").setAttribute("id", v); + }, + subtests: attributeTestData, + }, + { + description: "Test name attribute", + update: (doc, v) => { + doc.querySelector("form").setAttribute("name", v); + }, + subtests: attributeTestData, + }, + { + description: "Test class attribute", + update: (doc, v) => { + doc.querySelector("form").setAttribute("class", v); + }, + subtests: [...attributeTestData, ...classNameTestData], + }, +]; + +for (let testcase of TESTCASES) { + info("Sanity checking the testcase: " + testcase.description); + + (function () { + add_task(async function () { + info("Starting testcase: " + testcase.description); + + for (let subtest of testcase.subtests) { + const document = MockDocument.createTestDocument( + "http://localhost:8080/test/", + `
` + ); + + for (let value of subtest.testValues) { + testcase.update(document, value); + const ele = document.querySelector("form"); + const ret = LoginHelper.isInferredLoginForm(ele); + Assert.strictEqual( + ret, + subtest.expectation, + `${testcase.description}, isInferredLoginForm doesn't return correct result while setting the value to ${value}` + ); + } + } + }); + })(); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js b/toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js new file mode 100644 index 0000000000..e7d0785e8d --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js @@ -0,0 +1,222 @@ +/** + * Test for LoginHelper.isInferredUsernameField and LoginHelper.isInferredEmailField. + */ + +"use strict"; + +const attributeTestData = [ + { + testValues: [ + "", + "name", + "e-mail", + "user", + "user name", + "userid", + "lastname", + ], + expectation: "none", + }, + { + testValues: ["email", "EmaiL", "loginemail", "邮箱"], + expectation: "email", + }, + { + testValues: ["username", "usErNaMe", "my username"], + expectation: "username", + }, + { + testValues: ["usernameAndemail", "EMAILUSERNAME"], + expectation: "username,email", + }, +]; + +const classNameTestData = [ + { + testValues: [ + "inputTxt form-control", + "user-input form-name", + "text name mail", + ], + expectation: "none", + }, + { + testValues: ["input email", "signin-email form", "input form valid-Email"], + expectation: "email", + }, + { + testValues: [ + "input username", + "signup-username form", + "input form my_username", + ], + expectation: "username", + }, + { + testValues: ["input text form username email"], + expectation: "username,email", + }, +]; + +const labelTestData = [ + { + testValues: [ + "First Name", + "Last Name", + "Company Name", + "Password", + "User Name", + ], + expectation: "none", + }, + { + testValues: ["Email:", "Email Address*"], + expectation: "email", + }, + { + testValues: ["Username:", "choose a username"], + expectation: "username", + }, + { + testValues: ["Username/Email", "username or email"], + expectation: "username,email", + }, +]; + +const TESTCASES = [ + { + description: "Test input type", + update: (doc, v) => { + doc.querySelector("input").setAttribute("type", v); + }, + subtests: [ + { + testValues: ["text", "url", "number", "username"], + expectation: "none", + }, + { + testValues: ["email"], + expectation: "email", + }, + ], + }, + { + description: "Test autocomplete field", + update: (doc, v) => { + doc.querySelector("input").setAttribute("autocomplete", v); + }, + subtests: [ + { + testValues: [ + "off", + "on", + "name", + "new-password", + "current-password", + "tel", + "tel-national", + "url", + ], + expectation: "none", + }, + { + testValues: ["email"], + expectation: "email", + }, + { + testValues: ["username"], + expectation: "username", + }, + ], + }, + { + description: "Test id attribute", + update: (doc, v) => { + doc.querySelector("input").setAttribute("id", v); + }, + subtests: attributeTestData, + }, + { + description: "Test name attribute", + update: (doc, v) => { + doc.querySelector("input").setAttribute("name", v); + }, + subtests: attributeTestData, + }, + { + description: "Test class attribute", + update: (doc, v) => { + doc.querySelector("input").setAttribute("class", v); + }, + subtests: [...attributeTestData, ...classNameTestData], + }, + { + description: "Test placeholder attribute", + update: (doc, v) => { + doc.querySelector("input").setAttribute("placeholder", v); + }, + subtests: attributeTestData, + }, + { + description: "Test the first label", + update: (doc, v) => { + doc.getElementById("l1").textContent = v; + }, + subtests: labelTestData, + }, + { + description: "Test the second label", + update: (doc, v) => { + doc.getElementById("l2").textContent = v; + }, + subtests: labelTestData, + + // The username detection heuristic only examine the first label associated + // with the input, so no matter what the data is for this label, it doesn't + // affect the result. + // We can update this testcase once we decide to support multiple labels. + supported: false, + }, +]; + +for (let testcase of TESTCASES) { + info("Sanity checking the testcase: " + testcase.description); + + (function () { + add_task(async function () { + info("Starting testcase: " + testcase.description); + + for (let subtest of testcase.subtests) { + let document = MockDocument.createTestDocument( + "http://localhost:8080/test/", + ` + + ` + ); + + for (let value of subtest.testValues) { + testcase.update(document, value); + let ele = document.querySelector("input"); + + let ret = LoginHelper.isInferredUsernameField(ele); + Assert.strictEqual( + ret, + testcase.supported !== false + ? subtest.expectation.includes("username") + : false, + `${testcase.description}, isInferredUsernameField doesn't return correct result while setting the value to ${value}` + ); + + ret = LoginHelper.isInferredEmailField(ele); + Assert.strictEqual( + ret, + testcase.supported !== false + ? subtest.expectation.includes("email") + : false, + `${testcase.description}, isInferredEmailField doesn't return correct result while setting the value to ${value}` + ); + } + } + }); + })(); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js b/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js new file mode 100644 index 0000000000..02547609ec --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js @@ -0,0 +1,177 @@ +/** + * Test LoginHelper.isOriginMatching + */ + +"use strict"; + +add_task(function test_isOriginMatching() { + let testcases = [ + // Index 0 holds the expected return value followed by arguments to isOriginMatching. + [true, "http://example.com", "http://example.com"], + [true, "http://example.com:8080", "http://example.com:8080"], + [true, "https://example.com", "https://example.com"], + [true, "https://example.com:8443", "https://example.com:8443"], + + // The formActionOrigin can be "javascript:" + [true, "javascript:", "javascript:"], + [false, "javascript:", "http://example.com"], + [false, "http://example.com", "javascript:"], + + // HTTP Auth. logins have a null formActionOrigin + [true, null, null], + [false, null, "http://example.com"], + [false, "http://example.com", null], + + [false, "http://example.com", "http://mozilla.org"], + [false, "http://example.com", "http://example.com:8080"], + [false, "https://example.com", "http://example.com"], + [false, "https://example.com", "https://mozilla.org"], + [false, "http://example.com", "http://sub.example.com"], + [false, "https://example.com", "https://sub.example.com"], + [false, "http://example.com", "https://example.com:8443"], + [false, "http://example.com:8080", "http://example.com:8081"], + [false, "http://example.com", ""], + [false, "", "http://example.com"], + [ + true, + "http://example.com", + "https://example.com", + { schemeUpgrades: true }, + ], + [ + true, + "https://example.com", + "https://example.com", + { schemeUpgrades: true }, + ], + [ + true, + "http://example.com:8080", + "http://example.com:8080", + { schemeUpgrades: true }, + ], + [ + true, + "https://example.com:8443", + "https://example.com:8443", + { schemeUpgrades: true }, + ], + [ + false, + "https://example.com", + "http://example.com", + { schemeUpgrades: true }, + ], // downgrade + [ + false, + "http://example.com:8080", + "https://example.com", + { schemeUpgrades: true }, + ], // port mismatch + [ + false, + "http://example.com", + "https://example.com:8443", + { schemeUpgrades: true }, + ], // port mismatch + [ + false, + "http://sub.example.com", + "http://example.com", + { schemeUpgrades: true }, + ], + [ + true, + "http://sub.example.com", + "http://example.com", + { acceptDifferentSubdomains: true }, + ], + [ + true, + "http://sub.sub.example.com", + "http://example.com", + { acceptDifferentSubdomains: true }, + ], + [ + true, + "http://example.com", + "http://sub.example.com", + { acceptDifferentSubdomains: true }, + ], + [ + true, + "http://example.com", + "http://sub.sub.example.com", + { acceptDifferentSubdomains: true }, + ], + [ + false, + "https://sub.example.com", + "http://example.com", + { acceptDifferentSubdomains: true, schemeUpgrades: true }, + ], + [ + true, + "http://sub.example.com", + "https://example.com", + { acceptDifferentSubdomains: true, schemeUpgrades: true }, + ], + [ + true, + "http://sub.example.com", + "http://example.com:8081", + { acceptDifferentSubdomains: true }, + ], + [ + false, + "http://sub.example.com", + "http://sub.example.mozilla.com", + { acceptDifferentSubdomains: true }, + ], + // signon.includeOtherSubdomainsInLookup allows acceptDifferentSubdomains to be false + [ + false, + "http://sub.example.com", + "http://example.com", + { acceptDifferentSubdomains: false }, + ], + [ + false, + "http://sub.sub.example.com", + "http://example.com", + { acceptDifferentSubdomains: false }, + ], + [ + false, + "http://sub.example.com", + "http://example.com:8081", + { acceptDifferentSubdomains: false }, + ], + [ + false, + "http://sub.example.com", + "http://sub.example.mozilla.com", + { acceptDifferentSubdomains: false }, + ], + + // HTTP Auth. logins have a null formActionOrigin + [ + false, + null, + "http://example.com", + { + acceptDifferentSubdomains: false, + acceptWildcardMatch: true, + schemeUpgrades: true, + }, + ], + ]; + for (let tc of testcases) { + let expected = tc.shift(); + Assert.strictEqual( + LoginHelper.isOriginMatching(...tc), + expected, + "Check " + JSON.stringify(tc) + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js b/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js new file mode 100644 index 0000000000..167c160da2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js @@ -0,0 +1,192 @@ +/** + * Test for LoginAutoComplete.isProbablyANewPasswordField. + */ + +"use strict"; + +const LoginAutoComplete = Cc[ + "@mozilla.org/login-manager/autocompletesearch;1" +].getService(Ci.nsILoginAutoCompleteSearch).wrappedJSObject; +// TODO: create a fake window for the test document to pass fathom.isVisible check. +// We should consider moving these tests to mochitest because many fathom +// signals rely on visibility, position, etc., of the test element (See Bug 1712699), +// which is not supported in xpcshell-test. +function makeDocumentVisibleToFathom(doc) { + let win = { + getComputedStyle() { + return { + overflow: "visible", + visibility: "visible", + }; + }, + }; + Object.defineProperty(doc, "defaultView", { + value: win, + }); + return doc; +} + +function labelledByDocument() { + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + `
+ + +
` + ); + let div = doc.querySelector("div"); + // Put the div contents inside shadow DOM. + div.attachShadow({ mode: "open" }).append(...div.children); + return doc; +} +const LABELLEDBY_SHADOW_TESTCASE = labelledByDocument(); + +const TESTCASES = [ + // Note there is no test case for `` + // since isProbablyANewPasswordField explicitly does not run in that case. + { + description: "Basic login form", + document: ` +

Sign in

+
+ + + +
+ `, + expectedResult: [false], + }, + { + description: "Basic registration form", + document: ` +

Create account

+
+ + + +
+ `, + expectedResult: [true], + }, + { + // TODO: Add to "confirm-passowrd" password field so fathom can recognize it + // as a new password field. Currently, the fathom rules don't really work well in xpcshell-test + // because signals rely on visibility, position doesn't work. If we move this test to mochitest, we should + // be able to remove the interim solution (See Bug 1712699). + description: "Basic password change form", + document: ` +

Change password

+
+ + + + +
+ `, + expectedResult: [false, true, true], + }, + { + description: "Basic login 'form' without a form element", + document: ` +

Sign in

+ + + + `, + expectedResult: [false], + }, + { + description: "Basic registration 'form' without a form element", + document: ` +

Create account

+ + + + `, + expectedResult: [true], + }, + { + description: "Basic password change 'form' without a form element", + document: ` +

Change password

+ + + + + `, + expectedResult: [false, true, true], + }, + { + description: "Password field with aria-labelledby inside shadow DOM", + document: LABELLEDBY_SHADOW_TESTCASE, + inputs: LABELLEDBY_SHADOW_TESTCASE.querySelector( + "div" + ).shadowRoot.querySelectorAll("input[type='password']"), + expectedResult: [false], + }, +]; + +add_task(async function test_returns_false_when_pref_disabled() { + const threshold = Services.prefs.getStringPref( + NEW_PASSWORD_HEURISTIC_ENABLED_PREF + ); + + info("Temporarily disabling new-password heuristic pref"); + Services.prefs.setStringPref(NEW_PASSWORD_HEURISTIC_ENABLED_PREF, "-1"); + + // Use registration form test case, where we know it should return true if enabled + const testcase = TESTCASES[1]; + info("Starting testcase: " + testcase.description); + const document = Document.isInstance(testcase.document) + ? testcase.document + : MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + for (let [i, input] of testcase.inputs || + document.querySelectorAll(`input[type="password"]`).entries()) { + const result = LoginAutoComplete.isProbablyANewPasswordField(input); + Assert.strictEqual( + result, + false, + `When the pref is set to disable, the result is always false, e.g. for the testcase, ${testcase.description} ${i}` + ); + } + + info("Re-enabling new-password heuristic pref"); + Services.prefs.setStringPref(NEW_PASSWORD_HEURISTIC_ENABLED_PREF, threshold); +}); + +for (let testcase of TESTCASES) { + info("Sanity checking the testcase: " + testcase.description); + + (function () { + add_task(async function () { + info("Starting testcase: " + testcase.description); + let document = Document.isInstance(testcase.document) + ? testcase.document + : MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + document = makeDocumentVisibleToFathom(document); + + const results = []; + for (let input of testcase.inputs || + document.querySelectorAll(`input[type="password"]`)) { + const result = LoginAutoComplete.isProbablyANewPasswordField(input); + results.push(result); + } + + for (let i = 0; i < testcase.expectedResult.length; i++) { + let expectedResult = testcase.expectedResult[i]; + Assert.strictEqual( + results[i], + expectedResult, + `In the test case, ${testcase.description}, check if password field #${i} is a new password field.` + ); + } + }); + })(); +} diff --git a/toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js b/toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js new file mode 100644 index 0000000000..57238964a7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js @@ -0,0 +1,160 @@ +/** + * Test for LoginHelper.isUsernameFieldType + */ + +"use strict"; + +const autocompleteTypes = { + "": true, + on: true, + off: true, + name: false, + "unrecognized-type": true, + "given-name": false, + "additional-name": false, + "family-name": false, + nickname: false, + username: true, + "new-password": false, + "current-password": false, + "organization-title": false, + organization: false, + "street-address": false, + "address-line1": false, + "address-line2": false, + "address-line3": false, + "address-level4": false, + "address-level3": false, + "address-level2": false, + "address-level1": false, + country: false, + "country-name": false, + "postal-code": false, + "cc-name": false, + "cc-given-name": false, + "cc-additional-name": false, + "cc-family-name": false, + "cc-number": false, + "cc-exp": false, + "cc-exp-month": false, + "cc-exp-year": false, + "cc-csc": false, + "cc-type": false, + "transaction-currency": false, + "transaction-amount": false, + language: false, + bday: false, + "bday-day": false, + "bday-month": false, + "bday-year": false, + sex: false, + url: false, + photo: false, + tel: true, + "tel-country-code": false, + "tel-national": true, + "tel-area-code": false, + "tel-local": false, + "tel-local-prefix": false, + "tel-local-suffix": false, + "tel-extension": false, + email: true, + impp: false, +}; + +const TESTCASES = [ + { + description: "type=text", + document: ``, + expected: true, + }, + { + description: "type=email, no autocomplete attribute", + document: ``, + expected: true, + }, + { + description: "type=url, no autocomplete attribute", + document: ``, + expected: true, + }, + { + description: "type=tel, no autocomplete attribute", + document: ``, + expected: true, + }, + { + description: "type=number, no autocomplete attribute", + document: ``, + expected: true, + }, + { + description: "type=search, no autocomplete attribute", + document: ``, + expected: true, + }, + { + description: "type=range, no autocomplete attribute", + document: ``, + expected: false, + }, + { + description: "type=date, no autocomplete attribute", + document: ``, + expected: false, + }, + { + description: "type=month, no autocomplete attribute", + document: ``, + expected: false, + }, + { + description: "type=week, no autocomplete attribute", + document: ``, + expected: false, + }, + { + description: "type=time, no autocomplete attribute", + document: ``, + expected: false, + }, + { + description: "type=datetime, no autocomplete attribute", + document: ``, + expected: false, + }, + { + description: "type=datetime-local, no autocomplete attribute", + document: ``, + expected: false, + }, + { + description: "type=color, no autocomplete attribute", + document: ``, + expected: false, + }, +]; + +for (let [name, expected] of Object.entries(autocompleteTypes)) { + TESTCASES.push({ + description: `type=text autocomplete=${name}`, + document: ``, + expected, + }); +} + +TESTCASES.forEach(testcase => { + add_task(async function () { + info("Starting testcase: " + testcase.description); + let document = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let input = document.querySelector("input"); + Assert.equal( + LoginHelper.isUsernameFieldType(input), + testcase.expected, + testcase.description + ); + }); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js new file mode 100644 index 0000000000..ec6846ab9f --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js @@ -0,0 +1,126 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the legacy case of a login store containing entries that have an empty + * string in the formActionOrigin field. + * + * In normal conditions, for the purpose of login autocomplete, HTML forms are + * identified using both the prePath of the URI on which they are located, and + * the prePath of the URI where the data will be submitted. This is represented + * by the origin and formActionOrigin properties of the stored nsILoginInfo. + * + * When a new login for use in forms is saved (after the user replies to the + * password prompt), it is always stored with both the origin and the + * formActionOrigin (that will be equal to the origin when the form has no + * "action" attribute). + * + * When the same form is displayed again, the password is autocompleted. If + * there is another form on the same site that submits to a different site, it + * is considered a different form, so the password is not autocompleted, but a + * new password can be stored for the other form. + * + * However, the login database might contain data for an nsILoginInfo that has a + * valid origin, but an empty formActionOrigin. This means that the login + * applies to all forms on the site, regardless of where they submit data to. + * + * A site can have at most one such login, and in case it is present, then it is + * not possible to store separate logins for forms on the same site that submit + * data to different sites. + * + * The only way to have such condition is to be using logins that were initially + * saved by a very old version of the browser, or because of data manually added + * by an extension in an old version. + */ + +"use strict"; + +// Tests + +/** + * Adds a login with an empty formActionOrigin, then it verifies that no other + * form logins can be added for the same host. + */ +add_task(async function test_addLogin_wildcard() { + let loginInfo = TestData.formLogin({ + origin: "http://any.example.com", + formActionOrigin: "", + }); + await Services.logins.addLoginAsync(loginInfo); + + // Normal form logins cannot be added anymore. + loginInfo = TestData.formLogin({ origin: "http://any.example.com" }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /already exists/ + ); + + // Authentication logins can still be added. + loginInfo = TestData.authLogin({ origin: "http://any.example.com" }); + await Services.logins.addLoginAsync(loginInfo); + + // Form logins can be added for other hosts. + loginInfo = TestData.formLogin({ origin: "http://other.example.com" }); + await Services.logins.addLoginAsync(loginInfo); +}); + +/** + * Verifies that findLogins, searchLogins, and countLogins include all logins + * that have an empty formActionOrigin in the store, even when a formActionOrigin is + * specified. + */ +add_task(function test_search_all_wildcard() { + // Search a given formActionOrigin on any host. + let matchData = newPropertyBag({ + formActionOrigin: "http://www.example.com", + }); + Assert.equal(Services.logins.searchLogins(matchData).length, 2); + + Assert.equal( + Services.logins.findLogins("", "http://www.example.com", null).length, + 2 + ); + + Assert.equal( + Services.logins.countLogins("", "http://www.example.com", null), + 2 + ); + + // Restrict the search to one host. + matchData.setProperty("origin", "http://any.example.com"); + Assert.equal(Services.logins.searchLogins(matchData).length, 1); + + Assert.equal( + Services.logins.findLogins( + "http://any.example.com", + "http://www.example.com", + null + ).length, + 1 + ); + + Assert.equal( + Services.logins.countLogins( + "http://any.example.com", + "http://www.example.com", + null + ), + 1 + ); +}); + +/** + * Verifies that specifying an empty string for formActionOrigin in searchLogins + * includes only logins that have an empty formActionOrigin in the store. + */ +add_task(function test_searchLogins_wildcard() { + let logins = Services.logins.searchLogins( + newPropertyBag({ formActionOrigin: "" }) + ); + + let loginInfo = TestData.formLogin({ + origin: "http://any.example.com", + formActionOrigin: "", + }); + LoginTestUtils.assertLoginListsEqual(logins, [loginInfo]); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js b/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js new file mode 100644 index 0000000000..7fb6c9807d --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_legacy_validation.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the legacy validation made when storing nsILoginInfo or disabled hosts. + * + * These rules exist because of limitations of the "signons.txt" storage file, + * that is not used anymore. They are still enforced by the Login Manager + * service, despite these values can now be safely stored in the back-end. + */ + +"use strict"; + +// Tests + +/** + * Tests legacy validation with addLogin. + */ +add_task(async function test_addLogin_invalid_characters_legacy() { + // Test newlines and carriage returns in properties that contain URLs. + for (let testValue of [ + "http://newline\n.example.com", + "http://carriagereturn.example.com\r", + ]) { + let loginInfo = TestData.formLogin({ origin: testValue }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /login values can't contain newlines/ + ); + + loginInfo = TestData.formLogin({ formActionOrigin: testValue }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /login values can't contain newlines/ + ); + + loginInfo = TestData.authLogin({ httpRealm: testValue }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /login values can't contain newlines/ + ); + } + + // Test newlines and carriage returns in form field names. + for (let testValue of ["newline_field\n", "carriagereturn\r_field"]) { + let loginInfo = TestData.formLogin({ usernameField: testValue }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /login values can't contain newlines/ + ); + + loginInfo = TestData.formLogin({ passwordField: testValue }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /login values can't contain newlines/ + ); + } + + // Test a single dot as the value of usernameField and formActionOrigin. + let loginInfo = TestData.formLogin({ usernameField: "." }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /login values can't be periods/ + ); + + loginInfo = TestData.formLogin({ formActionOrigin: "." }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /login values can't be periods/ + ); + + // Test the sequence " (" inside the value of the "origin" property. + loginInfo = TestData.formLogin({ origin: "http://parens (.example.com" }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /bad parens in origin/ + ); +}); + +/** + * Tests legacy validation with setLoginSavingEnabled. + */ +add_task(function test_setLoginSavingEnabled_invalid_characters_legacy() { + for (let origin of [ + "http://newline\n.example.com", + "http://carriagereturn.example.com\r", + ".", + ]) { + Assert.throws( + () => Services.logins.setLoginSavingEnabled(origin, false), + /Invalid origin/ + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js b/toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js new file mode 100644 index 0000000000..8511e4f34a --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js @@ -0,0 +1,735 @@ +const { LoginAutoCompleteResult } = ChromeUtils.importESModule( + "resource://gre/modules/LoginAutoComplete.sys.mjs" +); +let nsLoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +const PREF_SCHEME_UPGRADES = "signon.schemeUpgrades"; + +let matchingLogins = []; +matchingLogins.push( + new nsLoginInfo( + "https://mochi.test:8888", + "https://autocomplete:8888", + null, + "", + "emptypass1", + "uname", + "pword" + ) +); + +matchingLogins.push( + new nsLoginInfo( + "https://mochi.test:8888", + "https://autocomplete:8888", + null, + "tempuser1", + "temppass1", + "uname", + "pword" + ) +); + +matchingLogins.push( + new nsLoginInfo( + "https://mochi.test:8888", + "https://autocomplete:8888", + null, + "testuser2", + "testpass2", + "uname", + "pword" + ) +); +// subdomain: +matchingLogins.push( + new nsLoginInfo( + "https://sub.mochi.test:8888", + "https://autocomplete:8888", + null, + "testuser3", + "testpass3", + "uname", + "pword" + ) +); + +// to test signon.schemeUpgrades +matchingLogins.push( + new nsLoginInfo( + "http://mochi.test:8888", + "http://autocomplete:8888", + null, + "zzzuser4", + "zzzpass4", + "uname", + "pword" + ) +); + +// HTTP auth +matchingLogins.push( + new nsLoginInfo( + "https://mochi.test:8888", + null, + "My HTTP auth realm", + "httpuser", + "httppass" + ) +); + +add_setup(async () => { + // Get a profile so we have storage access and insert the logins to get unique GUIDs. + do_get_profile(); + matchingLogins = await Services.logins.addLogins(matchingLogins); +}); + +add_task(async function test_all_patterns() { + let meta = matchingLogins[0].QueryInterface(Ci.nsILoginMetaInfo); + let dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }); + let time = dateAndTimeFormatter.format(new Date(meta.timePasswordChanged)); + const LABEL_NO_USERNAME = "No username (" + time + ")"; + const EXACT_ORIGIN_MATCH_COMMENT = "From this website"; + + let expectedResults = [ + { + isSecure: true, + hasBeenTypePassword: false, + matchingLogins, + items: [ + { + value: "", + label: LABEL_NO_USERNAME, + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "tempuser1", + label: "tempuser1", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "testuser2", + label: "testuser2", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "zzzuser4", + label: "zzzuser4", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "httpuser", + label: "httpuser", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888 (My HTTP auth realm)" }, + }, + { + value: "testuser3", + label: "testuser3", + style: "loginWithOrigin", + comment: { comment: "sub.mochi.test:8888" }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { + formHostname: "mochi.test", + telemetryEventData: { searchStartTimeMS: 0 }, + }, + }, + ], + }, + { + isSecure: false, + hasBeenTypePassword: false, + matchingLogins: [], + items: [ + { + value: "", + label: + "This connection is not secure. Logins entered here could be compromised. Learn More", + style: "insecureWarning", + comment: "", + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { + formHostname: "mochi.test", + telemetryEventData: { searchStartTimeMS: 1 }, + }, + }, + ], + }, + { + isSecure: false, + hasBeenTypePassword: false, + matchingLogins, + items: [ + { + value: "", + label: + "This connection is not secure. Logins entered here could be compromised. Learn More", + style: "insecureWarning", + comment: "", + }, + { + value: "", + label: LABEL_NO_USERNAME, + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "tempuser1", + label: "tempuser1", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "testuser2", + label: "testuser2", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "zzzuser4", + label: "zzzuser4", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "httpuser", + label: "httpuser", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888 (My HTTP auth realm)" }, + }, + { + value: "testuser3", + label: "testuser3", + style: "loginWithOrigin", + comment: { comment: "sub.mochi.test:8888" }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + isSecure: true, + hasBeenTypePassword: true, + matchingLogins, + items: [ + { + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "temppass1", + label: "tempuser1", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "testpass2", + label: "testuser2", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "zzzpass4", + label: "zzzuser4", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "httppass", + label: "httpuser", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888 (My HTTP auth realm)" }, + }, + { + value: "testpass3", + label: "testuser3", + style: "loginWithOrigin", + comment: { comment: "sub.mochi.test:8888" }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + isSecure: false, + hasBeenTypePassword: true, + matchingLogins, + items: [ + { + value: "", + label: + "This connection is not secure. Logins entered here could be compromised. Learn More", + style: "insecureWarning", + comment: "", + }, + { + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "temppass1", + label: "tempuser1", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "testpass2", + label: "testuser2", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "zzzpass4", + label: "zzzuser4", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "httppass", + label: "httpuser", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888 (My HTTP auth realm)" }, + }, + { + value: "testpass3", + label: "testuser3", + style: "loginWithOrigin", + comment: { comment: "sub.mochi.test:8888" }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + isSecure: true, + hasBeenTypePassword: false, + matchingLogins, + items: [ + { + value: "", + label: LABEL_NO_USERNAME, + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "tempuser1", + label: "tempuser1", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "testuser2", + label: "testuser2", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "zzzuser4", + label: "zzzuser4", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "httpuser", + label: "httpuser", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888 (My HTTP auth realm)" }, + }, + { + value: "testuser3", + label: "testuser3", + style: "loginWithOrigin", + comment: { comment: "sub.mochi.test:8888" }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + isSecure: false, + hasBeenTypePassword: false, + matchingLogins: [], + searchString: "foo", + items: [ + { + value: "", + label: + "This connection is not secure. Logins entered here could be compromised. Learn More", + style: "insecureWarning", + comment: "", + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + isSecure: true, + hasBeenTypePassword: true, + matchingLogins: [], + items: [ + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + isSecure: true, + hasBeenTypePassword: true, + matchingLogins: [], + searchString: "foo", + items: [], + }, + { + generatedPassword: "9ljgfd4shyktb45", + isSecure: true, + hasBeenTypePassword: true, + matchingLogins: [], + items: [ + { + value: "9ljgfd4shyktb45", + label: "Use a Securely Generated Password", + style: "generatedPassword", + comment: { + generatedPassword: "9ljgfd4shyktb45", + willAutoSaveGeneratedPassword: false, + }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + description: + "willAutoSaveGeneratedPassword should propagate to the comment", + generatedPassword: "9ljgfd4shyktb45", + willAutoSaveGeneratedPassword: true, + isSecure: true, + hasBeenTypePassword: true, + matchingLogins: [], + items: [ + { + value: "9ljgfd4shyktb45", + label: "Use a Securely Generated Password", + style: "generatedPassword", + comment: { + generatedPassword: "9ljgfd4shyktb45", + willAutoSaveGeneratedPassword: true, + }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + description: + "If a generated password is passed then show it even if there is a search string. This handles when forcing the generation option from the context menu of a non-empty field", + generatedPassword: "9ljgfd4shyktb45", + isSecure: true, + hasBeenTypePassword: true, + matchingLogins: [], + searchString: "9ljgfd4shyktb45", + items: [ + { + value: "9ljgfd4shyktb45", + label: "Use a Securely Generated Password", + style: "generatedPassword", + comment: { + generatedPassword: "9ljgfd4shyktb45", + willAutoSaveGeneratedPassword: false, + }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + description: "secure username field on sub.mochi.test", + formOrigin: "https://sub.mochi.test:8888", + isSecure: true, + hasBeenTypePassword: false, + matchingLogins, + items: [ + { + value: "testuser3", + label: "testuser3", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "", + label: LABEL_NO_USERNAME, + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "tempuser1", + label: "tempuser1", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "testuser2", + label: "testuser2", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "zzzuser4", + label: "zzzuser4", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "httpuser", + label: "httpuser", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888 (My HTTP auth realm)" }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + description: "secure password field on sub.mochi.test", + formOrigin: "https://sub.mochi.test:8888", + isSecure: true, + hasBeenTypePassword: true, + matchingLogins, + items: [ + { + value: "testpass3", + label: "testuser3", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "emptypass1", + label: LABEL_NO_USERNAME, + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "temppass1", + label: "tempuser1", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "testpass2", + label: "testuser2", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "zzzpass4", + label: "zzzuser4", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "httppass", + label: "httpuser", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888 (My HTTP auth realm)" }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + { + description: "schemeUpgrades: false", + formOrigin: "https://mochi.test:8888", + schemeUpgrades: false, + isSecure: true, + hasBeenTypePassword: false, + matchingLogins, + items: [ + { + value: "", + label: LABEL_NO_USERNAME, + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "tempuser1", + label: "tempuser1", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "testuser2", + label: "testuser2", + style: "loginWithOrigin", + comment: { comment: EXACT_ORIGIN_MATCH_COMMENT }, + }, + { + value: "zzzuser4", + label: "zzzuser4", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888" }, + }, + { + value: "httpuser", + label: "httpuser", + style: "loginWithOrigin", + comment: { comment: "mochi.test:8888 (My HTTP auth realm)" }, + }, + { + value: "testuser3", + label: "testuser3", + style: "loginWithOrigin", + comment: { comment: "sub.mochi.test:8888" }, + }, + { + value: "", + label: "View Saved Logins", + style: "loginsFooter", + comment: { formHostname: "mochi.test" }, + }, + ], + }, + ]; + + LoginHelper.createLogger("LoginAutoCompleteResult"); + Services.prefs.setBoolPref("signon.showAutoCompleteFooter", true); + + expectedResults.forEach((pattern, testIndex) => { + info(`expectedResults[${testIndex}]`); + info(JSON.stringify(pattern, null, 2)); + Services.prefs.setBoolPref( + PREF_SCHEME_UPGRADES, + "schemeUpgrades" in pattern ? pattern.schemeUpgrades : true + ); + let actual = new LoginAutoCompleteResult( + pattern.searchString || "", + pattern.matchingLogins, + [], + pattern.formOrigin || "https://mochi.test:8888", + { + hostname: "mochi.test", + generatedPassword: pattern.generatedPassword, + willAutoSaveGeneratedPassword: !!pattern.willAutoSaveGeneratedPassword, + isSecure: pattern.isSecure, + hasBeenTypePassword: pattern.hasBeenTypePassword, + telemetryEventData: { searchStartTimeMS: testIndex }, + } + ); + equal( + actual.matchCount, + pattern.items.length, + `${testIndex}: Check matching row count` + ); + pattern.items.forEach((item, index) => { + equal( + actual.getValueAt(index), + item.value, + `${testIndex}: Value ${index}` + ); + equal( + actual.getLabelAt(index), + item.label, + `${testIndex}: Label ${index}` + ); + equal( + actual.getStyleAt(index), + item.style, + `${testIndex}: Style ${index}` + ); + let actualComment = actual.getCommentAt(index); + if (typeof item.comment == "object") { + let parsedComment = JSON.parse(actualComment); + for (let [key, val] of Object.entries(item.comment)) { + Assert.deepEqual( + parsedComment[key], + val, + `${testIndex}: Comment.${key} ${index}` + ); + } + } else { + equal(actualComment, item.comment, `${testIndex}: Comment ${index}`); + } + }); + + if (pattern.items.length) { + Assert.throws( + () => actual.getValueAt(pattern.items.length), + /Index out of range\./ + ); + + Assert.throws( + () => actual.getLabelAt(pattern.items.length), + /Index out of range\./ + ); + + Assert.throws( + () => actual.removeValueAt(pattern.items.length), + /Index out of range\./ + ); + } + }); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_loginsBackup.js b/toolkit/components/passwordmgr/test/unit/test_loginsBackup.js new file mode 100644 index 0000000000..775f6f5486 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_loginsBackup.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if logins-backup.json is used correctly in the event that logins.json is missing or corrupt. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + LoginStore: "resource://gre/modules/LoginStore.sys.mjs", +}); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const rawLogin1 = { + id: 1, + hostname: "http://www.example.com", + httpRealm: null, + formSubmitURL: "http://www.example.com", + usernameField: "field_" + String.fromCharCode(533, 537, 7570, 345), + passwordField: "field_" + String.fromCharCode(421, 259, 349, 537), + encryptedUsername: "(test)", + encryptedPassword: "(test)", + guid: "(test)", + encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR, + timeCreated: Date.now(), + timeLastUsed: Date.now(), + timePasswordChanged: Date.now(), + timesUsed: 1, +}; + +const rawLogin2 = { + id: 2, + hostname: "http://www.example2.com", + httpRealm: null, + formSubmitURL: "http://www.example2.com", + usernameField: "field_2" + String.fromCharCode(533, 537, 7570, 345), + passwordField: "field_2" + String.fromCharCode(421, 259, 349, 537), + encryptedUsername: "(test2)", + encryptedPassword: "(test2)", + guid: "(test2)", + encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR, + timeCreated: Date.now(), + timeLastUsed: Date.now(), + timePasswordChanged: Date.now(), + timesUsed: 1, +}; + +// Enable the collection (during test) for all products so even products +// that don't collect the data will be able to run the test without failure. +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +/** + * Tests that logins-backup.json can be used by JSONFile.load() when logins.json is missing or cannot be read. + */ +add_task(async function test_logins_store_missing_or_corrupt_with_backup() { + const loginsStorePath = PathUtils.join(PathUtils.profileDir, "logins.json"); + const loginsStoreBackup = PathUtils.join( + PathUtils.profileDir, + "logins-backup.json" + ); + + // Get store.data ready. + let store = new LoginStore(loginsStorePath, loginsStoreBackup); + await store.load(); + + // Files should not exist at start up. + Assert.ok(!(await IOUtils.exists(store.path)), "No store file at start up"); + Assert.ok( + !(await IOUtils.exists(store._options.backupTo)), + "No backup file at start up" + ); + + // Add logins to create logins.json and logins-backup.json. + store.data.logins.push(rawLogin1); + await store._save(); + Assert.ok(await IOUtils.exists(store.path)); + + store.data.logins.push(rawLogin2); + await store._save(); + Assert.ok(await IOUtils.exists(store._options.backupTo)); + + // Remove logins.json and see if logins-backup.json will be used. + await IOUtils.remove(store.path); + store.data.logins = []; + store.dataReady = false; + Assert.ok(!(await IOUtils.exists(store.path))); + Assert.ok(await IOUtils.exists(store._options.backupTo)); + + // Clear any telemetry events recorded in the jsonfile category previously. + Services.telemetry.clearEvents(); + + await store.load(); + + Assert.ok( + await IOUtils.exists(store.path), + "logins.json is restored as expected after it went missing" + ); + + Assert.ok(await IOUtils.exists(store._options.backupTo)); + Assert.equal( + store.data.logins.length, + 1, + "Logins backup was used successfully when logins.json was missing" + ); + + TelemetryTestUtils.assertEvents( + [ + ["jsonfile", "load", "logins"], + ["jsonfile", "load", "logins", "used_backup"], + ], + {}, + { clear: true } + ); + info( + "Telemetry was recorded accurately when logins-backup.json is used when logins.json was missing" + ); + + // Corrupt the logins.json file. + let string = '{"logins":[{"hostname":"http://www.example.com","id":1,'; + await IOUtils.writeUTF8(store.path, string, { + tmpPath: `${store.path}.tmp`, + }); + + // Clear events recorded in the jsonfile category previously. + Services.telemetry.clearEvents(); + + // Try to load the corrupt file. + store.data.logins = []; + store.dataReady = false; + await store.load(); + + Assert.ok( + await IOUtils.exists(`${store.path}.corrupt`), + "logins.json.corrupt created" + ); + Assert.ok( + await IOUtils.exists(store.path), + "logins.json is restored after it was corrupted" + ); + + // Data should be loaded from logins-backup.json. + Assert.ok(await IOUtils.exists(store._options.backupTo)); + Assert.equal( + store.data.logins.length, + 1, + "Logins backup was used successfully when logins.json was corrupt" + ); + + TelemetryTestUtils.assertEvents( + [ + ["jsonfile", "load", "logins", ""], + ["jsonfile", "load", "logins", "invalid_json"], + ["jsonfile", "load", "logins", "used_backup"], + ], + {}, + { clear: true } + ); + info( + "Telemetry was recorded accurately when logins-backup.json is used when logins.json was corrupt" + ); + + // Clean up before we start the second part of the test. + await IOUtils.remove(`${store.path}.corrupt`); + + // Test that the backup file can be used by JSONFile.ensureDataReady() correctly when logins.json is missing. + // Remove logins.json + await IOUtils.remove(store.path); + store.data.logins = []; + store.dataReady = false; + Assert.ok(!(await IOUtils.exists(store.path))); + Assert.ok(await IOUtils.exists(store._options.backupTo)); + + store.ensureDataReady(); + + // Important to check here if logins.json is restored as expected + // after it went missing. + await IOUtils.exists(store.path); + + Assert.ok(await IOUtils.exists(store._options.backupTo)); + await TestUtils.waitForCondition(() => { + return store.data.logins.length == 1; + }); + + // Test that the backup file is used by JSONFile.ensureDataReady() when logins.json is corrupt. + // Corrupt the logins.json file. + await IOUtils.writeUTF8(store.path, string, { + tmpPath: `${store.path}.tmp`, + }); + + // Try to load the corrupt file. + store.data.logins = []; + store.dataReady = false; + store.ensureDataReady(); + + Assert.ok( + await IOUtils.exists(`${store.path}.corrupt`), + "logins.json.corrupt created" + ); + Assert.ok( + await IOUtils.exists(store.path), + "logins.json is restored after it was corrupted" + ); + + // Data should be loaded from logins-backup.json. + Assert.ok(await IOUtils.exists(store._options.backupTo)); + await TestUtils.waitForCondition(() => { + return store.data.logins.length == 1; + }); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_change.js b/toolkit/components/passwordmgr/test/unit/test_logins_change.js new file mode 100644 index 0000000000..ee3fee04d0 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_logins_change.js @@ -0,0 +1,628 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests methods that add, remove, and modify logins. + */ + +"use strict"; + +// Globals + +const MAX_DATE_MS = 8640000000000000; + +/** + * Verifies that the specified login is considered invalid by addLogin and by + * modifyLogin with both nsILoginInfo and nsIPropertyBag arguments. + * + * This test requires that the login store is empty. + * + * @param aLoginInfo + * nsILoginInfo corresponding to an invalid login. + * @param aExpectedError + * This argument is passed to the "Assert.throws" test to determine which + * error is expected from the modification functions. + */ +async function checkLoginInvalid(aLoginInfo, aExpectedError) { + // Try to add the new login, and verify that no data is stored. + await Assert.rejects( + Services.logins.addLoginAsync(aLoginInfo), + aExpectedError + ); + LoginTestUtils.checkLogins([]); + + // Add a login for the modification tests. + let testLogin = TestData.formLogin({ origin: "http://modify.example.com" }); + await Services.logins.addLoginAsync(testLogin); + + // Try to modify the existing login using nsILoginInfo and nsIPropertyBag. + Assert.throws( + () => Services.logins.modifyLogin(testLogin, aLoginInfo), + aExpectedError + ); + Assert.throws( + () => + Services.logins.modifyLogin( + testLogin, + newPropertyBag({ + origin: aLoginInfo.origin, + formActionOrigin: aLoginInfo.formActionOrigin, + httpRealm: aLoginInfo.httpRealm, + username: aLoginInfo.username, + password: aLoginInfo.password, + usernameField: aLoginInfo.usernameField, + passwordField: aLoginInfo.passwordField, + }) + ), + aExpectedError + ); + + // Verify that no data was stored by the previous calls. + LoginTestUtils.checkLogins([testLogin]); + Services.logins.removeLogin(testLogin); +} + +/** + * Verifies that two objects are not the same instance + * but have equal attributes. + * + * @param {Object} objectA + * An object to compare. + * + * @param {Object} objectB + * Another object to compare. + * + * @param {string[]} attributes + * Attributes to compare. + * + * @return true if all passed attributes are equal for both objects, false otherwise. + */ +function compareAttributes(objectA, objectB, attributes) { + // If it's the same object, we want to return false. + if (objectA == objectB) { + return false; + } + return attributes.every(attr => objectA[attr] == objectB[attr]); +} + +// Tests + +/** + * Tests that adding logins to the database works. + */ +add_task(async function test_addLogin_removeLogin() { + // Each login from the test data should be valid and added to the list. + await Services.logins.addLogins(TestData.loginList()); + LoginTestUtils.checkLogins(TestData.loginList()); + + // Trying to add each login again should result in an error. + for (let loginInfo of TestData.loginList()) { + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /This login already exists./ + ); + } + + // Removing each login should succeed. + for (let loginInfo of TestData.loginList()) { + Services.logins.removeLogin(loginInfo); + } + + LoginTestUtils.checkLogins([]); +}); + +add_task(async function add_login_works_with_empty_array() { + const result = await Services.logins.addLogins([]); + Assert.equal(result.length, 0, "no logins added"); +}); + +add_task(async function duplicated_logins_are_not_added() { + const login = TestData.formLogin({ + username: "user", + }); + await Services.logins.addLogins([login]); + const result = await Services.logins.addLogins([login]); + Assert.equal(result, 0, "no logins added"); + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function logins_containing_nul_in_username_are_not_added() { + const result = await Services.logins.addLogins([ + TestData.formLogin({ username: "user\0name" }), + ]); + Assert.equal(result, 0, "no logins added"); +}); + +add_task(async function logins_containing_nul_in_password_are_not_added() { + const result = await Services.logins.addLogins([ + TestData.formLogin({ password: "pass\0word" }), + ]); + Assert.equal(result, 0, "no logins added"); +}); + +add_task( + async function return_value_includes_plaintext_username_and_password() { + const login = TestData.formLogin({}); + const [result] = await Services.logins.addLogins([login]); + Assert.equal(result.username, login.username, "plaintext username is set"); + Assert.equal(result.password, login.password, "plaintext password is set"); + Services.logins.removeAllUserFacingLogins(); + } +); + +add_task(async function event_data_includes_plaintext_username_and_password() { + const login = TestData.formLogin({}); + const TestObserver = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + observe(subject, topic, data) { + Assert.ok(subject instanceof Ci.nsILoginInfo); + Assert.ok(subject instanceof Ci.nsILoginMetaInfo); + Assert.equal( + subject.username, + login.username, + "plaintext username is set" + ); + Assert.equal( + subject.password, + login.password, + "plaintext password is set" + ); + }, + }; + Services.obs.addObserver(TestObserver, "passwordmgr-storage-changed"); + await Services.logins.addLogins([login]); + Services.obs.removeObserver(TestObserver, "passwordmgr-storage-changed"); + Services.logins.removeAllUserFacingLogins(); +}); + +/** + * Tests invalid combinations of httpRealm and formActionOrigin. + * + * For an nsILoginInfo to be valid for storage, one of the two properties should + * be strictly equal to null, and the other must not be null or an empty string. + * + * The legacy case of an empty string in formActionOrigin and a null value in + * httpRealm is also supported for storage at the moment. + */ +add_task(async function test_invalid_httpRealm_formActionOrigin() { + // httpRealm === null, formActionOrigin === null + await checkLoginInvalid( + TestData.formLogin({ formActionOrigin: null }), + /without a httpRealm or formActionOrigin/ + ); + + // httpRealm === "", formActionOrigin === null + await checkLoginInvalid( + TestData.authLogin({ httpRealm: "" }), + /without a httpRealm or formActionOrigin/ + ); + + // httpRealm === null, formActionOrigin === "" + // TODO: This is not enforced for now. + // await checkLoginInvalid(TestData.formLogin({ formActionOrigin: "" }), + // /without a httpRealm or formActionOrigin/); + + // httpRealm === "", formActionOrigin === "" + let login = TestData.formLogin({ formActionOrigin: "" }); + login.httpRealm = ""; + await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/); + + // !!httpRealm, !!formActionOrigin + login = TestData.formLogin(); + login.httpRealm = "The HTTP Realm"; + await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/); + + // httpRealm === "", !!formActionOrigin + login = TestData.formLogin(); + login.httpRealm = ""; + await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/); + + // !!httpRealm, formActionOrigin === "" + login = TestData.authLogin(); + login.formActionOrigin = ""; + await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/); +}); + +/** + * Tests null or empty values in required login properties. + */ +add_task(async function test_missing_properties() { + await checkLoginInvalid( + TestData.formLogin({ origin: null }), + /null or empty origin/ + ); + + await checkLoginInvalid( + TestData.formLogin({ origin: "" }), + /null or empty origin/ + ); + + await checkLoginInvalid( + TestData.formLogin({ username: null }), + /null username/ + ); + + await checkLoginInvalid( + TestData.formLogin({ password: null }), + /null or empty password/ + ); + + await checkLoginInvalid( + TestData.formLogin({ password: "" }), + /null or empty password/ + ); +}); + +/** + * Tests invalid NUL characters in nsILoginInfo properties. + */ +add_task(async function test_invalid_characters() { + let loginList = [ + TestData.authLogin({ origin: "http://null\0X.example.com" }), + TestData.authLogin({ httpRealm: "realm\0" }), + TestData.formLogin({ formActionOrigin: "http://null\0X.example.com" }), + TestData.formLogin({ usernameField: "field\0_null" }), + TestData.formLogin({ usernameField: ".\0" }), // Special single dot case + TestData.formLogin({ passwordField: "field\0_null" }), + TestData.formLogin({ username: "user\0name" }), + TestData.formLogin({ password: "pass\0word" }), + ]; + for (let loginInfo of loginList) { + await checkLoginInvalid(loginInfo, /login values can't contain nulls/); + } +}); + +/** + * Tests removing a login that does not exists. + */ +add_task(function test_removeLogin_nonexisting() { + Assert.throws( + () => Services.logins.removeLogin(TestData.formLogin()), + /No matching logins/ + ); +}); + +/** + * Tests removing all logins at once. + */ +add_task(async function test_removeAllUserFacingLogins() { + await Services.logins.addLogins(TestData.loginList()); + + Services.logins.removeAllUserFacingLogins(); + LoginTestUtils.checkLogins([]); + + // The function should also work when there are no logins to delete. + Services.logins.removeAllUserFacingLogins(); +}); + +/** + * Tests the modifyLogin function with an nsILoginInfo argument. + */ +add_task(async function test_modifyLogin_nsILoginInfo() { + let loginInfo = TestData.formLogin(); + let updatedLoginInfo = TestData.formLogin({ + username: "new username", + password: "new password", + usernameField: "new_form_field_username", + passwordField: "new_form_field_password", + }); + let differentLoginInfo = TestData.authLogin(); + + // Trying to modify a login that does not exist should throw. + Assert.throws( + () => Services.logins.modifyLogin(loginInfo, updatedLoginInfo), + /No matching logins/ + ); + + // Add the first form login, then modify it to match the second. + await Services.logins.addLoginAsync(loginInfo); + Services.logins.modifyLogin(loginInfo, updatedLoginInfo); + + // The data should now match the second login. + LoginTestUtils.checkLogins([updatedLoginInfo]); + Assert.throws( + () => Services.logins.modifyLogin(loginInfo, updatedLoginInfo), + /No matching logins/ + ); + + // The login can be changed to have a different type and origin. + Services.logins.modifyLogin(updatedLoginInfo, differentLoginInfo); + LoginTestUtils.checkLogins([differentLoginInfo]); + + // It is now possible to add a login with the old type and origin. + await Services.logins.addLoginAsync(loginInfo); + LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]); + + // Modifying a login to match an existing one should not be possible. + Assert.throws( + () => Services.logins.modifyLogin(loginInfo, differentLoginInfo), + /already exists/ + ); + LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]); + + LoginTestUtils.clearData(); +}); + +/** + * Tests the modifyLogin function with an nsIPropertyBag argument. + */ +add_task(async function test_modifyLogin_nsIProperyBag() { + let loginInfo = TestData.formLogin(); + let updatedLoginInfo = TestData.formLogin({ + username: "new username", + password: "new password", + usernameField: "", + passwordField: "new_form_field_password", + }); + let differentLoginInfo = TestData.authLogin(); + let differentLoginProperties = newPropertyBag({ + origin: differentLoginInfo.origin, + formActionOrigin: differentLoginInfo.formActionOrigin, + httpRealm: differentLoginInfo.httpRealm, + username: differentLoginInfo.username, + password: differentLoginInfo.password, + usernameField: differentLoginInfo.usernameField, + passwordField: differentLoginInfo.passwordField, + }); + + // Trying to modify a login that does not exist should throw. + Assert.throws( + () => Services.logins.modifyLogin(loginInfo, newPropertyBag()), + /No matching logins/ + ); + + // Add the first form login, then modify it to match the second, changing + // only some of its properties and checking the behavior with an empty string. + await Services.logins.addLoginAsync(loginInfo); + Services.logins.modifyLogin( + loginInfo, + newPropertyBag({ + username: "new username", + password: "new password", + usernameField: "", + passwordField: "new_form_field_password", + }) + ); + + // The data should now match the second login. + LoginTestUtils.checkLogins([updatedLoginInfo]); + Assert.throws( + () => Services.logins.modifyLogin(loginInfo, newPropertyBag()), + /No matching logins/ + ); + + // It is also possible to provide no properties to be modified. + Services.logins.modifyLogin(updatedLoginInfo, newPropertyBag()); + + // Specifying a null property for a required value should throw. + Assert.throws( + () => + Services.logins.modifyLogin( + loginInfo, + newPropertyBag({ + usernameField: null, + }) + ), + /No matching logins/ + ); + + // The login can be changed to have a different type and origin. + Services.logins.modifyLogin(updatedLoginInfo, differentLoginProperties); + LoginTestUtils.checkLogins([differentLoginInfo]); + + // It is now possible to add a login with the old type and origin. + await Services.logins.addLoginAsync(loginInfo); + LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]); + + // Modifying a login to match an existing one should not be possible. + Assert.throws( + () => Services.logins.modifyLogin(loginInfo, differentLoginProperties), + /already exists/ + ); + LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]); + + LoginTestUtils.clearData(); +}); + +/** + * Tests the login deduplication function. + */ +add_task(function test_deduplicate_logins() { + // Different key attributes combinations and the amount of unique + // results expected for the TestData login list. + let keyCombinations = [ + { + keyset: ["username", "password"], + results: 17, + }, + { + keyset: ["origin", "username"], + results: 21, + }, + { + keyset: ["origin", "username", "password"], + results: 22, + }, + { + keyset: ["origin", "username", "password", "formActionOrigin"], + results: 27, + }, + ]; + + let logins = TestData.loginList(); + + for (let testCase of keyCombinations) { + // Deduplicate the logins using the current testcase keyset. + let deduped = LoginHelper.dedupeLogins(logins, testCase.keyset); + Assert.equal( + deduped.length, + testCase.results, + "Correct amount of results." + ); + + // Checks that every login after deduping is unique. + Assert.ok( + deduped.every(loginA => + deduped.every( + loginB => !compareAttributes(loginA, loginB, testCase.keyset) + ) + ), + "Every login is unique." + ); + } +}); + +/** + * Ensure that the login deduplication function keeps the most recent login. + */ +add_task(function test_deduplicate_keeps_most_recent() { + // Logins to deduplicate. + let logins = [ + TestData.formLogin({ timeLastUsed: Date.UTC(2004, 11, 4, 0, 0, 0) }), + TestData.formLogin({ + formActionOrigin: "http://example.com", + timeLastUsed: Date.UTC(2015, 11, 4, 0, 0, 0), + }), + ]; + + // Deduplicate the logins. + let deduped = LoginHelper.dedupeLogins(logins); + Assert.equal(deduped.length, 1, "Deduplicated the logins array."); + + // Verify that the remaining login have the most recent date. + let loginTimeLastUsed = deduped[0].QueryInterface( + Ci.nsILoginMetaInfo + ).timeLastUsed; + Assert.equal( + loginTimeLastUsed, + Date.UTC(2015, 11, 4, 0, 0, 0), + "Most recent login was kept." + ); + + // Deduplicate the reverse logins array. + deduped = LoginHelper.dedupeLogins(logins.reverse()); + Assert.equal(deduped.length, 1, "Deduplicated the reversed logins array."); + + // Verify that the remaining login have the most recent date. + loginTimeLastUsed = deduped[0].QueryInterface( + Ci.nsILoginMetaInfo + ).timeLastUsed; + Assert.equal( + loginTimeLastUsed, + Date.UTC(2015, 11, 4, 0, 0, 0), + "Most recent login was kept." + ); +}); + +/** + * Tests handling when adding a login with bad date values + */ +add_task(async function test_addLogin_badDates() { + LoginTestUtils.clearData(); + + let now = Date.now(); + let defaultLoginDates = { + timeCreated: now, + timeLastUsed: now, + timePasswordChanged: now, + }; + + let defaultsLogin = TestData.formLogin(); + for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) { + Assert.ok(!defaultsLogin[pname]); + } + Assert.ok( + !!(await Services.logins.addLoginAsync(defaultsLogin)), + "Sanity check adding defaults formLogin" + ); + Services.logins.removeAllUserFacingLogins(); + + // 0 is a valid date in this context - new nsLoginInfo timestamps init to 0 + for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) { + let loginInfo = TestData.formLogin( + Object.assign({}, defaultLoginDates, { + [pname]: 0, + }) + ); + Assert.ok( + !!(await Services.logins.addLoginAsync(loginInfo)), + "Check 0 value for " + pname + ); + Services.logins.removeAllUserFacingLogins(); + } + + // negative dates get clamped to 0 and are ok + for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) { + let loginInfo = TestData.formLogin( + Object.assign({}, defaultLoginDates, { + [pname]: -1, + }) + ); + Assert.ok( + !!(await Services.logins.addLoginAsync(loginInfo)), + "Check -1 value for " + pname + ); + Services.logins.removeAllUserFacingLogins(); + } + + // out-of-range dates will throw + for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) { + let loginInfo = TestData.formLogin( + Object.assign({}, defaultLoginDates, { + [pname]: MAX_DATE_MS + 1, + }) + ); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /invalid date properties/ + ); + Assert.equal(Services.logins.getAllLogins().length, 0); + } + + LoginTestUtils.checkLogins([]); +}); + +/** + * Tests handling when adding multiple logins with bad date values + */ +add_task(async function test_addLogins_badDates() { + LoginTestUtils.clearData(); + + let defaultsLogin = TestData.formLogin({ + username: "defaults", + }); + await Services.logins.addLoginAsync(defaultsLogin); + + // -11644473600000 is the value you get if you convert Dec 31 1600 16:07:02 to unix epoch time + let timeCreatedLogin = TestData.formLogin({ + username: "tc", + timeCreated: -11644473600000, + }); + await Assert.rejects( + Services.logins.addLoginAsync(timeCreatedLogin), + /Can\'t add a login with invalid date properties./ + ); + + let timeLastUsedLogin = TestData.formLogin({ + username: "tlu", + timeLastUsed: -11644473600000, + }); + await Assert.rejects( + Services.logins.addLoginAsync(timeLastUsedLogin), + /Can\'t add a login with invalid date properties./ + ); + + let timePasswordChangedLogin = TestData.formLogin({ + username: "tpc", + timePasswordChanged: -11644473600000, + }); + await Assert.rejects( + Services.logins.addLoginAsync(timePasswordChangedLogin), + /Can\'t add a login with invalid date properties./ + ); + + Services.logins.removeAllUserFacingLogins(); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js b/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js new file mode 100644 index 0000000000..30c7aa1be9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the case where there are logins that cannot be decrypted. + */ + +"use strict"; + +// Globals + +/** + * Resets the token used to decrypt logins. This is equivalent to resetting the + * primary password when it is not known. + */ +function resetPrimaryPassword() { + let token = Cc["@mozilla.org/security/pk11tokendb;1"] + .getService(Ci.nsIPK11TokenDB) + .getInternalKeyToken(); + token.reset(); + token.initPassword(""); +} + +// Tests + +/** + * Resets the primary password after some logins were added to the database. + */ +add_task(async function test_logins_decrypt_failure() { + let logins = TestData.loginList(); + await Services.logins.addLogins(logins); + + // This makes the existing logins non-decryptable. + resetPrimaryPassword(); + + // These functions don't see the non-decryptable entries anymore. + Assert.equal(Services.logins.getAllLogins().length, 0); + Assert.equal( + (await Services.logins.getAllLoginsAsync()).length, + 0, + "getAllLoginsAsync length" + ); + Assert.equal(Services.logins.findLogins("", "", "").length, 0); + Assert.equal(Services.logins.searchLogins(newPropertyBag()).length, 0); + Assert.throws( + () => Services.logins.modifyLogin(logins[0], newPropertyBag()), + /No matching logins/ + ); + Assert.throws( + () => Services.logins.removeLogin(logins[0]), + /No matching logins/ + ); + + // The function that counts logins sees the non-decryptable entries also. + Assert.equal(Services.logins.countLogins("", "", ""), logins.length); + + // Equivalent logins can be added. + await Services.logins.addLogins(logins); + LoginTestUtils.checkLogins(logins); + Assert.equal( + (await Services.logins.getAllLoginsAsync()).length, + logins.length, + "getAllLoginsAsync length" + ); + Assert.equal(Services.logins.countLogins("", "", ""), logins.length * 2); + + // Finding logins doesn't return the non-decryptable duplicates. + Assert.equal( + Services.logins.findLogins("http://www.example.com", "", "").length, + 1 + ); + let matchData = newPropertyBag({ origin: "http://www.example.com" }); + Assert.equal(Services.logins.searchLogins(matchData).length, 1); + + // Removing single logins does not remove non-decryptable logins. + for (let loginInfo of TestData.loginList()) { + Services.logins.removeLogin(loginInfo); + } + Assert.equal(Services.logins.getAllLogins().length, 0); + Assert.equal(Services.logins.countLogins("", "", ""), logins.length); + + // Removing all logins removes the non-decryptable entries also. + Services.logins.removeAllUserFacingLogins(); + Assert.equal(Services.logins.getAllLogins().length, 0); + Assert.equal(Services.logins.countLogins("", "", ""), 0); +}); + +// Bug 621846 - If a login has a GUID but can't be decrypted, a search for +// that GUID will (correctly) fail. Ensure we can add a new login with that +// same GUID. +add_task(async function test_add_logins_with_decrypt_failure() { + // a login with a GUID. + let login = new LoginInfo( + "http://www.example2.com", + "http://www.example2.com", + null, + "the username", + "the password for www.example.com", + "form_field_username", + "form_field_password" + ); + + login.QueryInterface(Ci.nsILoginMetaInfo); + login.guid = "{4bc50d2f-dbb6-4aa3-807c-c4c2065a2c35}"; + + // A different login but with the same GUID. + let loginDupeGuid = new LoginInfo( + "http://www.example3.com", + "http://www.example3.com", + null, + "the username", + "the password", + "form_field_username", + "form_field_password" + ); + loginDupeGuid.QueryInterface(Ci.nsILoginMetaInfo); + loginDupeGuid.guid = login.guid; + + await Services.logins.addLoginAsync(login); + + // We can search for this login by GUID. + let searchProp = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + searchProp.setPropertyAsAUTF8String("guid", login.guid); + + equal(Services.logins.searchLogins(searchProp).length, 1); + + // We should fail to re-add it as it remains good. + await Assert.rejects( + Services.logins.addLoginAsync(login), + /This login already exists./ + ); + // We should fail to re-add a different login with the same GUID. + await Assert.rejects( + Services.logins.addLoginAsync(loginDupeGuid), + /specified GUID already exists/ + ); + + // This makes the existing login non-decryptable. + resetPrimaryPassword(); + + // We can no longer find it in our search. + equal(Services.logins.searchLogins(searchProp).length, 0); + + // So we should be able to re-add a login with that same GUID. + await Services.logins.addLoginAsync(login); + equal(Services.logins.searchLogins(searchProp).length, 1); + + Services.logins.removeAllUserFacingLogins(); +}); + +// Test the "syncID" metadata works as expected on decryption failure. +add_task(async function test_sync_metadata_with_decrypt_failure() { + // And some sync metadata + await Services.logins.setSyncID("sync-id"); + await Services.logins.setLastSync(123); + equal(await Services.logins.getSyncID(), "sync-id"); + equal(await Services.logins.getLastSync(), 123); + + // This makes the existing login and syncID non-decryptable. + resetPrimaryPassword(); + + // The syncID is now null. + equal(await Services.logins.getSyncID(), null); + // The sync timestamp isn't impacted. + equal(await Services.logins.getLastSync(), 123); + + // But we should be able to set it again. + await Services.logins.setSyncID("new-id"); + equal(await Services.logins.getSyncID(), "new-id"); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js new file mode 100644 index 0000000000..8f31fba02b --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js @@ -0,0 +1,295 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the handling of nsILoginMetaInfo by methods that add, remove, modify, + * and find logins. + */ + +"use strict"; + +// Globals + +const gLooksLikeUUIDRegex = /^\{\w{8}-\w{4}-\w{4}-\w{4}-\w{12}\}$/; + +/** + * Retrieves the only login among the current data that matches the origin of + * the given nsILoginInfo. In case there is more than one login for the + * origin, the test fails. + */ +function retrieveLoginMatching(aLoginInfo) { + let logins = Services.logins.findLogins(aLoginInfo.origin, "", ""); + Assert.equal(logins.length, 1); + return logins[0].QueryInterface(Ci.nsILoginMetaInfo); +} + +/** + * Checks that the nsILoginInfo and nsILoginMetaInfo properties of two different + * login instances are equal. + */ +function assertMetaInfoEqual(aActual, aExpected) { + Assert.notEqual(aActual, aExpected); + + // Check the nsILoginInfo properties. + Assert.ok(aActual.equals(aExpected)); + + // Check the nsILoginMetaInfo properties. + Assert.equal(aActual.guid, aExpected.guid); + Assert.equal(aActual.timeCreated, aExpected.timeCreated); + Assert.equal(aActual.timeLastUsed, aExpected.timeLastUsed); + Assert.equal(aActual.timePasswordChanged, aExpected.timePasswordChanged); + Assert.equal(aActual.timesUsed, aExpected.timesUsed); +} + +/** + * nsILoginInfo instances with or without nsILoginMetaInfo properties. + */ +let gLoginInfo1; +let gLoginInfo2; +let gLoginInfo3; + +/** + * nsILoginInfo instances reloaded with all the nsILoginMetaInfo properties. + * These are often used to provide the reference values to test against. + */ +let gLoginMetaInfo1; +let gLoginMetaInfo2; +let gLoginMetaInfo3; + +// Tests + +/** + * Prepare the test objects that will be used by the following tests. + */ +add_task(function test_initialize() { + // Use a reference time from ten minutes ago to initialize one instance of + // nsILoginMetaInfo, to test that reference times are updated when needed. + let baseTimeMs = Date.now() - 600000; + + gLoginInfo1 = TestData.formLogin(); + gLoginInfo2 = TestData.formLogin({ + origin: "http://other.example.com", + guid: Services.uuid.generateUUID().toString(), + timeCreated: baseTimeMs, + timeLastUsed: baseTimeMs + 2, + timePasswordChanged: baseTimeMs + 1, + timesUsed: 2, + }); + gLoginInfo3 = TestData.authLogin(); +}); + +/** + * Tests the behavior of addLogin with regard to metadata. The logins added + * here are also used by the following tests. + */ +add_task(async function test_addLogin_metainfo() { + // Add a login without metadata to the database. + await Services.logins.addLoginAsync(gLoginInfo1); + + // The object provided to addLogin should not have been modified. + Assert.equal(gLoginInfo1.guid, null); + Assert.equal(gLoginInfo1.timeCreated, 0); + Assert.equal(gLoginInfo1.timeLastUsed, 0); + Assert.equal(gLoginInfo1.timePasswordChanged, 0); + Assert.equal(gLoginInfo1.timesUsed, 0); + + // A login with valid metadata should have been stored. + gLoginMetaInfo1 = retrieveLoginMatching(gLoginInfo1); + Assert.ok(gLooksLikeUUIDRegex.test(gLoginMetaInfo1.guid)); + let creationTime = gLoginMetaInfo1.timeCreated; + LoginTestUtils.assertTimeIsAboutNow(creationTime); + Assert.equal(gLoginMetaInfo1.timeLastUsed, creationTime); + Assert.equal(gLoginMetaInfo1.timePasswordChanged, creationTime); + Assert.equal(gLoginMetaInfo1.timesUsed, 1); + + // Add a login without metadata to the database. + let originalLogin = gLoginInfo2.clone().QueryInterface(Ci.nsILoginMetaInfo); + await Services.logins.addLoginAsync(gLoginInfo2); + + // The object provided to addLogin should not have been modified. + assertMetaInfoEqual(gLoginInfo2, originalLogin); + + // A login with the provided metadata should have been stored. + gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2); + assertMetaInfoEqual(gLoginMetaInfo2, gLoginInfo2); + + // Add an authentication login to the database before continuing. + await Services.logins.addLoginAsync(gLoginInfo3); + gLoginMetaInfo3 = retrieveLoginMatching(gLoginInfo3); + LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]); +}); + +/** + * Tests that adding a login with a duplicate GUID throws an exception. + */ +add_task(async function test_addLogin_metainfo_duplicate() { + let loginInfo = TestData.formLogin({ + origin: "http://duplicate.example.com", + guid: gLoginMetaInfo2.guid, + }); + await Assert.rejects( + Services.logins.addLoginAsync(loginInfo), + /specified GUID already exists/ + ); + + // Verify that no data was stored by the previous call. + LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]); +}); + +/** + * Tests that the existing metadata is not changed when modifyLogin is called + * with an nsILoginInfo argument. + */ +add_task(function test_modifyLogin_nsILoginInfo_metainfo_ignored() { + let newLoginInfo = gLoginInfo1.clone().QueryInterface(Ci.nsILoginMetaInfo); + newLoginInfo.guid = Services.uuid.generateUUID().toString(); + newLoginInfo.timeCreated = Date.now(); + newLoginInfo.timeLastUsed = Date.now(); + newLoginInfo.timePasswordChanged = Date.now(); + newLoginInfo.timesUsed = 12; + Services.logins.modifyLogin(gLoginInfo1, newLoginInfo); + + newLoginInfo = retrieveLoginMatching(gLoginInfo1); + assertMetaInfoEqual(newLoginInfo, gLoginMetaInfo1); +}); + +/** + * Tests the modifyLogin function with an nsIProperyBag argument. + */ +add_task(function test_modifyLogin_nsIProperyBag_metainfo() { + // Use a new reference time that is two minutes from now. + let newTimeMs = Date.now() + 120000; + let newUUIDValue = Services.uuid.generateUUID().toString(); + + // Check that properties are changed as requested. + Services.logins.modifyLogin( + gLoginInfo1, + newPropertyBag({ + guid: newUUIDValue, + timeCreated: newTimeMs, + timeLastUsed: newTimeMs + 2, + timePasswordChanged: newTimeMs + 1, + timesUsed: 2, + }) + ); + + gLoginMetaInfo1 = retrieveLoginMatching(gLoginInfo1); + Assert.equal(gLoginMetaInfo1.guid, newUUIDValue); + Assert.equal(gLoginMetaInfo1.timeCreated, newTimeMs); + Assert.equal(gLoginMetaInfo1.timeLastUsed, newTimeMs + 2); + Assert.equal(gLoginMetaInfo1.timePasswordChanged, newTimeMs + 1); + Assert.equal(gLoginMetaInfo1.timesUsed, 2); + + // Check that timePasswordChanged is updated when changing the password. + let originalLogin = gLoginInfo2.clone().QueryInterface(Ci.nsILoginMetaInfo); + Services.logins.modifyLogin( + gLoginInfo2, + newPropertyBag({ + password: "new password", + }) + ); + gLoginInfo2.password = "new password"; + + gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2); + Assert.equal(gLoginMetaInfo2.password, gLoginInfo2.password); + Assert.equal(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated); + Assert.equal(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed); + LoginTestUtils.assertTimeIsAboutNow(gLoginMetaInfo2.timePasswordChanged); + + // Check that timePasswordChanged is not set to the current time when changing + // the password and specifying a new value for the property at the same time. + Services.logins.modifyLogin( + gLoginInfo2, + newPropertyBag({ + password: "other password", + timePasswordChanged: newTimeMs, + }) + ); + gLoginInfo2.password = "other password"; + + gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2); + Assert.equal(gLoginMetaInfo2.password, gLoginInfo2.password); + Assert.equal(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated); + Assert.equal(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed); + Assert.equal(gLoginMetaInfo2.timePasswordChanged, newTimeMs); + + // Check the special timesUsedIncrement property. + Services.logins.modifyLogin( + gLoginInfo2, + newPropertyBag({ + timesUsedIncrement: 2, + }) + ); + + gLoginMetaInfo2 = retrieveLoginMatching(gLoginInfo2); + Assert.equal(gLoginMetaInfo2.timeCreated, originalLogin.timeCreated); + Assert.equal(gLoginMetaInfo2.timeLastUsed, originalLogin.timeLastUsed); + Assert.equal(gLoginMetaInfo2.timePasswordChanged, newTimeMs); + Assert.equal(gLoginMetaInfo2.timesUsed, 4); +}); + +/** + * Tests that modifying a login to a duplicate GUID throws an exception. + */ +add_task(function test_modifyLogin_nsIProperyBag_metainfo_duplicate() { + Assert.throws( + () => + Services.logins.modifyLogin( + gLoginInfo1, + newPropertyBag({ + guid: gLoginInfo2.guid, + }) + ), + /specified GUID already exists/ + ); + LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]); +}); + +/** + * Tests searching logins using nsILoginMetaInfo properties. + */ +add_task(function test_searchLogins_metainfo() { + // Find by GUID. + let logins = Services.logins.searchLogins( + newPropertyBag({ + guid: gLoginMetaInfo1.guid, + }) + ); + Assert.equal(logins.length, 1); + let foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + assertMetaInfoEqual(foundLogin, gLoginMetaInfo1); + + // Find by timestamp. + logins = Services.logins.searchLogins( + newPropertyBag({ + timePasswordChanged: gLoginMetaInfo2.timePasswordChanged, + }) + ); + Assert.equal(logins.length, 1); + foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + assertMetaInfoEqual(foundLogin, gLoginMetaInfo2); + + // Find using two properties at the same time. + logins = Services.logins.searchLogins( + newPropertyBag({ + guid: gLoginMetaInfo3.guid, + timePasswordChanged: gLoginMetaInfo3.timePasswordChanged, + }) + ); + Assert.equal(logins.length, 1); + foundLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + assertMetaInfoEqual(foundLogin, gLoginMetaInfo3); +}); + +/** + * Tests that the default nsILoginManagerStorage module attached to the Login + * Manager service is able to save and reload nsILoginMetaInfo properties. + */ +add_task(async function test_storage_metainfo() { + await LoginTestUtils.reloadData(); + LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]); + + assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo1), gLoginMetaInfo1); + assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo2), gLoginMetaInfo2); + assertMetaInfoEqual(retrieveLoginMatching(gLoginInfo3), gLoginMetaInfo3); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_logins_search.js b/toolkit/components/passwordmgr/test/unit/test_logins_search.js new file mode 100644 index 0000000000..89337f872e --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_logins_search.js @@ -0,0 +1,232 @@ +/** + * Tests methods that find specific logins in the store (findLogins, + * searchLogins, and countLogins). + * + * The getAllLogins method is not tested explicitly here, because it is used by + * all tests to verify additions, removals and modifications to the login store. + */ + +"use strict"; + +// Globals + +/** + * Returns a list of new nsILoginInfo objects that are a subset of the test + * data, built to match the specified query. + * + * @param aQuery + * Each property and value of this object restricts the search to those + * entries from the test data that match the property exactly. + */ +function buildExpectedLogins(aQuery) { + return TestData.loginList().filter(entry => + Object.keys(aQuery).every(name => entry[name] === aQuery[name]) + ); +} + +/** + * Tests the searchLogins function. + * + * @param aQuery + * Each property and value of this object is translated to an entry in + * the nsIPropertyBag parameter of searchLogins. + * @param aExpectedCount + * Number of logins from the test data that should be found. The actual + * list of logins is obtained using the buildExpectedLogins helper, and + * this value is just used to verify that modifications to the test data + * don't make the current test meaningless. + */ +function checkSearchLogins(aQuery, aExpectedCount) { + info("Testing searchLogins for " + JSON.stringify(aQuery)); + + let expectedLogins = buildExpectedLogins(aQuery); + Assert.equal(expectedLogins.length, aExpectedCount); + + let logins = Services.logins.searchLogins(newPropertyBag(aQuery)); + LoginTestUtils.assertLoginListsEqual(logins, expectedLogins); +} + +/** + * Tests findLogins, searchLogins, and countLogins with the same query. + * + * @param aQuery + * The "origin", "formActionOrigin", and "httpRealm" properties of this + * object are passed as parameters to findLogins and countLogins. The + * same object is then passed to the checkSearchLogins function. + * @param aExpectedCount + * Number of logins from the test data that should be found. The actual + * list of logins is obtained using the buildExpectedLogins helper, and + * this value is just used to verify that modifications to the test data + * don't make the current test meaningless. + */ +function checkAllSearches(aQuery, aExpectedCount) { + info("Testing all search functions for " + JSON.stringify(aQuery)); + + let expectedLogins = buildExpectedLogins(aQuery); + Assert.equal(expectedLogins.length, aExpectedCount); + + // The findLogins and countLogins functions support wildcard matches by + // specifying empty strings as parameters, while searchLogins requires + // omitting the property entirely. + let origin = "origin" in aQuery ? aQuery.origin : ""; + let formActionOrigin = + "formActionOrigin" in aQuery ? aQuery.formActionOrigin : ""; + let httpRealm = "httpRealm" in aQuery ? aQuery.httpRealm : ""; + + // Test findLogins. + let logins = Services.logins.findLogins(origin, formActionOrigin, httpRealm); + LoginTestUtils.assertLoginListsEqual(logins, expectedLogins); + + // Test countLogins. + let count = Services.logins.countLogins(origin, formActionOrigin, httpRealm); + Assert.equal(count, expectedLogins.length); + + // Test searchLogins. + checkSearchLogins(aQuery, aExpectedCount); +} + +// Tests + +/** + * Prepare data for the following tests. + */ +add_setup(async () => { + await Services.logins.addLogins(TestData.loginList()); +}); + +/** + * Tests findLogins, searchLogins, and countLogins with basic queries. + */ +add_task(function test_search_all_basic() { + // Find all logins, using no filters in the search functions. + checkAllSearches({}, 27); + + // Find all form logins, then all authentication logins. + checkAllSearches({ httpRealm: null }, 17); + checkAllSearches({ formActionOrigin: null }, 10); + + // Find all form logins on one host, then all authentication logins. + checkAllSearches({ origin: "http://www4.example.com", httpRealm: null }, 3); + checkAllSearches( + { origin: "http://www2.example.org", formActionOrigin: null }, + 2 + ); + + // Verify that scheme and subdomain are distinct in the origin. + checkAllSearches({ origin: "http://www.example.com" }, 1); + checkAllSearches({ origin: "https://www.example.com" }, 1); + checkAllSearches({ origin: "https://example.com" }, 1); + checkAllSearches({ origin: "http://www3.example.com" }, 3); + + // Verify that scheme and subdomain are distinct in formActionOrigin. + checkAllSearches({ formActionOrigin: "http://www.example.com" }, 2); + checkAllSearches({ formActionOrigin: "https://www.example.com" }, 2); + checkAllSearches({ formActionOrigin: "http://example.com" }, 1); + + // Find by formActionOrigin on a single host. + checkAllSearches( + { + origin: "http://www3.example.com", + formActionOrigin: "http://www.example.com", + }, + 1 + ); + checkAllSearches( + { + origin: "http://www3.example.com", + formActionOrigin: "https://www.example.com", + }, + 1 + ); + checkAllSearches( + { + origin: "http://www3.example.com", + formActionOrigin: "http://example.com", + }, + 1 + ); + + // Find by httpRealm on all hosts. + checkAllSearches({ httpRealm: "The HTTP Realm" }, 3); + checkAllSearches({ httpRealm: "ftp://ftp.example.org" }, 1); + checkAllSearches({ httpRealm: "The HTTP Realm Other" }, 2); + + // Find by httpRealm on a single host. + checkAllSearches( + { origin: "http://example.net", httpRealm: "The HTTP Realm" }, + 1 + ); + checkAllSearches( + { origin: "http://example.net", httpRealm: "The HTTP Realm Other" }, + 1 + ); + checkAllSearches( + { origin: "ftp://example.net", httpRealm: "ftp://example.net" }, + 1 + ); +}); + +/** + * Tests searchLogins with advanced queries. + */ +add_task(function test_searchLogins() { + checkSearchLogins({ usernameField: "form_field_username" }, 12); + checkSearchLogins({ passwordField: "form_field_password" }, 13); + + // Find all logins with an empty usernameField, including for authentication. + checkSearchLogins({ usernameField: "" }, 15); + + // Find form logins with an empty usernameField. + checkSearchLogins({ httpRealm: null, usernameField: "" }, 5); + + // Find logins with an empty usernameField on one host. + checkSearchLogins( + { origin: "http://www6.example.com", usernameField: "" }, + 1 + ); +}); + +/** + * Tests searchLogins with invalid arguments. + */ +add_task(function test_searchLogins_invalid() { + Assert.throws( + () => Services.logins.searchLogins(newPropertyBag({ username: "value" })), + /Unexpected field/ + ); +}); + +/** + * Tests that matches are case-sensitive, compare the full field value, and are + * strict when interpreting the prePath of URIs. + */ +add_task(function test_search_all_full_case_sensitive() { + checkAllSearches({ origin: "http://www.example.com" }, 1); + checkAllSearches({ origin: "http://www.example.com/" }, 0); + checkAllSearches({ origin: "example.com" }, 0); + + checkAllSearches({ formActionOrigin: "http://www.example.com" }, 2); + checkAllSearches({ formActionOrigin: "http://www.example.com/" }, 0); + checkAllSearches({ formActionOrigin: "http://" }, 0); + checkAllSearches({ formActionOrigin: "example.com" }, 0); + + checkAllSearches({ httpRealm: "The HTTP Realm" }, 3); + checkAllSearches({ httpRealm: "The http Realm" }, 0); + checkAllSearches({ httpRealm: "The HTTP" }, 0); + checkAllSearches({ httpRealm: "Realm" }, 0); +}); + +/** + * Tests findLogins, searchLogins, and countLogins with queries that should + * return no values. + */ +add_task(function test_search_all_empty() { + checkAllSearches({ origin: "http://nonexistent.example.com" }, 0); + checkAllSearches( + { formActionOrigin: "http://www.example.com", httpRealm: "The HTTP Realm" }, + 0 + ); + + checkSearchLogins({ origin: "" }, 0); + checkSearchLogins({ id: "1000" }, 0); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js b/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js new file mode 100644 index 0000000000..ff60ceb1d5 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js @@ -0,0 +1,368 @@ +"use strict"; + +const HOST1 = "https://www.example.com"; +const HOST2 = "https://www.mozilla.org"; + +const USER1 = "myuser"; +const USER2 = "anotheruser"; + +const PASS1 = "mypass"; +const PASS2 = "anotherpass"; +const PASS3 = "yetanotherpass"; + +async function maybeImportLogins(logins) { + let summary = await LoginHelper.maybeImportLogins(logins); + return summary.filter(ir => ir.result == "added").map(ir => ir.login); +} + +add_task(async function test_invalid_logins() { + let importedLogins = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: "example.com", // Not an origin + formActionOrigin: HOST1, + }, + { + username: USER1, + // no password + origin: HOST1, + formActionOrigin: HOST1, + }, + { + username: USER2, + password: "", // Empty password + origin: HOST1, + formActionOrigin: HOST1, + }, + ]); + Assert.equal( + importedLogins.length, + 0, + `Return value should indicate no imported login: ${JSON.stringify( + importedLogins, + null, + 2 + )}` + ); + let savedLogins = Services.logins.getAllLogins(); + Assert.equal( + savedLogins.length, + 0, + `Should have no logins in storage: ${JSON.stringify(savedLogins, null, 2)}` + ); + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_new_logins() { + let [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1 + "/", + formActionOrigin: HOST1 + "/", + }, + ]); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should be 1 login for ${HOST1}` + ); + + [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST2, + formActionOrigin: HOST2, + }, + ]); + + Assert.ok( + importedLogin, + "Return value should indicate another imported login." + ); + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should still be 1 login for ${HOST1}` + ); + + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST2 }); + Assert.equal( + matchingLogins.length, + 1, + `There should also be 1 login for ${HOST2}` + ); + Assert.equal( + Services.logins.getAllLogins().length, + 2, + "There should be 2 logins in total" + ); + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_duplicate_logins() { + let [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1, + formActionOrigin: HOST1, + }, + ]); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should be 1 login for ${HOST1}` + ); + + [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1, + formActionOrigin: HOST1, + }, + ]); + Assert.ok( + !importedLogin, + "Return value should indicate no new login was imported." + ); + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should still be 1 login for ${HOST1}` + ); + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_different_passwords() { + let [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1, + formActionOrigin: HOST1, + timeCreated: new Date(Date.now() - 1000), + }, + ]); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should be 1 login for ${HOST1}` + ); + + // This item will be newer, so its password should take precedence. + [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS2, + origin: HOST1, + formActionOrigin: HOST1, + timeCreated: new Date(), + }, + ]); + Assert.ok( + !importedLogin, + "Return value should not indicate imported login (as we updated an existing one)." + ); + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should still be 1 login for ${HOST1}` + ); + Assert.equal( + matchingLogins[0].password, + PASS2, + "We should have updated the password for this login." + ); + + // Now try to update with an older password: + [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS3, + origin: HOST1, + formActionOrigin: HOST1, + timeCreated: new Date(Date.now() - 1000000), + }, + ]); + Assert.ok( + !importedLogin, + "Return value should not indicate imported login (as we didn't update anything)." + ); + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should still be 1 login for ${HOST1}` + ); + Assert.equal( + matchingLogins[0].password, + PASS2, + "We should NOT have updated the password for this login." + ); + + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_different_usernames_without_guid() { + let [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1, + formActionOrigin: HOST1, + }, + ]); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should be 1 login for ${HOST1}` + ); + + [importedLogin] = await maybeImportLogins([ + { + username: USER2, + password: PASS1, + origin: HOST1, + formActionOrigin: HOST1, + }, + ]); + Assert.ok( + importedLogin, + "Return value should indicate another imported login." + ); + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 2, + `There should now be 2 logins for ${HOST1}` + ); + + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_different_usernames_with_guid() { + let [{ login: importedLogin }] = await LoginHelper.maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1, + formActionOrigin: HOST1, + }, + ]); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should be 1 login for ${HOST1}` + ); + + info("Changing both the origin and username using the GUID"); + let importedLogins = await LoginHelper.maybeImportLogins([ + { + username: USER2, + password: PASS1, + origin: HOST2, + formActionOrigin: HOST1, + guid: importedLogin.guid, + }, + ]); + Assert.equal( + importedLogins[0].result, + "modified", + "Return value should indicate an update" + ); + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST2 }); + Assert.equal( + matchingLogins.length, + 1, + `The 1 login for ${HOST1} should have been updated` + ); + let storageLogin = matchingLogins[0]; + Assert.equal(storageLogin.guid, importedLogin.guid, "Check same guid"); + Assert.equal(storageLogin.username, USER2, "Check username updated"); + Assert.equal(storageLogin.origin, HOST2, "Check origin updated"); + + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_different_targets() { + let [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1, + formActionOrigin: HOST1, + }, + ]); + Assert.ok(importedLogin, "Return value should indicate imported login."); + let matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should be 1 login for ${HOST1}` + ); + + // Not passing either a formActionOrigin or a httpRealm should be treated as + // the same as the previous login + [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1, + }, + ]); + Assert.ok( + !importedLogin, + "Return value should NOT indicate imported login " + + "(because a missing formActionOrigin and httpRealm should be duped to the existing login)." + ); + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 1, + `There should still be 1 login for ${HOST1}` + ); + Assert.equal( + matchingLogins[0].formActionOrigin, + HOST1, + "The form submission URL should have been kept." + ); + + [importedLogin] = await maybeImportLogins([ + { + username: USER1, + password: PASS1, + origin: HOST1, + httpRealm: HOST1, + }, + ]); + Assert.ok( + importedLogin, + "Return value should indicate another imported login " + + "as an httpRealm login shouldn't be duped." + ); + matchingLogins = LoginHelper.searchLoginsWithObject({ origin: HOST1 }); + Assert.equal( + matchingLogins.length, + 2, + `There should now be 2 logins for ${HOST1}` + ); + + Services.logins.removeAllUserFacingLogins(); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js new file mode 100644 index 0000000000..12e040eb60 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js @@ -0,0 +1,870 @@ +/* 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 LoginCSVImport module. + */ + +"use strict"; + +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); +const { LoginExport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginExport.sys.mjs" +); +const { TelemetryTestUtils: TTU } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +// Enable the collection (during test) for all products so even products +// that don't collect the data will be able to run the test without failure. +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +const CATEGORICAL_HISTOGRAM = "PWMGR_IMPORT_LOGINS_FROM_FILE_CATEGORICAL"; +const IMPORT_TIMER_HISTOGRAM = "PWMGR_IMPORT_LOGINS_FROM_FILE_MS"; +const IMPORT_JANK_HISTOGRAM = "PWMGR_IMPORT_LOGINS_FROM_FILE_JANK_MS"; +/** + * Given an array of strings it creates a temporary CSV file that has them as content. + * + * @param {string[]} csvLines + * The lines that make up the CSV file. + * @param {string} extension + * Optional parameter. Either 'csv' or 'tsv'. Default is 'csv'. + * @returns {string} The path to the CSV file that was created. + */ +async function setupCsv(csvLines, extension) { + // Cleanup state. + TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM); + TTU.getAndClearHistogram(IMPORT_TIMER_HISTOGRAM); + TTU.getAndClearHistogram(IMPORT_JANK_HISTOGRAM); + Services.logins.removeAllUserFacingLogins(); + let tmpFile = await LoginTestUtils.file.setupCsvFileWithLines( + csvLines, + extension + ); + return tmpFile.path; +} + +function checkMetaInfo( + actual, + expected, + props = ["timesUsed", "timeCreated", "timePasswordChanged", "timeLastUsed"] +) { + for (let prop of props) { + // This will throw if not equal. + equal(actual[prop], expected[prop], `Check ${prop}`); + } + return true; +} + +function checkLoginNewlyCreated(login) { + // These will throw if not equal. + LoginTestUtils.assertTimeIsAboutNow(login.timeCreated); + LoginTestUtils.assertTimeIsAboutNow(login.timePasswordChanged); + LoginTestUtils.assertTimeIsAboutNow(login.timeLastUsed); + return true; +} + +/** + * Asserts histogram telemetry for the categories of logins + * + * @param {Object} histogram Histogram object returned from `TelemetryTestUtils.getAndClearHistogram()` + * @param {Number} index Index representing one of the following values in order: ["added", "modified", "error", "no_change"]. See `toolkit/components/telemetry/Histogram.json` for more information + * @param {Number} expected The expected number of entries in the histogram at the passed index + */ +function assertHistogramTelemetry(histogram, index, expected) { + TTU.assertHistogram(histogram, index, expected); +} + +/** + * Ensure that an import works with TSV. + */ +add_task(async function test_import_tsv() { + let histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM); + let tsvFilePath = await setupCsv( + [ + "url\tusername\tpassword\thttpRealm\tformActionOrigin\tguid\ttimeCreated\ttimeLastUsed\ttimePasswordChanged", + `https://example.com:8080\tjoe@example.com\tqwerty\tMy realm\t""\t{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}\t1589617814635\t1589710449871\t1589617846802`, + ], + "tsv" + ); + + await LoginCSVImport.importFromCSV(tsvFilePath); + assertHistogramTelemetry(histogram, 0, 1); + + LoginTestUtils.checkLogins( + [ + TestData.authLogin({ + formActionOrigin: null, + guid: "{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}", + httpRealm: "My realm", + origin: "https://example.com:8080", + password: "qwerty", + passwordField: "", + timeCreated: 1589617814635, + timeLastUsed: 1589710449871, + timePasswordChanged: 1589617846802, + timesUsed: 1, + username: "joe@example.com", + usernameField: "", + }), + ], + "Check that a new login was added with the correct fields", + (a, e) => a.equals(e) && checkMetaInfo(a, e) + ); +}); + +/** + * Ensure that an import fails if there is no username column in a TSV file. + */ +add_task(async function test_import_tsv_with_missing_columns() { + let csvFilePath = await setupCsv( + [ + "url\tusernameTypo\tpassword\thttpRealm\tformActionOrigin\tguid\ttimeCreated\ttimeLastUsed\ttimePasswordChanged", + "https://example.com\tkramer@example.com\tqwerty\tMy realm\t\t{5ec0d12f-e194-4279-ae1b-d7d281bb46f7}\t1589617814635\t1589710449871\t1589617846802", + ], + "tsv" + ); + + await Assert.rejects( + LoginCSVImport.importFromCSV(csvFilePath), + /FILE_FORMAT_ERROR/, + "Ensure missing username throws" + ); + + LoginTestUtils.checkLogins( + [], + "Check that no login was added without finding columns" + ); +}); + +/** + * Ensure that an import fails if there is no username column. We don't want + * to accidentally import duplicates due to a name mismatch for the username column. + */ +add_task(async function test_import_lacking_username_column() { + let csvFilePath = await setupCsv([ + "url,usernameTypo,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example.com,joe@example.com,qwerty,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802`, + ]); + + await Assert.rejects( + LoginCSVImport.importFromCSV(csvFilePath), + /FILE_FORMAT_ERROR/, + "Ensure missing username throws" + ); + + LoginTestUtils.checkLogins( + [], + "Check that no login was added without finding a username column" + ); +}); + +/** + * Ensure that an import fails if there are two columns that map to one login field. + */ +add_task(async function test_import_with_duplicate_fields() { + // Two origin columns (url & login_uri). + // One row has different values and the other has the same. + let csvFilePath = await setupCsv([ + "url,login_uri,username,login_password", + "https://example.com/path,https://example.com,john@example.com,azerty", + "https://mozilla.org,https://mozilla.org,jdoe@example.com,qwerty", + ]); + + await Assert.rejects( + LoginCSVImport.importFromCSV(csvFilePath), + /CONFLICTING_VALUES_ERROR/, + "Check that the errorType is file format error" + ); + + LoginTestUtils.checkLogins( + [], + "Check that no login was added from a file with duplicated columns" + ); +}); + +/** + * Ensure that an import fails if there are two identical columns. + */ +add_task(async function test_import_with_duplicate_columns() { + let csvFilePath = await setupCsv([ + "url,username,password,password", + "https://example.com/path,john@example.com,azerty,12345", + ]); + + await Assert.rejects( + LoginCSVImport.importFromCSV(csvFilePath), + /CONFLICTING_VALUES_ERROR/, + "Check that the errorType is file format error" + ); + + LoginTestUtils.checkLogins( + [], + "Check that no login was added from a file with duplicated columns" + ); +}); + +/** + * Ensure that import is allowed with only origin, username, password and that + * one can mix and match column naming between conventions from different + * password managers (so that we better support new/unknown password managers). + */ +add_task(async function test_import_minimal_with_mixed_naming() { + let csvFilePath = await setupCsv([ + "url,username,login_password", + "ftp://example.com,john@example.com,azerty", + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + LoginTestUtils.checkLogins( + [ + TestData.formLogin({ + formActionOrigin: "", + httpRealm: null, + origin: "ftp://example.com", + password: "azerty", + passwordField: "", + timesUsed: 1, + username: "john@example.com", + usernameField: "", + }), + ], + "Check that a new login was added with the correct fields", + (a, e) => + a.equals(e) && + checkMetaInfo(a, e, ["timesUsed"]) && + checkLoginNewlyCreated(a) + ); +}); + +/** + * Imports login data from the latest Firefox CSV file for various logins from + * LoginTestUtils.testData.loginList(). + */ +add_task(async function test_import_from_firefox_various_latest() { + await setupCsv([]); + info("Populate the login list for export"); + let logins = LoginTestUtils.testData.loginList(); + await Services.logins.addLogins(logins); + + let tmpFilePath = FileTestUtils.getTempFile("logins.csv").path; + await LoginExport.exportAsCSV(tmpFilePath); + + await LoginCSVImport.importFromCSV(tmpFilePath); + + LoginTestUtils.checkLogins( + logins, + "Check that all of LoginTestUtils.testData.loginList can be re-imported" + ); +}); + +/** + * Imports login data from a Firefox CSV file without quotes. + */ +add_task(async function test_import_from_firefox_auth() { + let csvFilePath = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + `https://example.com:8080,joe@example.com,qwerty,My realm,"",{5ec0d12f-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802`, + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.authLogin({ + formActionOrigin: null, + guid: "{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}", + httpRealm: "My realm", + origin: "https://example.com:8080", + password: "qwerty", + passwordField: "", + timeCreated: 1589617814635, + timeLastUsed: 1589710449871, + timePasswordChanged: 1589617846802, + timesUsed: 1, + username: "joe@example.com", + usernameField: "", + }), + ], + "Check that a new login was added with the correct fields", + (a, e) => a.equals(e) && checkMetaInfo(a, e) + ); +}); + +/** + * Imports login data from a Firefox CSV file with quotes. + */ +add_task(async function test_import_from_firefox_auth_with_quotes() { + let csvFilePath = await setupCsv([ + '"url","username","password","httpRealm","formActionOrigin","guid","timeCreated","timeLastUsed","timePasswordChanged"', + '"https://example.com","joe@example.com","qwerty2","My realm",,"{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}","1589617814635","1589710449871","1589617846802"', + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.authLogin({ + formActionOrigin: null, + httpRealm: "My realm", + origin: "https://example.com", + password: "qwerty2", + passwordField: "", + timeCreated: 1589617814635, + timeLastUsed: 1589710449871, + timePasswordChanged: 1589617846802, + timesUsed: 1, + username: "joe@example.com", + usernameField: "", + }), + ], + "Check that a new login was added with the correct fields", + (a, e) => a.equals(e) && checkMetaInfo(a, e) + ); +}); + +/** + * Imports login data from a Firefox CSV file where only cells containing a comma are quoted. + */ +add_task(async function test_import_from_firefox_auth_some_quoted_fields() { + let csvFilePath = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + 'https://example.com,joe@example.com,"one,two,tree","My realm",,{5ec0d12f-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802', + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.authLogin({ + formActionOrigin: null, + httpRealm: "My realm", + origin: "https://example.com", + password: "one,two,tree", + passwordField: "", + timeCreated: 1589617814635, + timePasswordChanged: 1589617846802, + timeLastUsed: 1589710449871, + timesUsed: 1, + username: "joe@example.com", + usernameField: "", + }), + ], + "Check that a new login was added with the correct fields", + (a, e) => a.equals(e) && checkMetaInfo(a, e) + ); +}); + +/** + * Imports login data from a Firefox CSV file with an empty formActionOrigin and null httpRealm + */ +add_task(async function test_import_from_firefox_form_empty_formActionOrigin() { + let csvFilePath = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://example.com,joe@example.com,s3cret1,,,{5ec0d12f-e194-4279-ae1b-d7d281bb46f0},1589617814636,1589710449872,1589617846803", + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.formLogin({ + formActionOrigin: "", + httpRealm: null, + origin: "https://example.com", + password: "s3cret1", + passwordField: "", + timeCreated: 1589617814636, + timePasswordChanged: 1589617846803, + timeLastUsed: 1589710449872, + timesUsed: 1, + username: "joe@example.com", + usernameField: "", + }), + ], + "Check that a new login was added with the correct fields", + (a, e) => a.equals(e) && checkMetaInfo(a, e) + ); +}); + +/** + * Imports login data from a Firefox CSV file with a non-empty formActionOrigin and null httpRealm. + */ +add_task(async function test_import_from_firefox_form_with_formActionOrigin() { + let csvFilePath = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "http://example.com,joe@example.com,s3cret1,,https://other.example.com,{5ec0d12f-e194-4279-ae1b-d7d281bb46f1},1589617814635,1589710449871,1589617846802", + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.formLogin({ + formActionOrigin: "https://other.example.com", + httpRealm: null, + origin: "http://example.com", + password: "s3cret1", + passwordField: "", + timeCreated: 1589617814635, + timePasswordChanged: 1589617846802, + timeLastUsed: 1589710449871, + timesUsed: 1, + username: "joe@example.com", + usernameField: "", + }), + ], + "Check that a new login was added with the correct fields", + (a, e) => a.equals(e) && checkMetaInfo(a, e) + ); +}); + +/** + * Imports login data from a Bitwarden CSV file. + * `name` is ignored until bug 1433770. + */ +add_task(async function test_import_from_bitwarden_csv() { + let csvFilePath = await setupCsv([ + "folder,favorite,type,name,notes,fields,login_uri,login_username,login_password,login_totp", + `,,note,jane's note,"secret note, ignore me!",,,,,`, + ",,login,example.com,,,https://example.com/login,jane@example.com,secret_password", + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.formLogin({ + formActionOrigin: "", + httpRealm: null, + origin: "https://example.com", + password: "secret_password", + passwordField: "", + timesUsed: 1, + username: "jane@example.com", + usernameField: "", + }), + ], + "Check that a new Bitwarden login was added with the correct fields", + (a, e) => + a.equals(e) && + checkMetaInfo(a, e, ["timesUsed"]) && + checkLoginNewlyCreated(a) + ); +}); + +/** + * Imports login data from a Chrome CSV file. + * `name` is ignored until bug 1433770. + */ +add_task(async function test_import_from_chrome_csv() { + let csvFilePath = await setupCsv([ + "name,url,username,password", + "example.com,https://example.com/login,jane@example.com,secret_chrome_password", + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.formLogin({ + formActionOrigin: "", + httpRealm: null, + origin: "https://example.com", + password: "secret_chrome_password", + passwordField: "", + timesUsed: 1, + username: "jane@example.com", + usernameField: "", + }), + ], + "Check that a new Chrome login was added with the correct fields", + (a, e) => + a.equals(e) && + checkMetaInfo(a, e, ["timesUsed"]) && + checkLoginNewlyCreated(a) + ); +}); + +/** + * Imports login data with an item without the username. + */ +add_task(async function test_import_login_without_username() { + let csvFilePath = await setupCsv([ + "url,username,password", + "https://example.com/login,,secret_password", + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.formLogin({ + formActionOrigin: "", + httpRealm: null, + origin: "https://example.com", + password: "secret_password", + passwordField: "", + timesUsed: 1, + username: "", + usernameField: "", + }), + ], + "Check that a Login is added without an username", + (a, e) => + a.equals(e) && + checkMetaInfo(a, e, ["timesUsed"]) && + checkLoginNewlyCreated(a) + ); +}); + +/** + * Imports login data from a KeepassXC CSV file. + * `Title` is ignored until bug 1433770. + */ +add_task(async function test_import_from_keepassxc_csv() { + let csvFilePath = await setupCsv([ + `"Group","Title","Username","Password","URL","Notes"`, + `"NewDatabase/Internet","Amazing","test@example.com","","https://example.org",""`, + ]); + + await LoginCSVImport.importFromCSV(csvFilePath); + + LoginTestUtils.checkLogins( + [ + TestData.formLogin({ + formActionOrigin: "", + httpRealm: null, + origin: "https://example.org", + password: "", + passwordField: "", + timesUsed: 1, + username: "test@example.com", + usernameField: "", + }), + ], + "Check that a new KeepassXC login was added with the correct fields", + (a, e) => + a.equals(e) && + checkMetaInfo(a, e, ["timesUsed"]) && + checkLoginNewlyCreated(a) + ); +}); + +/** + * Imports login data summary contains added logins. + */ +add_task(async function test_import_summary_contains_added_login() { + let csvFilePath = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://added.example.com,jane@example.com,added_passwordd,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0003},1589617814635,1589710449871,1589617846802", + ]); + + let [added] = await LoginCSVImport.importFromCSV(csvFilePath); + + equal(added.result, "added", `Check that the login was added`); +}); + +/** + * Imports login data summary contains modified logins without guid. + */ +add_task(async function test_import_summary_modified_login_without_guid() { + let histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM); + let initialDataFile = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://modifiedwithoutguid.example.com,gini@example.com,initial_password,My realm,,,1589617814635,1589710449871,1589617846802", + ]); + await LoginCSVImport.importFromCSV(initialDataFile); + assertHistogramTelemetry(histogram, 0, 1); + histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM); + + let csvFile = await LoginTestUtils.file.setupCsvFileWithLines([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://modifiedwithoutguid.example.com,gini@example.com,modified_password,My realm,,,1589617814635,1589710449871,1589617846999", + ]); + + let [modifiedWithoutGuid] = await LoginCSVImport.importFromCSV(csvFile.path); + assertHistogramTelemetry(histogram, 1, 1); + equal( + modifiedWithoutGuid.result, + "modified", + `Check that the login was modified when there was no guid data` + ); + LoginTestUtils.checkLogins( + [ + TestData.authLogin({ + formActionOrigin: null, + guid: null, + httpRealm: "My realm", + origin: "https://modifiedwithoutguid.example.com", + password: "modified_password", + passwordField: "", + timeCreated: 1589617814635, + timeLastUsed: 1589710449871, + timePasswordChanged: 1589617846999, + timesUsed: 1, + username: "gini@example.com", + usernameField: "", + }), + ], + "Check that logins were updated with the correct fields", + (a, e) => a.equals(e) && checkMetaInfo(a, e) + ); +}); + +/** + * Imports login data summary contains modified logins with guid. + */ +add_task(async function test_import_summary_modified_login_with_guid() { + let initialDataFile = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://modifiedwithguid.example.com,jane@example.com,initial_password,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0001},1589617814635,1589710449871,1589617846802", + ]); + await LoginCSVImport.importFromCSV(initialDataFile); + + let csvFile = await LoginTestUtils.file.setupCsvFileWithLines([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://modified.example.com,jane@example.com,modified_password,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0001},1589617814635,1589710449871,1589617846999", + ]); + + let [modifiedWithGuid] = await LoginCSVImport.importFromCSV(csvFile.path); + + equal( + modifiedWithGuid.result, + "modified", + `Check that the login was modified when it had the same guid` + ); + LoginTestUtils.checkLogins( + [ + TestData.authLogin({ + formActionOrigin: null, + guid: "{5ec0d12f-e194-4279-ae1b-d7d281bb0001}", + httpRealm: "My realm", + origin: "https://modified.example.com", + password: "modified_password", + passwordField: "", + timeCreated: 1589617814635, + timeLastUsed: 1589710449871, + timePasswordChanged: 1589617846999, + timesUsed: 1, + username: "jane@example.com", + usernameField: "", + }), + ], + "Check that logins were updated with the correct fields", + (a, e) => a.equals(e) && checkMetaInfo(a, e) + ); +}); + +/** + * Imports login data summary contains unchanged logins. + */ +add_task(async function test_import_summary_contains_unchanged_login() { + let histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM); + let initialDataFile = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://nochange.example.com,jane@example.com,nochange_password,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0002},1589617814635,1589710449871,1589617846802", + ]); + await LoginCSVImport.importFromCSV(initialDataFile); + assertHistogramTelemetry(histogram, 0, 1); + histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM); + + let csvFile = await LoginTestUtils.file.setupCsvFileWithLines([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://nochange.example.com,jane@example.com,nochange_password,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0002},1589617814635,1589710449871,1589617846802", + ]); + + let [noChange] = await LoginCSVImport.importFromCSV(csvFile.path); + assertHistogramTelemetry(histogram, 3, 1); + equal(noChange.result, "no_change", `Check that the login was not changed`); +}); + +/** + * Imports login data summary contains logins with errors in case of missing fields. + */ +add_task(async function test_import_summary_contains_missing_fields_errors() { + let histogram = TTU.getAndClearHistogram(CATEGORICAL_HISTOGRAM); + const missingFieldsToCheck = ["url", "password"]; + const sourceObject = { + url: "https://invalid.password.example.com", + username: "jane@example.com", + password: "qwerty", + }; + for (const missingField of missingFieldsToCheck) { + const clonedUser = { ...sourceObject }; + clonedUser[missingField] = ""; + let csvFilePath = await setupCsv([ + "url,username,password", + `${clonedUser.url},${clonedUser.username},${clonedUser.password}`, + ]); + + let [importLogin] = await LoginCSVImport.importFromCSV(csvFilePath); + + equal( + importLogin.result, + "error_missing_field", + `Check that the missing field error is reported for ${missingField}` + ); + equal( + importLogin.field_name, + missingField, + `Check that the invalid field name is correctly reported for the ${missingField}` + ); + } + assertHistogramTelemetry(histogram, 2, 1); +}); + +/** + * Imports login with wrong file format will have correct errorType. + */ +add_task(async function test_import_summary_with_bad_format() { + let csvFilePath = await setupCsv(["password", "123qwe!@#QWE"]); + + await Assert.rejects( + LoginCSVImport.importFromCSV(csvFilePath), + /FILE_FORMAT_ERROR/, + "Check that the errorType is file format error" + ); + + LoginTestUtils.checkLogins( + [], + "Check that no login was added with bad format" + ); +}); + +/** + * Imports login with wrong file type will have correct errorType. + */ +add_task(async function test_import_summary_with_non_csv_file() { + let csvFilePath = await setupCsv([ + "this is totally not a csv file", + ]); + + await Assert.rejects( + LoginCSVImport.importFromCSV(csvFilePath), + /FILE_FORMAT_ERROR/, + "Check that the errorType is file format error" + ); + + LoginTestUtils.checkLogins( + [], + "Check that no login was added with file of different format" + ); +}); + +/** + * Imports login multiple url and user will import the first and skip the second. + */ +add_task(async function test_import_summary_with_url_user_multiple_values() { + let csvFilePath = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://example.com,jane@example.com,password1,My realm", + "https://example.com,jane@example.com,password2,My realm", + ]); + + let initialLoginCount = Services.logins.getAllLogins().length; + + let results = await LoginCSVImport.importFromCSV(csvFilePath); + let afterImportLoginCount = Services.logins.getAllLogins().length; + + equal(results.length, 2, `Check that we got a result for each imported row`); + equal(results[0].result, "added", `Check that the first login was added`); + equal( + results[1].result, + "no_change", + `Check that the second login was skipped` + ); + equal(initialLoginCount, 0, `Check that initially we had no logins`); + equal(afterImportLoginCount, 1, `Check that we imported only one login`); +}); + +/** + * Imports login with duplicated guid values throws error. + */ +add_task(async function test_import_summary_with_duplicated_guid_values() { + let csvFilePath = await setupCsv([ + "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged", + "https://example1.com,jane1@example.com,password1,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0004},1589617814635,1589710449871,1589617846802", + "https://example2.com,jane2@example.com,password2,My realm,,{5ec0d12f-e194-4279-ae1b-d7d281bb0004},1589617814635,1589710449871,1589617846802", + ]); + let initialLoginCount = Services.logins.getAllLogins().length; + + let results = await LoginCSVImport.importFromCSV(csvFilePath); + let afterImportLoginCount = Services.logins.getAllLogins().length; + + equal(results.length, 2, `Check that we got a result for each imported row`); + equal(results[0].result, "added", `Check that the first login was added`); + equal(results[1].result, "error", `Check that the second login was an error`); + equal(initialLoginCount, 0, `Check that initially we had no logins`); + equal(afterImportLoginCount, 1, `Check that we imported only one login`); +}); + +/** + * Imports login with different passwords will pick up the newest one and ignore the oldest one. + */ +add_task(async function test_import_summary_with_different_time_changed() { + let csvFilePath = await setupCsv([ + "url,username,password,timeCreated,timeLastUsed,timePasswordChanged", + "https://example.com,eve@example.com,old password,1589617814635,1589710449800,1589617846800", + "https://example.com,eve@example.com,new password,1589617814635,1589710449801,1589617846801", + ]); + let initialLoginCount = Services.logins.getAllLogins().length; + + let results = await LoginCSVImport.importFromCSV(csvFilePath); + let afterImportLoginCount = Services.logins.getAllLogins().length; + + equal(results.length, 2, `Check that we got a result for each imported row`); + equal( + results[0].result, + "no_change", + `Check that the oldest password is skipped` + ); + equal( + results[1].login.password, + "new password", + `Check that the newest password is imported` + ); + equal( + results[1].result, + "added", + `Check that the newest password result is correct` + ); + equal(initialLoginCount, 0, `Check that initially we had no logins`); + equal(afterImportLoginCount, 1, `Check that we imported only one login`); +}); + +/** + * Imports duplicate logins as one without an error. + */ +add_task(async function test_import_duplicate_logins_as_one() { + let csvFilePath = await setupCsv([ + "name,url,username,password", + "somesite,https://example.com/,user@example.com,asdasd123123", + "somesite,https://example.com/,user@example.com,asdasd123123", + ]); + let initialLoginCount = Services.logins.getAllLogins().length; + + let results = await LoginCSVImport.importFromCSV(csvFilePath); + let afterImportLoginCount = Services.logins.getAllLogins().length; + + equal(results.length, 2, `Check that we got a result for each imported row`); + equal( + results[0].result, + "added", + `Check that the first login login was added` + ); + equal( + results[1].result, + "no_change", + `Check that the second login was not changed` + ); + + equal(initialLoginCount, 0, `Check that initially we had no logins`); + equal(afterImportLoginCount, 1, `Check that we imported only one login`); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js new file mode 100644 index 0000000000..d7df6168c9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js @@ -0,0 +1,219 @@ +/* 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.importESModule( + "resource://gre/modules/LoginExport.sys.mjs" +); +let { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +/** + * 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() { + const tmpFilePath = FileTestUtils.getTempFile("logins.csv").path; + await LoginExport.exportAsCSV(tmpFilePath); + const csvString = await IOUtils.readUTF8(tmpFilePath); + await IOUtils.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_setup(async () => { + 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", + "unknownFields", + "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(); + // Note, because we're stubbing this method and avoiding the actual login manager logic, + // login de-duplication does not occur + 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` + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginManager.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginManager.js new file mode 100644 index 0000000000..8ab75bdeef --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginManager.js @@ -0,0 +1,37 @@ +/* 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 LoginManager module. + */ + +"use strict"; + +const { LoginManager } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManager.sys.mjs" +); + +add_task(async function test_ensureCurrentSyncID() { + let loginManager = new LoginManager(); + await loginManager.setSyncID(1); + await loginManager.setLastSync(100); + + // test calling ensureCurrentSyncID with the current sync ID + Assert.equal(await loginManager.ensureCurrentSyncID(1), 1); + Assert.equal(await loginManager.getSyncID(), 1, "sync ID shouldn't change"); + Assert.equal( + await loginManager.getLastSync(), + 100, + "last sync shouldn't change" + ); + + // test calling ensureCurrentSyncID with the different sync ID + Assert.equal(await loginManager.ensureCurrentSyncID(2), 2); + Assert.equal(await loginManager.getSyncID(), 2, "sync ID should be updated"); + Assert.equal( + await loginManager.getLastSync(), + 0, + "last sync should be reset" + ); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js b/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js new file mode 100644 index 0000000000..f7e764c789 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js @@ -0,0 +1,337 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the LoginStore object. + */ + +"use strict"; + +// Globals + +ChromeUtils.defineESModuleGetters(this, { + LoginStore: "resource://gre/modules/LoginStore.sys.mjs", +}); + +const TEST_STORE_FILE_NAME = "test-logins.json"; + +const MAX_DATE_MS = 8640000000000000; + +// Tests + +/** + * Saves login data to a file, then reloads it. + */ +add_task(async function test_save_reload() { + let storeForSave = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + // The "load" method must be called before preparing the data to be saved. + await storeForSave.load(); + + let rawLoginData = { + id: storeForSave.data.nextId++, + hostname: "http://www.example.com", + httpRealm: null, + formSubmitURL: "http://www.example.com", + usernameField: "field_" + String.fromCharCode(533, 537, 7570, 345), + passwordField: "field_" + String.fromCharCode(421, 259, 349, 537), + encryptedUsername: "(test)", + encryptedPassword: "(test)", + guid: "(test)", + encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR, + timeCreated: Date.now(), + timeLastUsed: Date.now(), + timePasswordChanged: Date.now(), + timesUsed: 1, + }; + storeForSave.data.logins.push(rawLoginData); + + await storeForSave._save(); + + // Test the asynchronous initialization path. + let storeForLoad = new LoginStore(storeForSave.path); + await storeForLoad.load(); + + Assert.equal(storeForLoad.data.logins.length, 1); + Assert.deepEqual(storeForLoad.data.logins[0], rawLoginData); + + // Test the synchronous initialization path. + storeForLoad = new LoginStore(storeForSave.path); + storeForLoad.ensureDataReady(); + + Assert.equal(storeForLoad.data.logins.length, 1); + Assert.deepEqual(storeForLoad.data.logins[0], rawLoginData); +}); + +/** + * Checks that loading from a missing file results in empty arrays. + */ +add_task(async function test_load_empty() { + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + Assert.equal(false, await IOUtils.exists(store.path)); + + await store.load(); + + Assert.equal(false, await IOUtils.exists(store.path)); + + Assert.equal(store.data.logins.length, 0); +}); + +/** + * Checks that saving empty data still overwrites any existing file. + */ +add_task(async function test_save_empty() { + const store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + await store.load(); + await IOUtils.writeUTF8(store.path, "", { writeMode: "create" }); + await store._save(); + + Assert.ok(await IOUtils.exists(store.path)); +}); + +/** + * Loads data from a string in a predefined format. The purpose of this test is + * to verify that the JSON format used in previous versions can be loaded. + */ +add_task(async function test_load_string_predefined() { + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + let string = + '{"logins":[{' + + '"id":1,' + + '"hostname":"http://www.example.com",' + + '"httpRealm":null,' + + '"formSubmitURL":"http://www.example.com",' + + '"usernameField":"usernameField",' + + '"passwordField":"passwordField",' + + '"encryptedUsername":"(test)",' + + '"encryptedPassword":"(test)",' + + '"guid":"(test)",' + + '"encType":1,' + + '"timeCreated":1262304000000,' + + '"timeLastUsed":1262390400000,' + + '"timePasswordChanged":1262476800000,' + + '"timesUsed":1}],"disabledHosts":[' + + '"http://www.example.org"]}'; + + await IOUtils.writeUTF8(store.path, string, { + tmpPath: store.path + ".tmp", + }); + + await store.load(); + + Assert.equal(store.data.logins.length, 1); + Assert.deepEqual(store.data.logins[0], { + id: 1, + hostname: "http://www.example.com", + httpRealm: null, + formSubmitURL: "http://www.example.com", + usernameField: "usernameField", + passwordField: "passwordField", + encryptedUsername: "(test)", + encryptedPassword: "(test)", + guid: "(test)", + encType: Ci.nsILoginManagerCrypto.ENCTYPE_SDR, + timeCreated: 1262304000000, + timeLastUsed: 1262390400000, + timePasswordChanged: 1262476800000, + timesUsed: 1, + }); +}); + +/** + * Loads login data from a malformed JSON string. + */ +add_task(async function test_load_string_malformed() { + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + let string = '{"logins":[{"hostname":"http://www.example.com","id":1,'; + + await IOUtils.writeUTF8(store.path, string, { + tmpPath: store.path + ".tmp", + }); + + await store.load(); + + // A backup file should have been created. + Assert.ok(await IOUtils.exists(store.path + ".corrupt")); + await IOUtils.remove(store.path + ".corrupt"); + + // The store should be ready to accept new data. + Assert.equal(store.data.logins.length, 0); +}); + +/** + * Loads login data from a malformed JSON string, using the synchronous + * initialization path. + */ +add_task(async function test_load_string_malformed_sync() { + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + + let string = '{"logins":[{"hostname":"http://www.example.com","id":1,'; + + await IOUtils.writeUTF8(store.path, string, { + tmpPath: store.path + ".tmp", + }); + + store.ensureDataReady(); + + // A backup file should have been created. + Assert.ok(await IOUtils.exists(store.path + ".corrupt")); + await IOUtils.remove(store.path + ".corrupt"); + + // The store should be ready to accept new data. + Assert.equal(store.data.logins.length, 0); +}); + +/** + * Fix bad dates when loading login data + */ +add_task(async function test_load_bad_dates() { + let rawLoginData = { + encType: 1, + encryptedPassword: "(test)", + encryptedUsername: "(test)", + formSubmitURL: "https://www.example.com", + guid: "{2a97313f-873b-4048-9a3d-4f442b46c1e5}", + hostname: "https://www.example.com", + httpRealm: null, + id: 1, + passwordField: "pass", + timesUsed: 1, + usernameField: "email", + }; + let rawStoreData = { + dismissedBreachAlertsByLoginGUID: {}, + logins: [], + nextId: 2, + potentiallyVulnerablePasswords: [], + version: 2, + }; + + /** + * test that: + * - bogus (0 or out-of-range) date values in any of the date fields are replaced with the + * earliest time marked by the other date fields + * - bogus bogus (0 or out-of-range) date values in all date fields are replaced with the time of import + */ + let tests = [ + { + name: "Out-of-range time values", + savedProps: { + timePasswordChanged: MAX_DATE_MS + 1, + timeLastUsed: MAX_DATE_MS + 1, + timeCreated: MAX_DATE_MS + 1, + }, + expectedProps: { + timePasswordChanged: "now", + timeLastUsed: "now", + timeCreated: "now", + }, + }, + { + name: "All zero time values", + savedProps: { + timePasswordChanged: 0, + timeLastUsed: 0, + timeCreated: 0, + }, + expectedProps: { + timePasswordChanged: "now", + timeLastUsed: "now", + timeCreated: "now", + }, + }, + { + name: "Only timeCreated has value", + savedProps: { + timePasswordChanged: 0, + timeLastUsed: 0, + timeCreated: 946713600000, + }, + expectedProps: { + timePasswordChanged: 946713600000, + timeLastUsed: 946713600000, + timeCreated: 946713600000, + }, + }, + { + name: "timeCreated has 0 value", + savedProps: { + timePasswordChanged: 946713600000, + timeLastUsed: 946713600000, + timeCreated: 0, + }, + expectedProps: { + timePasswordChanged: 946713600000, + timeLastUsed: 946713600000, + timeCreated: 946713600000, + }, + }, + { + name: "timeCreated has out-of-range value", + savedProps: { + timePasswordChanged: 946713600000, + timeLastUsed: 946713600000, + timeCreated: MAX_DATE_MS + 1, + }, + expectedProps: { + timePasswordChanged: 946713600000, + timeLastUsed: 946713600000, + timeCreated: 946713600000, + }, + }, + { + name: "Use earliest time for missing value", + savedProps: { + timePasswordChanged: 0, + timeLastUsed: 946713600000, + timeCreated: 946540800000, + }, + expectedProps: { + timePasswordChanged: 946540800000, + timeLastUsed: 946713600000, + timeCreated: 946540800000, + }, + }, + ]; + + for (let testData of tests) { + let store = new LoginStore(getTempFile(TEST_STORE_FILE_NAME).path); + let string = JSON.stringify( + Object.assign({}, rawStoreData, { + logins: [Object.assign({}, rawLoginData, testData.savedProps)], + }) + ); + await IOUtils.writeUTF8(store.path, string, { + tmpPath: store.path + ".tmp", + }); + let now = Date.now(); + await store.load(); + + Assert.equal( + store.data.logins.length, + 1, + `${testData.name}: Expected a single login` + ); + + let login = store.data.logins[0]; + for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) { + if (testData.expectedProps[pname] === "now") { + Assert.ok( + login[pname] >= now, + `${testData.name}: Check ${pname} is at/near now` + ); + } else { + Assert.equal( + login[pname], + testData.expectedProps[pname], + `${testData.name}: Check expected ${pname}` + ); + } + } + Assert.equal(store.data.version, 3, "Check version was bumped"); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_notifications.js b/toolkit/components/passwordmgr/test/unit/test_notifications.js new file mode 100644 index 0000000000..d13ecdcab6 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_notifications.js @@ -0,0 +1,193 @@ +/** + * Tests notifications dispatched when modifying stored logins. + */ + +let expectedNotification; +let expectedData; + +let TestObserver = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + observe(subject, topic, data) { + Assert.equal(topic, "passwordmgr-storage-changed"); + Assert.equal(data, expectedNotification); + + switch (data) { + case "addLogin": + Assert.ok(subject instanceof Ci.nsILoginInfo); + Assert.ok(subject instanceof Ci.nsILoginMetaInfo); + Assert.ok(expectedData.equals(subject)); // nsILoginInfo.equals() + break; + case "modifyLogin": + Assert.ok(subject instanceof Ci.nsIArray); + Assert.equal(subject.length, 2); + let oldLogin = subject.queryElementAt(0, Ci.nsILoginInfo); + let newLogin = subject.queryElementAt(1, Ci.nsILoginInfo); + Assert.ok(expectedData[0].equals(oldLogin)); // nsILoginInfo.equals() + Assert.ok(expectedData[1].equals(newLogin)); + break; + case "removeLogin": + Assert.ok(subject instanceof Ci.nsILoginInfo); + Assert.ok(subject instanceof Ci.nsILoginMetaInfo); + Assert.ok(expectedData.equals(subject)); // nsILoginInfo.equals() + break; + case "removeAllLogins": + Assert.ok(subject instanceof Ci.nsIArray); + break; + case "hostSavingEnabled": + case "hostSavingDisabled": + Assert.ok(subject instanceof Ci.nsISupportsString); + Assert.equal(subject.data, expectedData); + break; + default: + do_throw("Unhandled notification: " + data + " / " + topic); + } + + expectedNotification = null; // ensure a duplicate is flagged as unexpected. + expectedData = null; + }, +}; + +add_task(async function test_notifications() { + let testnum = 0; + let testdesc = "Setup of nsLoginInfo test-users"; + + try { + let testuser1 = new LoginInfo( + "http://testhost1", + "", + null, + "dummydude", + "itsasecret", + "put_user_here", + "put_pw_here" + ); + + let testuser2 = new LoginInfo( + "http://testhost2", + "", + null, + "dummydude2", + "itsasecret2", + "put_user2_here", + "put_pw2_here" + ); + + Services.obs.addObserver(TestObserver, "passwordmgr-storage-changed"); + + /* ========== 1 ========== */ + testnum = 1; + testdesc = "Initial connection to storage module"; + + /* ========== 2 ========== */ + testnum++; + testdesc = "addLogin"; + + expectedNotification = "addLogin"; + expectedData = testuser1; + await Services.logins.addLoginAsync(testuser1); + LoginTestUtils.checkLogins([testuser1]); + Assert.equal(expectedNotification, null); // check that observer got a notification + + /* ========== 3 ========== */ + testnum++; + testdesc = "modifyLogin"; + + expectedNotification = "modifyLogin"; + expectedData = [testuser1, testuser2]; + Services.logins.modifyLogin(testuser1, testuser2); + Assert.equal(expectedNotification, null); + LoginTestUtils.checkLogins([testuser2]); + + /* ========== 4 ========== */ + testnum++; + testdesc = "removeLogin"; + + expectedNotification = "removeLogin"; + expectedData = testuser2; + Services.logins.removeLogin(testuser2); + Assert.equal(expectedNotification, null); + LoginTestUtils.checkLogins([]); + + /* ========== 5 ========== */ + testnum++; + testdesc = "removeAllLogins"; + + expectedNotification = "removeAllLogins"; + expectedData = null; + Services.logins.removeAllLogins(); + Assert.equal(expectedNotification, null); + LoginTestUtils.checkLogins([]); + + /* ========== 6 ========== */ + testnum++; + testdesc = "removeAllLogins (again)"; + + expectedNotification = "addLogin"; + expectedData = testuser1; + await Services.logins.addLoginAsync(testuser1); + + expectedNotification = "removeAllLogins"; + expectedData = null; + Services.logins.removeAllLogins(); + Assert.equal(expectedNotification, null); + LoginTestUtils.checkLogins([]); + + /* ========== 7 ========== */ + testnum++; + testdesc = "setLoginSavingEnabled / false"; + + expectedNotification = "hostSavingDisabled"; + expectedData = "http://site.com"; + Services.logins.setLoginSavingEnabled("http://site.com", false); + Assert.equal(expectedNotification, null); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + ["http://site.com"] + ); + + /* ========== 8 ========== */ + testnum++; + testdesc = "setLoginSavingEnabled / false (again)"; + + expectedNotification = "hostSavingDisabled"; + expectedData = "http://site.com"; + Services.logins.setLoginSavingEnabled("http://site.com", false); + Assert.equal(expectedNotification, null); + LoginTestUtils.assertDisabledHostsEqual( + Services.logins.getAllDisabledHosts(), + ["http://site.com"] + ); + + /* ========== 9 ========== */ + testnum++; + testdesc = "setLoginSavingEnabled / true"; + + expectedNotification = "hostSavingEnabled"; + expectedData = "http://site.com"; + Services.logins.setLoginSavingEnabled("http://site.com", true); + Assert.equal(expectedNotification, null); + LoginTestUtils.checkLogins([]); + + /* ========== 10 ========== */ + testnum++; + testdesc = "setLoginSavingEnabled / true (again)"; + + expectedNotification = "hostSavingEnabled"; + expectedData = "http://site.com"; + Services.logins.setLoginSavingEnabled("http://site.com", true); + Assert.equal(expectedNotification, null); + LoginTestUtils.checkLogins([]); + + Services.obs.removeObserver(TestObserver, "passwordmgr-storage-changed"); + + LoginTestUtils.clearData(); + } catch (e) { + throw new Error( + "FAILED in test #" + testnum + " -- " + testdesc + ": " + e + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_recipes_add.js b/toolkit/components/passwordmgr/test/unit/test_recipes_add.js new file mode 100644 index 0000000000..253556232e --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_recipes_add.js @@ -0,0 +1,288 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests adding and retrieving LoginRecipes in the parent process. + */ + +"use strict"; + +add_task(async function test_init() { + let parent = new LoginRecipesParent({ defaults: null }); + let initPromise1 = parent.initializationPromise; + let initPromise2 = parent.initializationPromise; + Assert.strictEqual( + initPromise1, + initPromise2, + "Check that the same promise is returned" + ); + + let recipesParent = await initPromise1; + Assert.ok( + recipesParent instanceof LoginRecipesParent, + "Check init return value" + ); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 0, + "Initially 0 recipes" + ); +}); + +add_task(async function test_get_missing_host() { + let recipesParent = await RecipeHelpers.initNewParent(); + let exampleRecipes = recipesParent.getRecipesForHost("example.invalid"); + Assert.strictEqual( + exampleRecipes.size, + 0, + "Check recipe count for example.invalid" + ); +}); + +add_task(async function test_add_get_simple_host() { + let recipesParent = await RecipeHelpers.initNewParent(); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 0, + "Initially 0 recipes" + ); + recipesParent.add({ + hosts: ["example.com"], + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 1, + "Check number of hosts after the addition" + ); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com"); + Assert.strictEqual( + exampleRecipes.size, + 1, + "Check recipe count for example.com" + ); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof recipe, "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present"); + Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host"); +}); + +add_task(async function test_add_get_non_standard_port_host() { + let recipesParent = await RecipeHelpers.initNewParent(); + recipesParent.add({ + hosts: ["example.com:8080"], + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 1, + "Check number of hosts after the addition" + ); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com:8080"); + Assert.strictEqual( + exampleRecipes.size, + 1, + "Check recipe count for example.com:8080" + ); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof recipe, "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present"); + Assert.strictEqual(recipe.hosts[0], "example.com:8080", "Check the one host"); +}); + +add_task(async function test_add_multiple_hosts() { + let recipesParent = await RecipeHelpers.initNewParent(); + recipesParent.add({ + hosts: ["example.com", "foo.invalid"], + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 2, + "Check number of hosts after the addition" + ); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com"); + Assert.strictEqual( + exampleRecipes.size, + 1, + "Check recipe count for example.com" + ); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof recipe, "object", "Check recipe type"); + Assert.strictEqual( + recipe.hosts.length, + 2, + "Check that two hosts are present" + ); + Assert.strictEqual(recipe.hosts[0], "example.com", "Check the first host"); + Assert.strictEqual(recipe.hosts[1], "foo.invalid", "Check the second host"); + + let fooRecipes = recipesParent.getRecipesForHost("foo.invalid"); + Assert.strictEqual(fooRecipes.size, 1, "Check recipe count for foo.invalid"); + let fooRecipe = [...fooRecipes][0]; + Assert.strictEqual(fooRecipe, recipe, "Check that the recipe is shared"); + Assert.strictEqual(typeof fooRecipe, "object", "Check recipe type"); + Assert.strictEqual( + fooRecipe.hosts.length, + 2, + "Check that two hosts are present" + ); + Assert.strictEqual(fooRecipe.hosts[0], "example.com", "Check the first host"); + Assert.strictEqual( + fooRecipe.hosts[1], + "foo.invalid", + "Check the second host" + ); +}); + +add_task(async function test_add_pathRegex() { + let recipesParent = await RecipeHelpers.initNewParent(); + recipesParent.add({ + hosts: ["example.com"], + pathRegex: /^\/mypath\//, + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 1, + "Check number of hosts after the addition" + ); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com"); + Assert.strictEqual( + exampleRecipes.size, + 1, + "Check recipe count for example.com" + ); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof recipe, "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present"); + Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host"); + Assert.strictEqual( + recipe.pathRegex.toString(), + "/^\\/mypath\\//", + "Check the pathRegex" + ); +}); + +add_task(async function test_add_selectors() { + let recipesParent = await RecipeHelpers.initNewParent(); + recipesParent.add({ + hosts: ["example.com"], + usernameSelector: "#my-username", + passwordSelector: "#my-form > input.password", + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 1, + "Check number of hosts after the addition" + ); + + let exampleRecipes = recipesParent.getRecipesForHost("example.com"); + Assert.strictEqual( + exampleRecipes.size, + 1, + "Check recipe count for example.com" + ); + let recipe = [...exampleRecipes][0]; + Assert.strictEqual(typeof recipe, "object", "Check recipe type"); + Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present"); + Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host"); + Assert.strictEqual( + recipe.usernameSelector, + "#my-username", + "Check the usernameSelector" + ); + Assert.strictEqual( + recipe.passwordSelector, + "#my-form > input.password", + "Check the passwordSelector" + ); +}); + +/* Begin checking errors with add */ + +add_task(async function test_add_missing_prop() { + let recipesParent = await RecipeHelpers.initNewParent(); + Assert.throws( + () => recipesParent.add({}), + /required/, + "Some properties are required" + ); +}); + +add_task(async function test_add_unknown_prop() { + let recipesParent = await RecipeHelpers.initNewParent(); + Assert.throws( + () => + recipesParent.add({ + unknownProp: true, + }), + /supported/, + "Unknown properties should cause an error to help with typos" + ); +}); + +add_task(async function test_add_invalid_hosts() { + let recipesParent = await RecipeHelpers.initNewParent(); + Assert.throws( + () => + recipesParent.add({ + hosts: 404, + }), + /array/, + "hosts should be an array" + ); +}); + +add_task(async function test_add_empty_host_array() { + let recipesParent = await RecipeHelpers.initNewParent(); + Assert.throws( + () => + recipesParent.add({ + hosts: [], + }), + /array/, + "hosts should be a non-empty array" + ); +}); + +add_task(async function test_add_pathRegex_non_regexp() { + let recipesParent = await RecipeHelpers.initNewParent(); + Assert.throws( + () => + recipesParent.add({ + hosts: ["example.com"], + pathRegex: "foo", + }), + /regular expression/, + "pathRegex should be a RegExp" + ); +}); + +add_task(async function test_add_usernameSelector_non_string() { + let recipesParent = await RecipeHelpers.initNewParent(); + Assert.throws( + () => + recipesParent.add({ + hosts: ["example.com"], + usernameSelector: 404, + }), + /string/, + "usernameSelector should be a string" + ); +}); + +add_task(async function test_add_passwordSelector_non_string() { + let recipesParent = await RecipeHelpers.initNewParent(); + Assert.throws( + () => + recipesParent.add({ + hosts: ["example.com"], + passwordSelector: 404, + }), + /string/, + "passwordSelector should be a string" + ); +}); + +/* End checking errors with add */ diff --git a/toolkit/components/passwordmgr/test/unit/test_recipes_content.js b/toolkit/components/passwordmgr/test/unit/test_recipes_content.js new file mode 100644 index 0000000000..673bb50851 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_recipes_content.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test filtering recipes in LoginRecipesContent. + */ + +"use strict"; + +add_task(async function test_getFieldOverrides() { + let recipes = new Set([ + { + // path doesn't match but otherwise good + hosts: ["example.com:8080"], + passwordSelector: "#password", + pathRegex: /^\/$/, + usernameSelector: ".username", + }, + { + // match with no field overrides + hosts: ["example.com:8080"], + }, + { + // best match (field selectors + path match) + description: "best match", + hosts: ["a.invalid", "example.com:8080", "other.invalid"], + passwordSelector: "#password", + pathRegex: /^\/first\/second\/$/, + usernameSelector: ".username", + }, + ]); + + let form = MockDocument.createTestDocument( + "http://localhost:8080/first/second/", + "
" + ).forms[0]; + let override = LoginRecipesContent.getFieldOverrides(recipes, form); + Assert.strictEqual( + override.description, + "best match", + "Check the best field override recipe was returned" + ); + Assert.strictEqual( + override.usernameSelector, + ".username", + "Check usernameSelector" + ); + Assert.strictEqual( + override.passwordSelector, + "#password", + "Check passwordSelector" + ); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js b/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js new file mode 100644 index 0000000000..7d16e205e9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_remote_recipes.js @@ -0,0 +1,162 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests retrieving remote LoginRecipes in the parent process. + * See https://firefox-source-docs.mozilla.org/services/settings/#unit-tests for explanation of db.importChanges({}, Date.now()); + */ + +"use strict"; + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const REMOTE_SETTINGS_COLLECTION = "password-recipes"; + +add_task(async function test_init_remote_recipe() { + const db = RemoteSettings(REMOTE_SETTINGS_COLLECTION).db; + await db.clear(); + const record1 = { + id: "some-fake-ID", + hosts: ["www.testDomain.com"], + description: "Some description here", + usernameSelector: "#username", + }; + await db.importChanges({}, Date.now(), [record1], { clear: true }); + let parent = new LoginRecipesParent({ defaults: true }); + + let recipesParent = await parent.initializationPromise; + Assert.ok( + recipesParent instanceof LoginRecipesParent, + "Check initialization promise value which should be an instance of LoginRecipesParent" + ); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 1, + "Initially 1 recipe based on our test record" + ); + let rsClient = recipesParent._rsClient; + + recipesParent.reset(); + await recipesParent.initializationPromise; + Assert.ok( + recipesParent instanceof LoginRecipesParent, + "Ensure that the instance of LoginRecipesParent has not changed after resetting" + ); + Assert.strictEqual( + rsClient, + recipesParent._rsClient, + "Resetting recipes should not modify the rs client" + ); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 1, + "Initially 1 recipe based on our test record" + ); + await db.clear(); + await db.importChanges({}, 42); +}); + +add_task(async function test_add_recipe_sync() { + const db = RemoteSettings(REMOTE_SETTINGS_COLLECTION).db; + const record1 = { + id: "some-fake-ID", + hosts: ["www.testDomain.com"], + description: "Some description here", + usernameSelector: "#username", + }; + await db.importChanges({}, Date.now(), [record1], { clear: true }); + let parent = new LoginRecipesParent({ defaults: true }); + let recipesParent = await parent.initializationPromise; + + const record2 = { + id: "some-fake-ID-2", + hosts: ["www.testDomain2.com"], + description: "Some description here. Wow it changed!", + usernameSelector: "#username", + }; + const payload = { + current: [record1, record2], + created: [record2], + updated: [], + deleted: [], + }; + await RemoteSettings(REMOTE_SETTINGS_COLLECTION).emit("sync", { + data: payload, + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 2, + "New recipe from sync event added successfully" + ); + await db.clear(); + await db.importChanges({}, 42); +}); + +add_task(async function test_remove_recipe_sync() { + const db = RemoteSettings(REMOTE_SETTINGS_COLLECTION).db; + const record1 = { + id: "some-fake-ID", + hosts: ["www.testDomain.com"], + description: "Some description here", + usernameSelector: "#username", + }; + await db.importChanges({}, Date.now(), [record1], { clear: true }); + let parent = new LoginRecipesParent({ defaults: true }); + let recipesParent = await parent.initializationPromise; + + const deletePayload = { + current: [], + created: [], + updated: [], + deleted: [record1], + }; + await RemoteSettings(REMOTE_SETTINGS_COLLECTION).emit("sync", { + data: deletePayload, + }); + Assert.strictEqual( + recipesParent._recipesByHost.size, + 0, + "Recipes successfully deleted on sync event" + ); + await db.clear(); +}); + +add_task(async function test_malformed_recipes_in_db() { + const db = RemoteSettings(REMOTE_SETTINGS_COLLECTION).db; + const malformedRecord = { + id: "some-ID", + hosts: ["www.testDomain.com"], + description: "Some description here", + usernameSelector: "#username", + fieldThatDoesNotExist: "value", + }; + await db.importChanges({}, Date.now(), [malformedRecord], { clear: true }); + let parent = new LoginRecipesParent({ defaults: true }); + try { + await parent.initializationPromise; + } catch (e) { + Assert.ok( + e == "There were 1 recipe error(s)", + "It should throw an error because of field that does not match the schema" + ); + } + + await db.clear(); + const missingHostsRecord = { + id: "some-ID", + description: "Some description here", + usernameSelector: "#username", + }; + await db.importChanges({}, Date.now(), [missingHostsRecord], { clear: true }); + parent = new LoginRecipesParent({ defaults: true }); + try { + await parent.initializationPromise; + } catch (e) { + Assert.ok( + e == "There were 1 recipe error(s)", + "It should throw an error because of missing hosts field" + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js b/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js new file mode 100644 index 0000000000..9428d3f897 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js @@ -0,0 +1,239 @@ +/** + * Test Services.logins.searchLogins with the `schemeUpgrades` property. + */ + +const HTTP3_ORIGIN = "http://www3.example.com"; +const HTTPS_ORIGIN = "https://www.example.com"; +const HTTP_ORIGIN = "http://www.example.com"; + +/** + * Returns a list of new nsILoginInfo objects that are a subset of the test + * data, built to match the specified query. + * + * @param {Object} aQuery + * Each property and value of this object restricts the search to those + * entries from the test data that match the property exactly. + */ +function buildExpectedLogins(aQuery) { + return TestData.loginList().filter(entry => + Object.keys(aQuery).every(name => { + if (name == "schemeUpgrades") { + return true; + } + if (["origin", "formActionOrigin"].includes(name)) { + return LoginHelper.isOriginMatching(entry[name], aQuery[name], { + schemeUpgrades: aQuery.schemeUpgrades, + }); + } + return entry[name] === aQuery[name]; + }) + ); +} + +/** + * Tests the searchLogins function. + * + * @param {Object} aQuery + * Each property and value of this object is translated to an entry in + * the nsIPropertyBag parameter of searchLogins. + * @param {Number} aExpectedCount + * Number of logins from the test data that should be found. The actual + * list of logins is obtained using the buildExpectedLogins helper, and + * this value is just used to verify that modifications to the test data + * don't make the current test meaningless. + */ +function checkSearch(aQuery, aExpectedCount) { + info("Testing searchLogins for " + JSON.stringify(aQuery)); + + let expectedLogins = buildExpectedLogins(aQuery); + Assert.equal(expectedLogins.length, aExpectedCount); + + let logins = Services.logins.searchLogins(newPropertyBag(aQuery)); + LoginTestUtils.assertLoginListsEqual(logins, expectedLogins); +} + +/** + * Prepare data for the following tests. + */ +add_setup(async () => { + await Services.logins.addLogins(TestData.loginList()); +}); + +/** + * Tests searchLogins with the `schemeUpgrades` property + */ +add_task(function test_search_schemeUpgrades_origin() { + // Origin-only + checkSearch( + { + origin: HTTPS_ORIGIN, + }, + 1 + ); + checkSearch( + { + origin: HTTPS_ORIGIN, + schemeUpgrades: false, + }, + 1 + ); + checkSearch( + { + origin: HTTPS_ORIGIN, + schemeUpgrades: undefined, + }, + 1 + ); + checkSearch( + { + origin: HTTPS_ORIGIN, + schemeUpgrades: true, + }, + 2 + ); +}); + +/** + * Same as above but replacing origin with formActionOrigin. + */ +add_task(function test_search_schemeUpgrades_formActionOrigin() { + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + }, + 2 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + schemeUpgrades: false, + }, + 2 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + schemeUpgrades: undefined, + }, + 2 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + schemeUpgrades: true, + }, + 4 + ); +}); + +add_task(function test_search_schemeUpgrades_origin_formActionOrigin() { + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTPS_ORIGIN, + }, + 1 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTPS_ORIGIN, + schemeUpgrades: false, + }, + 1 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTPS_ORIGIN, + schemeUpgrades: undefined, + }, + 1 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTPS_ORIGIN, + schemeUpgrades: true, + }, + 2 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTPS_ORIGIN, + schemeUpgrades: true, + usernameField: "form_field_username", + }, + 2 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTPS_ORIGIN, + passwordField: "form_field_password", + schemeUpgrades: true, + usernameField: "form_field_username", + }, + 2 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTPS_ORIGIN, + httpRealm: null, + passwordField: "form_field_password", + schemeUpgrades: true, + usernameField: "form_field_username", + }, + 2 + ); +}); + +/** + * HTTP submitting to HTTPS + */ +add_task(function test_http_to_https() { + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTP3_ORIGIN, + httpRealm: null, + schemeUpgrades: false, + }, + 1 + ); + checkSearch( + { + formActionOrigin: HTTPS_ORIGIN, + origin: HTTP3_ORIGIN, + httpRealm: null, + schemeUpgrades: true, + }, + 2 + ); +}); + +/** + * schemeUpgrades shouldn't cause downgrades + */ +add_task(function test_search_schemeUpgrades_downgrade() { + checkSearch( + { + formActionOrigin: HTTP_ORIGIN, + origin: HTTP_ORIGIN, + }, + 1 + ); + info( + "The same number should be found with schemeUpgrades since we're searching for HTTP" + ); + checkSearch( + { + formActionOrigin: HTTP_ORIGIN, + origin: HTTP_ORIGIN, + schemeUpgrades: true, + }, + 1 + ); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js b/toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js new file mode 100644 index 0000000000..86a9a08ac3 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js @@ -0,0 +1,82 @@ +/** + * Test LoginHelper.shadowHTTPLogins + */ + +"use strict"; + +const DOMAIN1_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({}); +const DOMAIN1_HTTP_TO_HTTP_U2_P1 = TestData.formLogin({ + username: "user2", +}); +const DOMAIN1_HTTPS_TO_HTTPS_U1_P1 = TestData.formLogin({ + origin: "https://www3.example.com", + formActionOrigin: "https://login.example.com", +}); +const DOMAIN1_HTTPS_TO_HTTPS_U1_P2 = TestData.formLogin({ + origin: "https://www3.example.com", + formActionOrigin: "https://login.example.com", + password: "password two", +}); +const DOMAIN1_HTTP_TO_HTTP_U1_P2 = TestData.formLogin({ + password: "password two", +}); +const DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT = TestData.formLogin({ + origin: "http://www3.example.com:8080", +}); +const DOMAIN2_HTTP_TO_HTTP_U1_P1 = TestData.formLogin({ + origin: "http://different.example.com", +}); + +add_task(function test_shadowHTTPLogins() { + let testcases = [ + { + description: "same hostPort, same username, different scheme", + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P1], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1], + }, + { + description: "different passwords, different scheme", + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U1_P2], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1], + }, + { + description: "both https, same username, different password", + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTPS_TO_HTTPS_U1_P2], + }, + { + description: "same origin, different port, different scheme", + logins: [ + DOMAIN1_HTTPS_TO_HTTPS_U1_P1, + DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT, + ], + expected: [ + DOMAIN1_HTTPS_TO_HTTPS_U1_P1, + DOMAIN1_HTTP_TO_HTTP_U1_P1_DIFFERENT_PORT, + ], + }, + { + description: "different origin, different scheme", + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTP_TO_HTTP_U1_P1], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN2_HTTP_TO_HTTP_U1_P1], + }, + { + description: "different username, different scheme", + logins: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1], + expected: [DOMAIN1_HTTPS_TO_HTTPS_U1_P1, DOMAIN1_HTTP_TO_HTTP_U2_P1], + }, + ]; + + for (let tc of testcases) { + info(tc.description); + let actual = LoginHelper.shadowHTTPLogins(tc.logins); + Assert.strictEqual( + actual.length, + tc.expected.length, + `Check result length` + ); + for (let [i, login] of tc.expected.entries()) { + Assert.strictEqual(actual[i], login, `Check index ${i}`); + } + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_storage.js b/toolkit/components/passwordmgr/test/unit/test_storage.js new file mode 100644 index 0000000000..97c54586f1 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_storage.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the default nsILoginManagerStorage module attached to the Login + * Manager service is able to save and reload nsILoginInfo properties correctly, + * even when they include special characters. + */ + +"use strict"; + +// Globals + +async function reloadAndCheckLoginsGen(aExpectedLogins) { + await LoginTestUtils.reloadData(); + LoginTestUtils.checkLogins(aExpectedLogins); + LoginTestUtils.clearData(); +} + +// Tests + +/** + * Tests addLogin with valid non-ASCII characters. + */ +add_task(async function test_storage_addLogin_nonascii() { + let origin = "http://" + String.fromCharCode(355) + ".example.com"; + + // Store the strings "user" and "pass" using similarly looking glyphs. + let loginInfo = TestData.formLogin({ + origin, + formActionOrigin: origin, + username: String.fromCharCode(533, 537, 7570, 345), + password: String.fromCharCode(421, 259, 349, 537), + usernameField: "field_" + String.fromCharCode(533, 537, 7570, 345), + passwordField: "field_" + String.fromCharCode(421, 259, 349, 537), + }); + await Services.logins.addLoginAsync(loginInfo); + await reloadAndCheckLoginsGen([loginInfo]); + + // Store the string "test" using similarly looking glyphs. + loginInfo = TestData.authLogin({ + httpRealm: String.fromCharCode(355, 277, 349, 357), + }); + await Services.logins.addLoginAsync(loginInfo); + await reloadAndCheckLoginsGen([loginInfo]); +}); + +/** + * Tests addLogin with newline characters in the username and password. + */ +add_task(async function test_storage_addLogin_newlines() { + let loginInfo = TestData.formLogin({ + username: "user\r\nname", + password: "password\r\n", + }); + await Services.logins.addLoginAsync(loginInfo); + await reloadAndCheckLoginsGen([loginInfo]); +}); + +/** + * Tests addLogin with a single dot in fields where it is allowed. + * + * These tests exist to verify the legacy "signons.txt" storage format. + */ +add_task(async function test_storage_addLogin_dot() { + let loginInfo = TestData.formLogin({ origin: ".", passwordField: "." }); + await Services.logins.addLoginAsync(loginInfo); + await reloadAndCheckLoginsGen([loginInfo]); + + loginInfo = TestData.authLogin({ httpRealm: "." }); + await Services.logins.addLoginAsync(loginInfo); + await reloadAndCheckLoginsGen([loginInfo]); +}); + +/** + * Tests addLogin with parentheses in origins. + * + * These tests exist to verify the legacy "signons.txt" storage format. + */ +add_task(async function test_storage_addLogin_parentheses() { + let loginList = [ + TestData.authLogin({ httpRealm: "(realm" }), + TestData.authLogin({ httpRealm: "realm)" }), + TestData.authLogin({ httpRealm: "(realm)" }), + TestData.authLogin({ httpRealm: ")realm(" }), + TestData.authLogin({ origin: "http://parens(.example.com" }), + TestData.authLogin({ origin: "http://parens).example.com" }), + TestData.authLogin({ origin: "http://parens(example).example.com" }), + TestData.authLogin({ origin: "http://parens)example(.example.com" }), + ]; + await Services.logins.addLogins(loginList); + await reloadAndCheckLoginsGen(loginList); +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_telemetry.js b/toolkit/components/passwordmgr/test/unit/test_telemetry.js new file mode 100644 index 0000000000..56fe6233d9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_telemetry.js @@ -0,0 +1,201 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the statistics and other counters reported through telemetry. + */ + +"use strict"; + +// Globals + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +// To prevent intermittent failures when the test is executed at a time that is +// very close to a day boundary, we make it deterministic by using a static +// reference date for all the time-based statistics. +const gReferenceTimeMs = new Date("2000-01-01T00:00:00").getTime(); + +// Returns a milliseconds value to use with nsILoginMetaInfo properties, falling +// approximately in the middle of the specified number of days before the +// reference time, where zero days indicates a time within the past 24 hours. +const daysBeforeMs = days => gReferenceTimeMs - (days + 0.5) * MS_PER_DAY; + +/** + * Contains metadata that will be attached to test logins in order to verify + * that the statistics collection is working properly. Most properties of the + * logins are initialized to the default test values already. + * + * If you update this data or any of the telemetry histograms it checks, you'll + * probably need to update the expected statistics in the test below. + */ +const StatisticsTestData = [ + { + timeLastUsed: daysBeforeMs(0), + }, + { + timeLastUsed: daysBeforeMs(1), + }, + { + timeLastUsed: daysBeforeMs(7), + formActionOrigin: null, + httpRealm: "The HTTP Realm", + }, + { + username: "", + timeLastUsed: daysBeforeMs(7), + }, + { + username: "", + timeLastUsed: daysBeforeMs(30), + }, + { + username: "", + timeLastUsed: daysBeforeMs(31), + }, + { + timeLastUsed: daysBeforeMs(365), + }, + { + username: "", + timeLastUsed: daysBeforeMs(366), + }, + { + // If the login was saved in the future, it is ignored for statistiscs. + timeLastUsed: daysBeforeMs(-1), + }, + { + timeLastUsed: daysBeforeMs(1000), + }, +]; + +/** + * Triggers the collection of those statistics that are not accumulated each + * time an action is taken, but are a static snapshot of the current state. + */ +async function triggerStatisticsCollection() { + Services.obs.notifyObservers(null, "gather-telemetry", "" + gReferenceTimeMs); + await TestUtils.topicObserved("passwordmgr-gather-telemetry-complete"); +} + +/** + * Tests the telemetry histogram with the given ID contains only the specified + * non-zero ranges, expressed in the format { range1: value1, range2: value2 }. + */ +function testHistogram(histogramId, expectedNonZeroRanges) { + let snapshot = Services.telemetry.getHistogramById(histogramId).snapshot(); + + // Compute the actual ranges in the format { range1: value1, range2: value2 }. + let actualNonZeroRanges = {}; + for (let [range, value] of Object.entries(snapshot.values)) { + if (value > 0) { + actualNonZeroRanges[range] = value; + } + } + + // These are stringified to visualize the differences between the values. + info("Testing histogram: " + histogramId); + Assert.equal( + JSON.stringify(actualNonZeroRanges), + JSON.stringify(expectedNonZeroRanges) + ); +} + +// Tests + +/** + * Enable local telemetry recording for the duration of the tests, and prepare + * the test data that will be used by the following tests. + */ +add_setup(async () => { + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + registerCleanupFunction(function () { + Services.telemetry.canRecordExtended = oldCanRecord; + }); + + let uniqueNumber = 1; + let logins = []; + for (let loginModifications of StatisticsTestData) { + loginModifications.origin = `http://${uniqueNumber++}.example.com`; + if (typeof loginModifications.httpRealm != "undefined") { + logins.push(TestData.authLogin(loginModifications)); + } else { + logins.push(TestData.formLogin(loginModifications)); + } + } + await Services.logins.addLogins(logins); +}); + +/** + * Tests the collection of statistics related to login metadata. + */ +add_task(async function test_logins_statistics() { + // Repeat the operation twice to test that histograms are not accumulated. + for (let pass of [1, 2]) { + info(`pass ${pass}`); + await triggerStatisticsCollection(); + + // Should record 1 in the bucket corresponding to the number of passwords. + testHistogram("PWMGR_NUM_SAVED_PASSWORDS", { 10: 1 }); + + // Should record 1 in the bucket corresponding to the number of passwords. + testHistogram("PWMGR_NUM_HTTPAUTH_PASSWORDS", { 1: 1 }); + + // For each saved login, should record 1 in the bucket corresponding to the + // age in days since the login was last used. + testHistogram("PWMGR_LOGIN_LAST_USED_DAYS", { + 0: 1, + 1: 1, + 7: 2, + 29: 2, + 356: 2, + 750: 1, + }); + + // Should record the number of logins without a username in bucket 0, and + // the number of logins with a username in bucket 1. + testHistogram("PWMGR_USERNAME_PRESENT", { 0: 4, 1: 6 }); + } +}); + +/** + * Tests the collection of statistics related to hosts for which passowrd saving + * has been explicitly disabled. + */ +add_task(async function test_disabledHosts_statistics() { + // Should record 1 in the bucket corresponding to the number of sites for + // which password saving is disabled. + Services.logins.setLoginSavingEnabled("http://www.example.com", false); + await triggerStatisticsCollection(); + testHistogram("PWMGR_BLOCKLIST_NUM_SITES", { 1: 1 }); + + Services.logins.setLoginSavingEnabled("http://www.example.com", true); + await triggerStatisticsCollection(); + testHistogram("PWMGR_BLOCKLIST_NUM_SITES", { 0: 1 }); +}); + +/** + * Tests the collection of statistics related to general settings. + */ +add_task(async function test_settings_statistics() { + let oldRememberSignons = Services.prefs.getBoolPref("signon.rememberSignons"); + registerCleanupFunction(function () { + Services.prefs.setBoolPref("signon.rememberSignons", oldRememberSignons); + }); + + // Repeat the operation twice per value to test that histograms are reset. + for (let remember of [false, true, false, true]) { + // This change should be observed immediately by the login service. + Services.prefs.setBoolPref("signon.rememberSignons", remember); + + await triggerStatisticsCollection(); + + // Should record 1 in either bucket 0 or bucket 1 based on the preference. + testHistogram("PWMGR_SAVING_ENABLED", remember ? { 1: 1 } : { 0: 1 }); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js b/toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js new file mode 100644 index 0000000000..b3250b9676 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async () => { + await Services.logins.initializationPromise; +}); + +add_task(async function test_vulnerable_password_methods() { + const storageJSON = Services.logins.wrappedJSObject._storage.wrappedJSObject; + + const logins = TestData.loginList(); + Assert.greater(logins.length, 0, "Initial logins length should be > 0."); + + for (const loginInfo of logins) { + await Services.logins.addLoginAsync(loginInfo); + Assert.ok( + !storageJSON.isPotentiallyVulnerablePassword(loginInfo), + "No logins should be vulnerable until addVulnerablePasswords is called." + ); + } + + const vulnerableLogin = logins.shift(); + storageJSON.addPotentiallyVulnerablePassword(vulnerableLogin); + + Assert.ok( + storageJSON.isPotentiallyVulnerablePassword(vulnerableLogin), + "Login should be vulnerable after calling addVulnerablePassword." + ); + for (const loginInfo of logins) { + Assert.ok( + !storageJSON.isPotentiallyVulnerablePassword(loginInfo), + "No other logins should be vulnerable when addVulnerablePassword is called" + + " with a single argument" + ); + } + + storageJSON.clearAllPotentiallyVulnerablePasswords(); + + for (const loginInfo of logins) { + Assert.ok( + !storageJSON.isPotentiallyVulnerablePassword(loginInfo), + "No logins should be vulnerable when clearAllPotentiallyVulnerablePasswords is called." + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/xpcshell.ini b/toolkit/components/passwordmgr/test/unit/xpcshell.ini new file mode 100644 index 0000000000..e576855b85 --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini @@ -0,0 +1,74 @@ +[DEFAULT] +head = head.js +skip-if = + os == "android" || toolkit == "android" # Not supported on GV because we can't add/remove from storage. +support-files = data/** + +# Test logins.json file access, not applicable to Android. +[test_module_LoginStore.js] +skip-if = os == "android" +[test_loginsBackup.js] +skip-if = os == "android" + +# The following tests apply to any storage back-end that supports add/modify/remove. +[test_context_menu.js] +skip-if = os == "android" # The context menu isn't used on Android. +# LoginManagerContextMenu is only included for MOZ_BUILD_APP == 'browser'. +run-if = buildapp == "browser" +[test_dedupeLogins.js] +[test_disabled_hosts.js] +[test_displayOrigin.js] +[test_doLoginsMatch.js] +[test_findRelatedRealms.js] +[test_getFormFields.js] +[test_getPasswordFields.js] +[test_getPasswordOrigin.js] +[test_getUserNameAndPasswordFields.js] +[test_getUsernameFieldFromUsernameOnlyForm.js] +[test_isInferredLoginForm.js] +[test_isInferredUsernameField.js] +[test_isOriginMatching.js] +[test_isProbablyANewPasswordField.js] +[test_isUsernameFieldType.js] +[test_legacy_empty_formActionOrigin.js] +[test_LoginManagerParent_doAutocompleteSearch.js] +skip-if = os == "android" # Password generation not packaged/used on Android +[test_LoginManagerParent_getGeneratedPassword.js] +skip-if = os == "android" # Password generation not packaged/used on Android +[test_LoginManagerParent_onPasswordEditedOrGenerated.js] +skip-if = os == "android" # Password generation not packaged/used on Android +[test_LoginManagerParent_searchAndDedupeLogins.js] +skip-if = os == "android" # schemeUpgrades aren't supported +[test_LoginManagerPrompter_getUsernameSuggestions.js] +skip-if = os == "android" # Tests desktop's prompter +[test_legacy_validation.js] +[test_login_autocomplete_result.js] +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_LoginCSVImport.js] +[test_CSVParser.js] +[test_module_LoginExport.js] +skip-if = os == "android" # there is no export for android +[test_module_LoginManager.js] +[test_notifications.js] +[test_OSCrypto_win.js] +skip-if = os != "win" +[test_PasswordGenerator.js] +skip-if = os == "android" # Not packaged/used on Android +[test_PasswordRulesManager_generatePassword.js] +[test_recipes_add.js] +[test_recipes_content.js] +[test_remote_recipes.js] +skip-if = os == "android" +[test_search_schemeUpgrades.js] +[test_shadowHTTPLogins.js] +[test_storage.js] +[test_telemetry.js] +[test_vulnerable_passwords.js] +skip-if = os == "android" # Not implemented for storage-mozStorage -- cgit v1.2.3