summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/passwordmgr
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/passwordmgr')
-rw-r--r--toolkit/components/passwordmgr/CSV.sys.mjs122
-rw-r--r--toolkit/components/passwordmgr/InsecurePasswordUtils.sys.mjs214
-rw-r--r--toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs773
-rw-r--r--toolkit/components/passwordmgr/LoginCSVImport.sys.mjs199
-rw-r--r--toolkit/components/passwordmgr/LoginExport.sys.mjs76
-rw-r--r--toolkit/components/passwordmgr/LoginFormFactory.sys.mjs148
-rw-r--r--toolkit/components/passwordmgr/LoginHelper.sys.mjs1883
-rw-r--r--toolkit/components/passwordmgr/LoginInfo.sys.mjs144
-rw-r--r--toolkit/components/passwordmgr/LoginManager.shared.mjs44
-rw-r--r--toolkit/components/passwordmgr/LoginManager.sys.mjs618
-rw-r--r--toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs1124
-rw-r--r--toolkit/components/passwordmgr/LoginManagerChild.sys.mjs3178
-rw-r--r--toolkit/components/passwordmgr/LoginManagerContextMenu.sys.mjs238
-rw-r--r--toolkit/components/passwordmgr/LoginManagerParent.sys.mjs1553
-rw-r--r--toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs1121
-rw-r--r--toolkit/components/passwordmgr/LoginManagerTelemetry.sys.mjs54
-rw-r--r--toolkit/components/passwordmgr/LoginRecipes.sys.mjs381
-rw-r--r--toolkit/components/passwordmgr/LoginRelatedRealms.sys.mjs108
-rw-r--r--toolkit/components/passwordmgr/LoginStore.sys.mjs181
-rw-r--r--toolkit/components/passwordmgr/NewPasswordModel.sys.mjs681
-rw-r--r--toolkit/components/passwordmgr/OSCrypto_win.sys.mjs284
-rw-r--r--toolkit/components/passwordmgr/PasswordGenerator.sys.mjs229
-rw-r--r--toolkit/components/passwordmgr/PasswordRulesManager.sys.mjs128
-rw-r--r--toolkit/components/passwordmgr/PasswordRulesParser.sys.mjs695
-rw-r--r--toolkit/components/passwordmgr/components.conf65
-rw-r--r--toolkit/components/passwordmgr/crypto-SDR.sys.mjs307
-rw-r--r--toolkit/components/passwordmgr/jar.mn12
-rw-r--r--toolkit/components/passwordmgr/metrics.yaml41
-rw-r--r--toolkit/components/passwordmgr/moz.build79
-rw-r--r--toolkit/components/passwordmgr/nsILoginAutoCompleteSearch.idl31
-rw-r--r--toolkit/components/passwordmgr/nsILoginInfo.idl160
-rw-r--r--toolkit/components/passwordmgr/nsILoginManager.idl326
-rw-r--r--toolkit/components/passwordmgr/nsILoginManagerAuthPrompter.idl44
-rw-r--r--toolkit/components/passwordmgr/nsILoginManagerCrypto.idl89
-rw-r--r--toolkit/components/passwordmgr/nsILoginManagerPrompter.idl103
-rw-r--r--toolkit/components/passwordmgr/nsILoginMetaInfo.idl54
-rw-r--r--toolkit/components/passwordmgr/nsIPromptInstance.idl17
-rw-r--r--toolkit/components/passwordmgr/storage-desktop.sys.mjs20
-rw-r--r--toolkit/components/passwordmgr/storage-geckoview.sys.mjs245
-rw-r--r--toolkit/components/passwordmgr/storage-json.sys.mjs1056
-rw-r--r--toolkit/components/passwordmgr/test/LoginTestUtils.sys.mjs662
-rw-r--r--toolkit/components/passwordmgr/test/authenticate.sjs229
-rw-r--r--toolkit/components/passwordmgr/test/blank.html8
-rw-r--r--toolkit/components/passwordmgr/test/browser/.eslintrc.js7
-rw-r--r--toolkit/components/passwordmgr/test/browser/authenticate.sjs84
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser.toml248
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js152
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPossibleUsername.js254
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js101
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autocomplete_autofocus_with_frame.js48
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autocomplete_disabled_readonly_passwordField.js138
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autocomplete_footer.js125
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autocomplete_generated_password_private_window.js111
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autocomplete_import.js258
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js44
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autocomplete_primary_password.js121
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autofill_hidden_document.js205
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autofill_http.js135
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_autofill_track_filled_logins.js111
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_basicAuth_multiTab.js158
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_basicAuth_rateLimit.js146
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_basicAuth_switchTab.js34
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_context_menu.js678
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js120
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js482
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js223
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_crossOriginSubmissionUsesCorrectOrigin.js53
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_deleteLoginsBackup.js282
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_autocomplete_values.js274
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_autofill_then_save_password.js181
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_crossframe.js236
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_dismissed_for_ccnumber.js202
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_empty_password.js42
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_form_password_edit.js562
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_generated_password.js1845
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js310
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_multipage_form.js182
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_password_edits.js220
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_promptToChangePassword.js685
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js1295
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_replace_dismissed_with_visible_while_opening.js65
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_save_password.js159
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_submit_telemetry.js387
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_target_blank.js94
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_toggles.js478
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_username_edits.js192
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_doorhanger_window_open.js201
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_entry_point_telemetry.js103
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js141
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_fileURIOrigin.js51
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_focus_before_first_DOMContentLoaded.js103
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js72
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js161
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_glean_pwmgr_form_autofill_result.js186
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js131
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js52
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_localip_frame.js86
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_message_onFormSubmit.js82
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_openPasswordManager.js161
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_preselect_login.js247
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_private_window.js954
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_proxyAuth_prompt.js182
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js534
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js53
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_test_changeContentInputValue.js129
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_username_only_form_telemetry.js198
-rw-r--r--toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js177
-rw-r--r--toolkit/components/passwordmgr/test/browser/empty.html8
-rw-r--r--toolkit/components/passwordmgr/test/browser/file_focus_before_DOMContentLoaded.sjs35
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_autofocus_frame.html10
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_autofocus_js.html10
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_iframe.html23
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_login.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_login_fields_with_max_length.html19
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_no_username.html11
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_password_autocomplete_new_password.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_password_autocomplete_off.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_prefilled_password.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_prefilled_username.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_signup.html10
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_basic_with_confirm_field.html13
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_cross_origin_insecure_action.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_cross_origin_secure_action.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_crossframe.html13
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_crossframe_inner.html13
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_crossframe_no_outer_login_form.html8
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_disabled_readonly_passwordField.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_expanded.html16
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_multipage.html32
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_multiple_passwords.html16
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_password_change.html17
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_same_origin_action.html12
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_signup_detection.html34
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_unmasked_password_after_pageload.html16
-rw-r--r--toolkit/components/passwordmgr/test/browser/form_username_only.html11
-rw-r--r--toolkit/components/passwordmgr/test/browser/formless_basic.html18
-rw-r--r--toolkit/components/passwordmgr/test/browser/head.js965
-rw-r--r--toolkit/components/passwordmgr/test/browser/insecure_test.html9
-rw-r--r--toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html16
-rw-r--r--toolkit/components/passwordmgr/test/browser/multiple_forms.html145
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_1.html29
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_10.html27
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_11.html26
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_11_popup.html32
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_12_target_blank.html31
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_2.html30
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_0un.html27
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_2pw_1un_1text.html31
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_3.html30
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_4.html30
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_5.html26
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_6.html27
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_8.html29
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_9.html29
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_notifications_change_p.html32
-rw-r--r--toolkit/components/passwordmgr/test/browser/subtst_privbrowsing_1.html16
-rw-r--r--toolkit/components/passwordmgr/test/formsubmit.sjs37
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/.eslintrc.js17
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/auth2/authenticate.sjs216
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/chrome_timeout.js14
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/file_history_back.html14
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/form_basic_bfcache.html58
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html31
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_each_field_in_its_own_shadow_root.html31
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/form_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html33
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_both_fields_together_in_a_shadow_root.html34
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_each_field_in_its_own_shadow_root.html38
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/form_nested_shadow_DOM_form_and_fields_together_in_a_shadow_root.html37
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html28
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_each_field_in_its_own_shadow_root.html28
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/formless_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html30
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/mochitest.toml419
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/multiple_forms_shadow_DOM_all_known_variants.html111
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js1175
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js247
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/slow_image.html9
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/slow_image.sjs25
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/subtst_prefilled_form.html18
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/subtst_primary_pass.html8
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/subtst_prompt_async.html12
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html61
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_LoginManagerContent_passwordEditedOrGenerated.html160
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html112
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form.html935
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_formActionOrigin.html79
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_insecure.html932
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html112
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_hasBeenTypePassword.html93
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight.html86
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_non_login.html91
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_username_only_form.html56
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_downgrade.html105
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_upgrade.html191
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation.html596
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html380
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_telemetry.html309
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_open.html90
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_sandboxed.html70
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autocomplete_tab_between_fields.html167
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_autocomplete_types.html112
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_different_formActionOrigin.html91
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_different_subdomain.html150
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_from_bfcache.html58
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_hasBeenTypePassword.html64
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight.html58
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_empty_username.html60
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_highlight_username_only_form.html50
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_https_downgrade.html118
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html148
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html133
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_sandboxed.html100
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_tab_between_fields.html151
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only.html107
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofill_username-only_threshold.html83
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_autofocus_js.html114
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form.html48
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_0pw.html70
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw.html171
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_1pw_2.html115
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_1.html190
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_2pw_2.html111
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_3pw_1.html259
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_honor_autocomplete_off.html149
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_html5.html165
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwevent.html50
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html193
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html163
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_bug_776171.html57
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_case_differences.html100
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_dismissed_doorhanger_in_shadow_DOM.html108
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_formLike_rootElement_with_Shadow_DOM.html151
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_form_action_1.html140
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_form_action_2.html173
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html44
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html140
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html242
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal.html287
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal_negative.html204
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html267
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html148
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_include_other_subdomains_in_lookup.html202
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_input_events.html56
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_input_events_for_identical_values.html52
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_insecure_form_field_no_saved_login.html91
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_maxlength.html144
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_munged_values.html362
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_one_doorhanger_per_un_pw.html59
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_onsubmit_value_change.html71
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html185
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_password_length.html145
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_passwords_in_type_password.html114
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_primary_password.html296
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_prompt.html669
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_prompt_async.html621
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html319
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html72
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html370
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html269
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_recipe_login_fields.html212
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_set_stored_logins_during_task.html50
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_submit_without_field_modifications.html311
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_username_focus.html166
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_xhr.html164
-rw-r--r--toolkit/components/passwordmgr/test/mochitest/test_xhr_2.html56
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlitebin0 -> 32772 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/data/key4.dbbin0 -> 294912 bytes
-rw-r--r--toolkit/components/passwordmgr/test/unit/head.js134
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_CSVParser.js254
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_doAutocompleteSearch.js148
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_getGeneratedPassword.js176
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js1126
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_searchAndDedupeLogins.js207
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_LoginManagerPrompter_getUsernameSuggestions.js188
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_OSCrypto_win.js138
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_PasswordGenerator.js122
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_PasswordRulesManager_generatePassword.js523
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_context_menu.js345
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_dedupeLogins.js411
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_disabled_hosts.js223
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_displayOrigin.js43
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_doLoginsMatch.js57
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_findRelatedRealms.js156
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getFormFields.js572
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getPasswordFields.js308
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getPasswordOrigin.js35
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js187
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_getUsernameFieldFromUsernameOnlyForm.js179
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isInferredLoginForm.js98
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isInferredUsernameField.js222
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isOriginMatching.js177
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isProbablyANewPasswordField.js192
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_isUsernameFieldType.js160
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js112
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_legacy_validation.js94
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_login_autocomplete_result.js735
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_loginsBackup.js218
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_change.js622
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_decrypt_failure.js172
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js304
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_logins_search.js227
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_maybeImportLogin.js368
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js866
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js222
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginManager.js37
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_module_LoginStore.js337
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_notifications.js193
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_recipes_add.js288
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_recipes_content.js53
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_remote_recipes.js162
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_search_schemeUpgrades.js239
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_shadowHTTPLogins.js82
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_storage.js93
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_telemetry.js201
-rw-r--r--toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js47
-rw-r--r--toolkit/components/passwordmgr/test/unit/xpcshell.toml118
316 files changed, 65359 insertions, 0 deletions
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/InsecurePasswordUtils.sys.mjs b/toolkit/components/passwordmgr/InsecurePasswordUtils.sys.mjs
new file mode 100644
index 0000000000..5d1bb2bf19
--- /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",
+});
+
+ChromeUtils.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..6ff96d999e
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs
@@ -0,0 +1,773 @@
+/* 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
+);
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ return lazy.LoginHelper.createLogger("LoginAutoComplete");
+});
+ChromeUtils.defineLazyGetter(lazy, "passwordMgrBundle", () => {
+ return Services.strings.createBundle(
+ "chrome://passwordmgr/locale/passwordmgr.properties"
+ );
+});
+ChromeUtils.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, // We have to keep login here to satisfy Android
+ isDuplicateUsername,
+ isOriginMatched,
+ comment:
+ isOriginMatched && login.httpRealm === null
+ ? getLocalizedString("displaySameOrigin")
+ : login.displayOrigin,
+ });
+ this.image = `page-icon:${login.origin}`;
+ }
+
+ 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,
+ });
+ this.image = "chrome://browser/skin/login.svg";
+ }
+}
+
+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("managePasswords.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.image,
+ 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 "Manage Passwords"
+ 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 this.#rows[index].image ?? "";
+ }
+
+ 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,
+ isWebAuthn: this.#isWebAuthnCredentials(autocompleteInfo),
+ }
+ );
+
+ 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;
+ }
+
+ /**
+ * @param {string} autocompleteInfo
+ * @returns whether the non-autofill credential type (https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#non-autofill-credential-type)
+ * of the input field is "webauthn"
+ */
+ #isWebAuthnCredentials(autocompleteInfo) {
+ return autocompleteInfo.credentialType == "webauthn";
+ }
+}
+
+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..2e9db07014
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginCSVImport.sys.mjs
@@ -0,0 +1,199 @@
+/* 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",
+});
+
+/**
+ * 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) {
+ 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 and duration telemetry.
+ try {
+ let histogram = Services.telemetry.getHistogramById(
+ "PWMGR_IMPORT_LOGINS_FROM_FILE_CATEGORICAL"
+ );
+ this._recordHistogramTelemetry(histogram, report);
+ } catch (ex) {
+ console.error(ex);
+ }
+ LoginCSVImport.lastImportReport = report;
+ return report;
+ }
+}
diff --git a/toolkit/components/passwordmgr/LoginExport.sys.mjs b/toolkit/components/passwordmgr/LoginExport.sys.mjs
new file mode 100644
index 0000000000..20d6a80952
--- /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.getAllLogins();
+ }
+ 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..32dc3e3cfa
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginFormFactory.sys.mjs
@@ -0,0 +1,148 @@
+/* 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 <form> element.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+});
+
+ChromeUtils.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 <form>.
+ *
+ * @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 <form>, construct the LoginForm from the form.
+ * Otherwise, create a LoginForm with a rootElement (wrapper) according to
+ * heuristics. Currently all <input> not in a <form> 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 <form>.
+ *
+ * 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..a63654c8f2
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginHelper.sys.mjs
@@ -0,0 +1,1883 @@
+/* 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 {
+ image;
+ title;
+ subtitle;
+ fillMessageName;
+ fillMessageData;
+
+ constructor(image, title, subtitle, fillMessageName, fillMessageData) {
+ this.image = image;
+ 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.
+ */
+ async 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 = await Services.logins.searchLoginsAsync({
+ origin: login.origin,
+ formActionOrigin: login.formActionOrigin,
+ httpRealm: 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,
+ 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);
+
+ // Watch for FXA Logout to reset signon.firefoxRelay to 'available'
+ // Using hard-coded value for FxAccountsCommon.ONLOGOUT_NOTIFICATION because
+ // importing FxAccountsCommon here caused hard-to-diagnose crash.
+ Services.obs.addObserver(() => {
+ Services.prefs.clearUserPref("signon.firefoxRelay.feature");
+ }, "fxaccounts:onlogout");
+ },
+
+ 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.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) {
+ 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
+ );
+ }
+
+ const 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.
+ return uri.scheme + "://" + uri.displayHostPort;
+ } catch {
+ return null;
+ }
+ },
+
+ 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 || 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 browser = window.gBrowser ?? window.opener?.gBrowser;
+
+ const tab = browser.addTrustedTab(`about:logins${paramsPart}`, {
+ inBackground: false,
+ });
+
+ tab.setAttribute("preselect-login", preselectedLogin);
+ },
+
+ /**
+ * 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" ||
+ acFieldName == "webauthn" ||
+ // 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 (await processor.checkConflictingWithExistingLogins(login)) {
+ continue;
+ }
+ processor.addLoginToSummary(login, "added");
+ }
+ return await processor.processLoginsAndBuildSummary();
+ } finally {
+ this.importing = false;
+
+ Services.obs.notifyObservers(null, "passwordmgr-reload-all");
+ this.notifyStorageChanged("importLogins", []);
+ }
+ },
+
+ /**
+ * 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 <browser> 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.getAllLogins();
+ 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 <browser> 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 <browser> that a prompt was triggered for
+ * @returns {Element} The <browser> 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;
+ },
+};
+
+ChromeUtils.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..79ea17b1f1
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginInfo.sys.mjs
@@ -0,0 +1,144 @@
+/* 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,
+
+ everSynced: false,
+ syncCounter: 0,
+
+ 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;
+ clone.syncCounter = this.syncCounter;
+ clone.everSynced = this.everSynced;
+
+ // 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..d50c53cbad
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManager.shared.mjs
@@ -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/. */
+
+/**
+ * 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 (
+ ["text", "email", "url", "tel", "number", "search"].includes(fieldType) ||
+ fieldType?.includes("user")
+ );
+ }
+
+ /**
+ * 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..b95f3ada8b
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManager.sys.mjs
@@ -0,0 +1,618 @@
+/* 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 { LoginManagerStorage } from "resource://passwordmgr/passwordstorage.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+});
+
+ChromeUtils.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.initializationPromise = new Promise(resolve => {
+ this._storage = LoginManagerStorage.create(() => {
+ resolve();
+
+ 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.getAllLogins();
+
+ 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.sys.jms
+ // 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 || 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.
+ */
+ async addLoginAsync(login) {
+ this._checkLogin(login);
+
+ lazy.log.debug("Adding login");
+ const [resultLogin] = await this._storage.addLoginsAsync([login]);
+ return resultLogin;
+ },
+
+ /**
+ * Add multiple logins to login storage.
+ * TODO: rename to `addLoginsAsync` https://bugzilla.mozilla.org/show_bug.cgi?id=1832757
+ */
+ async addLogins(logins) {
+ if (logins.length === 0) {
+ return logins;
+ }
+
+ const validLogins = logins.filter(login => {
+ try {
+ this._checkLogin(login);
+ return true;
+ } catch (e) {
+ console.error(e);
+ return false;
+ }
+ });
+ lazy.log.debug("Adding logins");
+ return this._storage.addLoginsAsync(validLogins, true);
+ },
+
+ /**
+ * 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 asynchronously. Used by the login manager UI.
+ *
+ * @return {nsILoginInfo[]} - If there are no logins, the array is empty.
+ */
+ async getAllLogins() {
+ lazy.log.debug("Getting a list of all logins asynchronously.");
+ return this._storage.getAllLogins();
+ },
+
+ /**
+ * Get a dump of all stored logins asynchronously. Used by the login detection service.
+ */
+ getAllLoginsWithCallback(aCallback) {
+ lazy.log.debug("Searching a list of all logins asynchronously.");
+ this._storage.getAllLogins().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 */
+ 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..8c39cf09b9
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs
@@ -0,0 +1,1124 @@
+/* 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);
+ }
+ }
+
+ 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
+
+ChromeUtils.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.
+ *
+ * Note this implementation no longer provides `nsIAuthPrompt.promptPassword()`
+ * and `nsIAuthPrompt.promptUsernameAndPassword()`. Use their async
+ * counterparts `asyncPromptPassword` and `asyncPromptUsernameAndPassword`
+ * instead.
+ *
+ * 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.
+ // We don't use searchLoginsAsync here and in asyncPromptPassword
+ // because of bug 1848682
+ let matchData = lazy.LoginHelper.newPropertyBag({
+ origin,
+ httpRealm: realm,
+ });
+ foundLogins = Services.logins.searchLogins(matchData);
+
+ // 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.
+ let matchData = lazy.LoginHelper.newPropertyBag({
+ origin,
+ httpRealm: realm,
+ });
+ let foundLogins = Services.logins.searchLogins(matchData);
+
+ // 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);
+ }
+
+ 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);
+ console.error("Falling back to promptAuth");
+ // 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
+
+ChromeUtils.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..94be604d02
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManagerChild.sys.mjs
@@ -0,0 +1,3178 @@
+/* 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";
+
+export const AUTOFILL_RESULT = {
+ FILLED: "filled",
+ NO_PASSWORD_FIELD: "no_password_field",
+ PASSWORD_DISABLED_READONLY: "password_disabled_readonly",
+ NO_LOGINS_FIT: "no_logins_fit",
+ NO_SAVED_LOGINS: "no_saved_logins",
+ EXISTING_PASSWORD: "existing_password",
+ EXISTING_USERNAME: "existing_username",
+ MULTIPLE_LOGINS: "multiple_logins",
+ NO_AUTOFILL_FORMS: "no_autofill_forms",
+ AUTOCOMPLETE_OFF: "autocomplete_off",
+ INSECURE: "insecure",
+ PASSWORD_AUTOCOMPLETE_NEW_PASSWORD: "password_autocomplete_new_password",
+ TYPE_NO_LONGER_PASSWORD: "type_no_longer_password",
+ FORM_IN_CROSSORIGIN_SUBFRAME: "form_in_crossorigin_subframe",
+ FILLED_USERNAME_ONLY_FORM: "filled_username_only_form",
+};
+
+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",
+ FormScenarios: "resource://gre/modules/FormScenarios.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",
+ LoginManagerTelemetry: "resource://gre/modules/LoginManagerTelemetry.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gFormFillService",
+ "@mozilla.org/satchel/form-fill-controller;1",
+ "nsIFormFillController"
+);
+
+ChromeUtils.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 || selectedIndex >= input.controller.matchCount) {
+ 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":
+ this.handleBlur(docState, field);
+ break;
+
+ // Used to watch for changes to username and password fields.
+ case "change":
+ this.handleChange(docState, field, loginManagerChild);
+ break;
+
+ case "input":
+ this.handleInput(
+ aEvent,
+ docState,
+ field,
+ loginManagerChild,
+ ownerDocument
+ );
+ break;
+
+ case "keydown":
+ this.handleKeydown(aEvent, field, loginManagerChild, ownerDocument);
+ break;
+
+ case "focus":
+ this.handleFocus(field, docState, aEvent.target);
+ break;
+
+ case "mousedown":
+ this.handleMousedown(aEvent.button);
+ break;
+
+ default: {
+ throw new Error("Unexpected event");
+ }
+ }
+ },
+
+ handleBlur(docState, field) {
+ if (docState.generatedPasswordFields.has(field)) {
+ docState._togglePasswordFieldMasking(field, false);
+ }
+ },
+
+ handleChange(docState, field, loginManagerChild) {
+ 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();
+ }
+ return;
+ }
+
+ 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();
+ }
+ }
+ },
+
+ handleInput(aEvent, docState, field, loginManagerChild, ownerDocument) {
+ 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)) {
+ return;
+ }
+
+ // React to input into potential username or password fields
+ let formLikeRoot = lazy.FormLikeFactory.findRootForField(field);
+
+ if (formLikeRoot !== aEvent.currentTarget) {
+ return;
+ }
+ // 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."
+ );
+ return;
+ }
+ if (
+ !isPasswordType &&
+ filledLogin.usernameField &&
+ filledLogin.username == field.value
+ ) {
+ lazy.log(
+ "Ignoring username input event that doesn't change autofilled values."
+ );
+ return;
+ }
+ }
+ 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)
+ ) {
+ loginManagerChild.registerDOMDocFetchSuccessEventListener(ownerDocument);
+ }
+
+ 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) {
+ return;
+ }
+ 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
+ ) {
+ return;
+ }
+ // 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),
+ });
+ }
+ }
+ },
+
+ handleKeydown(aEvent, field, loginManagerChild, ownerDocument) {
+ 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);
+ }
+ }
+ },
+
+ handleFocus(field, docState, target) {
+ //@sg see if we can drop focusedField (aEvent.target) and use field (aEvent.composedTarget)
+ docState.onFocus(field, target);
+ },
+
+ handleMousedown(button) {
+ if (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();
+ }
+ },
+};
+
+// 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 <input> that we think might be a username
+ */
+ possibleUsernames = new Set();
+ /**
+ * Anything entered into an <input> 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();
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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;
+ }
+
+ // The login manager is responsible for fields with the "webauthn" credential type.
+ let acCredentialType = focusedField.getAutocompleteInfo()?.credentialType;
+ if (acCredentialType == "webauthn") {
+ lazy.gFormFillService.markAsLoginManagerField(focusedField);
+ }
+
+ 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 5 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")
+ );
+ }
+
+ registerDOMDocFetchSuccessEventListener(document) {
+ document.setNotifyFetchSuccess(true);
+ this.docShell.chromeEventHandler.addEventListener(
+ "DOMDocFetchSuccess",
+ this,
+ true
+ );
+ }
+
+ unregisterDOMDocFetchSuccessEventListener(document) {
+ document.setNotifyFetchSuccess(false);
+ this.docShell.chromeEventHandler.removeEventListener(
+ "DOMDocFetchSuccess",
+ this
+ );
+ }
+
+ 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 set up, remove the listener.
+ this.unregisterDOMDocFetchSuccessEventListener(document);
+ }
+
+ /*
+ * 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 <form> 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;
+ 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;
+
+ if (usernameField) {
+ const isSignUpForm =
+ lazy.FormScenarios.detect({
+ input: usernameField,
+ formRoot: form.rootElement,
+ }).signUpForm ?? passwordACFieldName == "new-password";
+
+ if (isSignUpForm) {
+ 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) {
+ // 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.
+
+ lazy.LoginManagerTelemetry.recordAutofillResult(autofillResult);
+
+ if (usernameField) {
+ let focusedElement = lazy.gFormFillService.focusedInput;
+ if (
+ usernameField == focusedElement &&
+ ![
+ AUTOFILL_RESULT.FILLED,
+ AUTOFILL_RESULT.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,
+ autofillResult,
+ });
+ }
+ }
+
+ 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..fc87f5cf58
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManagerContextMenu.sys.mjs
@@ -0,0 +1,238 @@
+/* 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",
+});
+
+/**
+ * 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);
+ },
+};
+
+ChromeUtils.defineLazyGetter(
+ LoginManagerContextMenu,
+ "_stringBundle",
+ function () {
+ return Services.strings.createBundle(
+ "chrome://passwordmgr/locale/passwordmgr.properties"
+ );
+ }
+);
+
+ChromeUtils.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..862ab6b846
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManagerParent.sys.mjs
@@ -0,0 +1,1553 @@
+/* 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 = {};
+
+ChromeUtils.defineLazyGetter(lazy, "LoginRelatedRealmsParent", () => {
+ const { LoginRelatedRealmsParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginRelatedRealms.sys.mjs"
+ );
+ return new LoginRelatedRealmsParent();
+});
+
+ChromeUtils.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",
+ WebAuthnFeature: "resource://gre/modules/WebAuthnFeature.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
+);
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ let logger = lazy.LoginHelper.createLogger("LoginManagerParent");
+ return logger.log.bind(logger);
+});
+ChromeUtils.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 = {};
+ ChromeUtils.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, data.autofillResult);
+ break;
+ }
+
+ case "PasswordManager:offerRelayIntegration": {
+ FirefoxRelayTelemetry.recordRelayOfferedEvent(
+ "clicked",
+ data.telemetry.flowId,
+ data.telemetry.scenarioName
+ );
+ return this.#offerRelayIntegration(context.origin);
+ }
+
+ case "PasswordManager:generateRelayUsername": {
+ FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
+ "clicked",
+ data.telemetry.flowId
+ );
+ return this.#generateRelayUsername(context.origin);
+ }
+
+ case "PasswordManager:promptForAuthenticator": {
+ return this.#promptForAuthenticator(data.selection);
+ }
+ }
+
+ 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, autofillResult) {
+ const topActor =
+ this.browsingContext.currentWindowGlobal.getActor("LoginManager");
+ topActor.sendAsyncMessage("PasswordManager:formProcessed", { formid });
+ if (gListenerForTests) {
+ gListenerForTests("FormProcessed", {
+ browsingContext: this.browsingContext,
+ data: {
+ formId: formid,
+ autofillResult,
+ },
+ });
+ }
+ }
+
+ 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);
+ }
+
+ async #promptForAuthenticator(selection) {
+ const browser = lazy.LoginHelper.getBrowserForPrompt(this.getRootBrowser());
+ return lazy.WebAuthnFeature.promptForAuthenticator(browser, selection);
+ }
+
+ /**
+ * 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,
+ isWebAuthn,
+ }
+ ) {
+ // 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);
+
+ let autocompleteItems = [];
+
+ if (!hasBeenTypePassword) {
+ autocompleteItems.push(
+ ...(await lazy.FirefoxRelay.autocompleteItemsAsync({
+ formOrigin,
+ scenarioName,
+ hasInput: !!searchStringLower.length,
+ }))
+ );
+ }
+ autocompleteItems.push(
+ ...(await lazy.WebAuthnFeature.autocompleteItemsAsync(
+ this._overrideBrowsingContextId ??
+ this.getRootBrowser().browsingContext.id,
+ formOrigin,
+ scenarioName,
+ isWebAuthn
+ ))
+ );
+
+ return {
+ generatedPassword,
+ importable: await getImportableLogins(formOrigin),
+ autocompleteItems,
+ 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..2637e8a52f
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManagerPrompter.sys.mjs
@@ -0,0 +1,1121 @@
+/* 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";
+import { showConfirmation } from "resource://gre/modules/FillHelpers.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "usernameAutocompleteSearch",
+ "@mozilla.org/autocomplete/search;1?name=login-doorhanger-username",
+ "nsIAutoCompleteSimpleSearch"
+);
+
+ChromeUtils.defineLazyGetter(lazy, "l10n", () => {
+ return new Localization(["toolkit/passwordmgr/passwordmgr.ftl"], true);
+});
+
+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) {
+ const doc = popup.ownerDocument;
+ const nameField = doc.getElementById("password-notification-username");
+ const passwordField = doc.getElementById("password-notification-password");
+
+ const 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": {
+ const 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<String>} possibleValues.usernames
+ * @param {Set<String>} possibleValues.passwords
+ */
+ promptToSavePassword(
+ aBrowser,
+ aLogin,
+ dismissed = false,
+ notifySaved = false,
+ autoFilledLoginGuid = "",
+ possibleValues = undefined
+ ) {
+ lazy.log.debug("Prompting user to save login.");
+ const inPrivateBrowsing = PrivateBrowsingUtils.isBrowserPrivate(aBrowser);
+ const 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() {
+ const { 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<String>} possibleValues.usernames
+ * @param {Set<String>} possibleValues.passwords
+ */
+ static _showLoginCaptureDoorhanger(
+ browser,
+ login,
+ type,
+ showOptions = {},
+ possibleValues = undefined,
+ {
+ notifySaved = false,
+ messageStringID,
+ autoSavedLoginGuid = "",
+ autoFilledLoginGuid = "",
+ } = {}
+ ) {
+ lazy.log.debug(
+ `Got autoSavedLoginGuid: ${autoSavedLoginGuid} and autoFilledLoginGuid ${autoFilledLoginGuid}.`
+ );
+
+ const saveMessageIds = {
+ prompt: "password-manager-save-password-message",
+ mainButton: "password-manager-save-password-button-allow",
+ secondaryButton: "password-manager-save-password-button-deny",
+ };
+
+ const changeMessageIds = {
+ prompt: messageStringID ?? "password-manager-update-password-message",
+ mainButton: "password-manager-password-password-button-allow",
+ secondaryButton: "password-manager-update-password-button-deny",
+ };
+
+ const initialMessageIds =
+ type == "password-save" ? saveMessageIds : changeMessageIds;
+
+ const promptId = initialMessageIds.prompt;
+ const host = this._getShortDisplayHost(login.origin);
+ const promptMessage = lazy.l10n.formatValueSync(promptId, { host });
+
+ const histogramName =
+ type == "password-save"
+ ? "PWMGR_PROMPT_REMEMBER_ACTION"
+ : "PWMGR_PROMPT_UPDATE_ACTION";
+ const histogram = Services.telemetry.getHistogramById(histogramName);
+
+ const chromeDoc = browser.ownerDocument;
+ let currentNotification;
+
+ const wasModifiedEvent = {
+ // Values are mutated
+ did_edit_un: "false",
+ did_select_un: "false",
+ did_edit_pw: "false",
+ did_select_pw: "false",
+ };
+
+ const updateButtonStatus = element => {
+ const 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");
+ }
+ };
+
+ const updateButtonLabel = () => {
+ if (!currentNotification) {
+ console.error("updateButtonLabel, no currentNotification");
+ }
+ const foundLogins = lazy.LoginHelper.searchLoginsWithObject({
+ formActionOrigin: login.formActionOrigin,
+ origin: login.origin,
+ httpRealm: login.httpRealm,
+ schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
+ });
+
+ const logins = this._filterUpdatableLogins(
+ login,
+ foundLogins,
+ autoSavedLoginGuid
+ );
+ const messageIds = !logins.length ? saveMessageIds : changeMessageIds;
+
+ // Update the label based on whether this will be a new login or not.
+
+ const mainButton = this.getLabelAndAccessKey(messageIds.mainButton);
+
+ // Update the labels for the next time the panel is opened.
+ currentNotification.mainAction.label = mainButton.label;
+ currentNotification.mainAction.accessKey = mainButton.accessKey;
+
+ // Update the labels in real time if the notification is displayed.
+ const element = [...currentNotification.owner.panel.childNodes].find(
+ n => n.notification == currentNotification
+ );
+ if (element) {
+ element.setAttribute("buttonlabel", mainButton.label);
+ element.setAttribute("buttonaccesskey", mainButton.accessKey);
+ updateButtonStatus(element);
+ }
+ };
+
+ const writeDataToUI = () => {
+ const nameField = chromeDoc.getElementById(
+ "password-notification-username"
+ );
+
+ nameField.placeholder = usernamePlaceholder;
+ nameField.value = login.username;
+
+ const toggleCheckbox = chromeDoc.getElementById(
+ "password-notification-visibilityToggle"
+ );
+ toggleCheckbox.removeAttribute("checked");
+ const passwordField = chromeDoc.getElementById(
+ "password-notification-password"
+ );
+ // Ensure the type is reset so the field is masked.
+ passwordField.type = "password";
+ passwordField.value = login.password;
+
+ updateButtonLabel();
+ };
+
+ const readDataFromUI = () => {
+ login.username = chromeDoc.getElementById(
+ "password-notification-username"
+ ).value;
+ login.password = chromeDoc.getElementById(
+ "password-notification-password"
+ ).value;
+ };
+
+ const onInput = () => {
+ readDataFromUI();
+ updateButtonLabel();
+ };
+
+ const onUsernameInput = () => {
+ wasModifiedEvent.did_edit_un = "true";
+ wasModifiedEvent.did_select_un = "false";
+ onInput();
+ };
+
+ const onUsernameSelect = () => {
+ wasModifiedEvent.did_edit_un = "false";
+ wasModifiedEvent.did_select_un = "true";
+ };
+
+ const onPasswordInput = () => {
+ wasModifiedEvent.did_edit_pw = "true";
+ wasModifiedEvent.did_select_pw = "false";
+ onInput();
+ };
+
+ const onPasswordSelect = () => {
+ wasModifiedEvent.did_edit_pw = "false";
+ wasModifiedEvent.did_select_pw = "true";
+ };
+
+ const onKeyUp = e => {
+ if (e.key == "Enter") {
+ e.target.closest("popupnotification").button.doCommand();
+ }
+ };
+
+ const onVisibilityToggle = commandEvent => {
+ const passwordField = chromeDoc.getElementById(
+ "password-notification-password"
+ );
+ // Gets the caret position before changing the type of the textbox
+ const selectionStart = passwordField.selectionStart;
+ const selectionEnd = passwordField.selectionEnd;
+ passwordField.setAttribute(
+ "type",
+ commandEvent.target.checked ? "" : "password"
+ );
+ if (!passwordField.hasAttribute("focused")) {
+ return;
+ }
+ passwordField.selectionStart = selectionStart;
+ passwordField.selectionEnd = selectionEnd;
+ };
+
+ const togglePopup = event => {
+ event.target.parentElement
+ .getElementsByClassName("ac-has-end-icon")[0]
+ .toggleHistoryPopup();
+ };
+
+ const persistData = async () => {
+ const foundLogins = lazy.LoginHelper.searchLoginsWithObject({
+ formActionOrigin: login.formActionOrigin,
+ origin: login.origin,
+ httpRealm: login.httpRealm,
+ schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
+ });
+
+ let logins = this._filterUpdatableLogins(
+ login,
+ foundLogins,
+ autoSavedLoginGuid
+ );
+ const 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;
+ const 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.
+ await Services.logins.addLoginAsync(
+ 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);
+ }
+ };
+
+ const supportedHistogramNames = {
+ PWMGR_PROMPT_REMEMBER_ACTION: true,
+ PWMGR_PROMPT_UPDATE_ACTION: true,
+ };
+
+ const mainButton = this.getLabelAndAccessKey(initialMessageIds.mainButton);
+
+ // The main action is the "Save" or "Update" button.
+ const mainAction = {
+ label: mainButton.label,
+ accessKey: mainButton.accessKey,
+ callback: async () => {
+ const eventTypeMapping = {
+ "password-save": {
+ eventObject: "save",
+ confirmationHintFtlId: "confirmation-hint-password-created",
+ },
+ "password-change": {
+ eventObject: "update",
+ confirmationHintFtlId: "confirmation-hint-password-updated",
+ },
+ };
+
+ if (!eventTypeMapping[type]) {
+ throw new Error(`Unexpected doorhanger type: '${type}'`);
+ }
+
+ 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 (!supportedHistogramNames[histogramName]) {
+ throw new Error("Unknown histogram");
+ }
+
+ showConfirmation(browser, eventTypeMapping[type].confirmationHintFtlId);
+ // The popup does not wait until this promise is resolved, but is
+ // closed immediately when the function is returned. Therefore, we set
+ // the focus before awaiting the asynchronous operation.
+ browser.focus();
+ await persistData();
+
+ Services.telemetry.recordEvent(
+ "pwmgr",
+ "doorhanger_submitted",
+ eventTypeMapping[type].eventObject,
+ null,
+ wasModifiedEvent
+ );
+
+ 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");
+ }
+
+ Services.obs.notifyObservers(
+ null,
+ "weave:telemetry:histogram",
+ histogramName
+ );
+ },
+ };
+
+ const secondaryButton = this.getLabelAndAccessKey(
+ initialMessageIds.secondaryButton
+ );
+
+ const secondaryActions = [
+ {
+ label: secondaryButton.label,
+ accessKey: secondaryButton.accessKey,
+ 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") {
+ const neverSaveButton = this.getLabelAndAccessKey(
+ "password-manager-save-password-button-never"
+ );
+ secondaryActions.push({
+ label: neverSaveButton.label,
+ accessKey: neverSaveButton.accessKey,
+ callback: () => {
+ histogram.add(PROMPT_NEVER);
+ Services.obs.notifyObservers(
+ null,
+ "weave:telemetry:histogram",
+ histogramName
+ );
+ Services.logins.setLoginSavingEnabled(login.origin, false);
+ browser.focus();
+ },
+ });
+ }
+
+ const updatePasswordButtonDelete = this.getLabelAndAccessKey(
+ "password-manager-update-password-button-delete"
+ );
+
+ // Include a "Delete this login" button when updating an existing password
+ if (type == "password-change") {
+ secondaryActions.push({
+ label: updatePasswordButtonDelete.label,
+ accessKey: updatePasswordButtonDelete.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();
+ lazy.log.debug("Showing the ConfirmationHint");
+ showConfirmation(browser, "confirmation-hint-password-removed");
+ },
+ });
+ }
+
+ const usernamePlaceholder = lazy.l10n.formatValueSync(
+ "password-manager-no-username-placeholder"
+ );
+ const togglePassword = this.getLabelAndAccessKey(
+ "password-manager-toggle-password"
+ );
+
+ // .wrappedJSObject needed here -- see bug 422974 comment 5.
+ const { PopupNotifications } = browser.ownerGlobal.wrappedJSObject;
+
+ const 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;
+
+ const 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 => {
+ const dropmarker = chromeDoc?.getElementById(
+ "password-notification-username-dropmarker"
+ );
+ if (dropmarker) {
+ dropmarker.hidden = !usernameSuggestions.length;
+ }
+
+ const usernameField = chromeDoc?.getElementById(
+ "password-notification-username"
+ );
+ if (usernameField) {
+ usernameField.classList.toggle(
+ "ac-has-end-icon",
+ !!usernameSuggestions.length
+ );
+ }
+ });
+
+ const toggleBtn = chromeDoc.getElementById(
+ "password-notification-visibilityToggle"
+ );
+
+ if (
+ Services.prefs.getBoolPref(
+ "signon.rememberSignons.visibilityToggle"
+ )
+ ) {
+ toggleBtn.addEventListener("command", onVisibilityToggle);
+
+ toggleBtn.setAttribute("label", togglePassword.label);
+ toggleBtn.setAttribute("accesskey", togglePassword.accessKey);
+
+ const 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 ==
+ "password-manager-update-login-add-username" &&
+ 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();
+ const 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;
+
+ const usernameField = chromeDoc.getElementById(
+ "password-notification-username"
+ );
+ usernameField.removeEventListener("input", onUsernameInput);
+ usernameField.removeEventListener("keyup", onKeyUp);
+ const 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
+ );
+
+ const notification = PopupNotifications.show(
+ browser,
+ notificationID,
+ promptMessage,
+ "password-notification-icon",
+ mainAction,
+ secondaryActions,
+ options
+ );
+
+ if (notifySaved) {
+ showConfirmation(
+ browser,
+ "confirmation-hint-password-created",
+ "password-notification-icon"
+ );
+ }
+
+ 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<String>} possibleValues.usernames
+ * @param {Set<String>} possibleValues.passwords
+ */
+ promptToChangePassword(
+ aBrowser,
+ aOldLogin,
+ aNewLogin,
+ dismissed = false,
+ notifySaved = false,
+ autoSavedLoginGuid = "",
+ autoFilledLoginGuid = "",
+ possibleValues = undefined
+ ) {
+ const 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 = "password-manager-update-login-add-username";
+ }
+
+ const notification = LoginManagerPrompter._showLoginCaptureDoorhanger(
+ aBrowser,
+ login,
+ "password-change",
+ {
+ dismissed,
+ extraAttr: notifySaved ? "attention" : "",
+ },
+ possibleValues,
+ {
+ notifySaved,
+ messageStringID,
+ autoSavedLoginGuid,
+ autoFilledLoginGuid,
+ }
+ );
+
+ const oldGUID = aOldLogin.QueryInterface(Ci.nsILoginMetaInfo).guid;
+ Services.obs.notifyObservers(
+ aNewLogin,
+ "passwordmgr-prompt-change",
+ oldGUID
+ );
+
+ return {
+ dismiss() {
+ const { 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}.`
+ );
+
+ const noUsernamePlaceholder = lazy.l10n.formatValueSync(
+ "password-manager-no-username-placeholder"
+ );
+ const usernames = logins.map(l => l.username || noUsernamePlaceholder);
+ const dialogText = lazy.l10n.formatValueSync(
+ "password-manager-select-username"
+ );
+ const dialogTitle = lazy.l10n.formatValueSync(
+ "password-manager-confirm-password-change"
+ );
+ const selectedIndex = { value: null };
+
+ // If user selects ok, outparam.value is set to the index
+ // of the selected username.
+ const ok = Services.prompt.select(
+ browser.ownerGlobal,
+ dialogTitle,
+ dialogText,
+ usernames,
+ selectedIndex
+ );
+ if (ok) {
+ // Now that we know which login to use, modify its password.
+ const selectedLogin = logins[selectedIndex.value];
+ lazy.log.debug(`Updating password for origin: ${aNewLogin.origin}.`);
+ const 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) {
+ const now = Date.now();
+ const 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);
+ }
+
+ /**
+ * Retrieves the message of the given id from fluent
+ * and extracts the label and accesskey
+ *
+ * @param {String} id message id
+ * @returns label and accesskey
+ */
+ static getLabelAndAccessKey(id) {
+ const msg = lazy.l10n.formatMessagesSync([id])[0];
+ return {
+ label: msg.attributes.find(x => x.name == "label").value,
+ accessKey: msg.attributes.find(x => x.name == "accesskey").value,
+ };
+ }
+
+ /**
+ * 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) {
+ let displayHost;
+
+ const idnService = Cc["@mozilla.org/network/idn-service;1"].getService(
+ Ci.nsIIDNService
+ );
+ try {
+ const uri = Services.io.newURI(aURIString);
+ const 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<String>?} possibleUsernames - values that we believe may be new/changed login usernames.
+ */
+ static async _setUsernameAutocomplete(login, possibleUsernames = new Set()) {
+ const result = Cc[
+ "@mozilla.org/autocomplete/simple-result;1"
+ ].createInstance(Ci.nsIAutoCompleteSimpleResult);
+ result.setDefaultIndex(0);
+
+ const usernames = await this._getUsernameSuggestions(
+ login,
+ possibleUsernames
+ );
+ for (const { text, style } of usernames) {
+ const value = text;
+ const comment = "";
+ const image = "";
+ const _style = style;
+ result.appendMatch(value, comment, image, _style);
+ }
+
+ result.setSearchResult(
+ usernames.length
+ ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS
+ : Ci.nsIAutoCompleteResult.RESULT_NOMATCH
+ );
+
+ lazy.usernameAutocompleteSearch.overrideNextResult(result);
+ }
+
+ /**
+ * @param {nsILoginInfo} login - used only for its information about the current domain.
+ * @param {Set<String>?} 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 [];
+ }
+
+ const baseDomainLogins = await Services.logins.searchLoginsAsync({
+ origin: login.origin,
+ schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
+ acceptDifferentSubdomains: true,
+ });
+
+ const saved = baseDomainLogins.map(login => {
+ return { text: login.username, style: "login" };
+ });
+ const possible = [...possibleUsernames].map(username => {
+ return { text: username, style: "possible-username" };
+ });
+
+ return possible
+ .concat(saved)
+ .reduce((acc, next) => {
+ const alreadyInAcc =
+ acc.findIndex(entry => entry.text == next.text) != -1;
+ if (!alreadyInAcc) {
+ acc.push(next);
+ } else if (next.style == "possible-username") {
+ const 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");
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ return lazy.LoginHelper.createLogger("LoginManagerPrompter");
+});
diff --git a/toolkit/components/passwordmgr/LoginManagerTelemetry.sys.mjs b/toolkit/components/passwordmgr/LoginManagerTelemetry.sys.mjs
new file mode 100644
index 0000000000..ad2e7d80d3
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginManagerTelemetry.sys.mjs
@@ -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/. */
+
+/**
+ * Provides the logic for recording all password manager related telemetry data.
+ */
+export class LoginManagerTelemetry {
+ static recordAutofillResult(result) {
+ Glean.pwmgr.formAutofillResult[result].add(1);
+ LoginManagerLegacyTelemetry.recordAutofillResult(result);
+ }
+}
+
+/**
+ * Until the password manager related measurements are fully migrated to Glean,
+ * we need to collect the data in both systems (Legacy Telemetry and Glean) for now.
+ * Not all new Glean metric can be mirrored automatically (using the property telemetry_mirror in metrics.yaml).
+ * Therefore, we need to manually call the Legacy Telemetry API calls in this class.
+ * Once we have collected enough data for all probes in the Glean system, we can remove this class and its references.
+ */
+class LoginManagerLegacyTelemetry {
+ static HISTOGRAM_AUTOFILL_RESULT = "PWMGR_FORM_AUTOFILL_RESULT";
+ static 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,
+ };
+
+ static convertToAutofillResultNumber(result) {
+ return LoginManagerLegacyTelemetry.AUTOFILL_RESULT[result];
+ }
+
+ static recordAutofillResult(result) {
+ const autofillResultNumber =
+ LoginManagerLegacyTelemetry.convertToAutofillResultNumber(result);
+ Services.telemetry
+ .getHistogramById(LoginManagerLegacyTelemetry.HISTOGRAM_AUTOFILL_RESULT)
+ .add(autofillResultNumber);
+ }
+}
+export default LoginManagerTelemetry;
diff --git a/toolkit/components/passwordmgr/LoginRecipes.sys.mjs b/toolkit/components/passwordmgr/LoginRecipes.sys.mjs
new file mode 100644
index 0000000000..66d7bf80bd
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginRecipes.sys.mjs
@@ -0,0 +1,381 @@
+/* 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);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+});
+
+ChromeUtils.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 <input> 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..6ab9ca8642
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginRelatedRealms.sys.mjs
@@ -0,0 +1,108 @@
+/* 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",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+});
+
+ChromeUtils.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..ad7a7e3d30
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginStore.sys.mjs
@@ -0,0 +1,181 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.sys.mjs",
+ FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.sys.mjs",
+});
+
+/**
+ * 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<HTMLElement>) 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 <td>, and, if so, check the textContent of the containing <tr>
+ if (parentElement.tagName === "TD" && parentElement.parentElement) {
+ // TODO: How bad is the assumption that the <tr> won't be the parent of the <td>?
+ return regex.test(parentElement.parentElement.textContent);
+ }
+
+ // Check if the input is in a <dd>, and, if so, check the textContent of the preceding <dt>
+ 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..32757e51c1
--- /dev/null
+++ b/toolkit/components/passwordmgr/PasswordRulesManager.sys.mjs
@@ -0,0 +1,128 @@
+/* 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",
+ PasswordGenerator: "resource://gre/modules/PasswordGenerator.sys.mjs",
+ PasswordRulesParser: "resource://gre/modules/PasswordRulesParser.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+});
+
+ChromeUtils.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('"', "&quot;")}]`;
+ }
+}
+
+// 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/components.conf b/toolkit/components/passwordmgr/components.conf
new file mode 100644
index 0000000000..4adb4c3b3f
--- /dev/null
+++ b/toolkit/components/passwordmgr/components.conf
@@ -0,0 +1,65 @@
+# -*- 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': '{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..3bdfecb2b3
--- /dev/null
+++ b/toolkit/components/passwordmgr/crypto-SDR.sys.mjs
@@ -0,0 +1,307 @@
+/* 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 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
+
+ChromeUtils.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..f5d3419990
--- /dev/null
+++ b/toolkit/components/passwordmgr/jar.mn
@@ -0,0 +1,12 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+toolkit.jar:
+% content passwordmgr %content/passwordmgr/
+% resource passwordmgr %passwordmgr/
+#ifdef ANDROID
+ passwordmgr/passwordstorage.sys.mjs (./storage-geckoview.sys.mjs)
+#else
+ passwordmgr/passwordstorage.sys.mjs (./storage-desktop.sys.mjs)
+#endif
diff --git a/toolkit/components/passwordmgr/metrics.yaml b/toolkit/components/passwordmgr/metrics.yaml
new file mode 100644
index 0000000000..a524d10d9b
--- /dev/null
+++ b/toolkit/components/passwordmgr/metrics.yaml
@@ -0,0 +1,41 @@
+# 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/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Toolkit :: Password Manager'
+
+pwmgr:
+ form_autofill_result:
+ type: labeled_counter
+ description:
+ The result of auto-filling a login form.
+ labels:
+ - filled
+ - no_password_field
+ - password_disabled_readonly
+ - no_logins_fit
+ - no_saved_logins
+ - existing_password
+ - existing_username
+ - multiple_logins
+ - no_autofill_forms
+ - autocomplete_off
+ - insecure
+ - password_autocomplete_new_password
+ - type_no_longer_password
+ - form_in_crossorigin_subframe
+ - filled_username_only_form
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1340021
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1833398
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1833398#c3
+ notification_emails:
+ - passwords-dev@mozilla.org
+ expires: never
diff --git a/toolkit/components/passwordmgr/moz.build b/toolkit/components/passwordmgr/moz.build
new file mode 100644
index 0000000000..4fbb9dcbf4
--- /dev/null
+++ b/toolkit/components/passwordmgr/moz.build
@@ -0,0 +1,79 @@
+# -*- 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.toml"]
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"]
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"]
+
+TESTING_JS_MODULES += [
+ "test/LoginTestUtils.sys.mjs",
+]
+
+XPIDL_SOURCES += [
+ "nsILoginAutoCompleteSearch.idl",
+ "nsILoginInfo.idl",
+ "nsILoginManager.idl",
+ "nsILoginManagerAuthPrompter.idl",
+ "nsILoginManagerCrypto.idl",
+ "nsILoginManagerPrompter.idl",
+ "nsILoginMetaInfo.idl",
+ "nsIPromptInstance.idl",
+]
+
+XPIDL_MODULE = "loginmgr"
+
+EXTRA_JS_MODULES += [
+ "crypto-SDR.sys.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",
+ "LoginManagerTelemetry.sys.mjs",
+ "LoginRecipes.sys.mjs",
+ "LoginRelatedRealms.sys.mjs",
+ "NewPasswordModel.sys.mjs",
+ "PasswordGenerator.sys.mjs",
+ "PasswordRulesManager.sys.mjs",
+ "PasswordRulesParser.sys.mjs",
+ "storage-json.sys.mjs",
+]
+
+if CONFIG["OS_TARGET"] != "Android":
+ 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..93f4e2808d
--- /dev/null
+++ b/toolkit/components/passwordmgr/nsILoginInfo.idl
@@ -0,0 +1,160 @@
+/* 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;
+
+ /**
+ * True if the login has ever been synced at some point.
+ */
+ attribute boolean everSynced;
+
+ /**
+ * A counter used to indicate that syncing is occuring. It will get restored to 0
+ * once syncing is complete.
+ */
+ attribute long syncCounter;
+
+ /**
+ * 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..d9c1571485
--- /dev/null
+++ b/toolkit/components/passwordmgr/nsILoginManager.idl
@@ -0,0 +1,326 @@
+/* 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<nsILoginInfo> 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. Decryption is handled in
+ * one batch.
+ *
+ * @return A promise which resolves with a JS Array of nsILoginInfo objects.
+ */
+ Promise getAllLogins();
+
+ /**
+ * Like getAllLogins, but with a callback returning the search results.
+ *
+ * @param {nsILoginSearchCallback} aCallback
+ * The interface to notify when the search is complete.
+ *
+ */
+ void getAllLoginsWithCallback(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<AString> 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. This function is retained
+ * for Thunderbird compatibility.
+ *
+ * @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<nsILoginInfo> 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<nsILoginInfo> 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<nsILoginInfo> logins,
+ in nsILoginInfo aNewLogin);
+};
+%{C++
+
+#define NS_LOGINMANAGERPROMPTER_CONTRACTID "@mozilla.org/login-manager/prompter/;1"
+
+%}
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-desktop.sys.mjs b/toolkit/components/passwordmgr/storage-desktop.sys.mjs
new file mode 100644
index 0000000000..eaa72ae4c8
--- /dev/null
+++ b/toolkit/components/passwordmgr/storage-desktop.sys.mjs
@@ -0,0 +1,20 @@
+/* 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 { LoginManagerStorage_json } from "resource://gre/modules/storage-json.sys.mjs";
+
+export class LoginManagerStorage extends LoginManagerStorage_json {
+ static #storage = null;
+
+ static create(callback) {
+ if (!LoginManagerStorage.#storage) {
+ LoginManagerStorage.#storage = new LoginManagerStorage();
+ LoginManagerStorage.#storage.initialize().then(callback);
+ } else if (callback) {
+ callback();
+ }
+
+ return LoginManagerStorage.#storage;
+ }
+}
diff --git a/toolkit/components/passwordmgr/storage-geckoview.sys.mjs b/toolkit/components/passwordmgr/storage-geckoview.sys.mjs
new file mode 100644
index 0000000000..db2ef6d61b
--- /dev/null
+++ b/toolkit/components/passwordmgr/storage-geckoview.sys.mjs
@@ -0,0 +1,245 @@
+/* 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/. */
+
+/**
+ * LoginManagerStorage implementation for GeckoView
+ */
+
+import { LoginManagerStorage_json } from "resource://gre/modules/storage-json.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
+ LoginEntry: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+});
+
+export class LoginManagerStorage extends LoginManagerStorage_json {
+ static #storage = null;
+
+ static create(callback) {
+ if (!LoginManagerStorage.#storage) {
+ LoginManagerStorage.#storage = new LoginManagerStorage();
+ LoginManagerStorage.#storage.initialize().then(callback);
+ } else if (callback) {
+ callback();
+ }
+
+ return LoginManagerStorage.#storage;
+ }
+
+ 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() {}
+
+ async addLoginsAsync(logins, continueOnDuplicates = false) {
+ 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)
+ );
+ }
+
+ /**
+ * Returns a promise resolving to an array of all saved logins that can be decrypted.
+ *
+ * @resolve {nsILoginInfo[]}
+ */
+ getAllLogins(includeDeleted) {
+ return this._getLoginsAsync({}, includeDeleted);
+ }
+
+ async searchLoginsAsync(matchData, includeDeleted) {
+ this.log(
+ `Searching for matching saved logins for origin: ${matchData.origin}`
+ );
+ return this._getLoginsAsync(matchData, includeDeleted);
+ }
+
+ _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, includeDeleted) {
+ 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,
+ includeDeleted,
+ 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);
+ }
+}
+
+ChromeUtils.defineLazyGetter(LoginManagerStorage.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..36cc1c3c88
--- /dev/null
+++ b/toolkit/components/passwordmgr/storage-json.sys.mjs
@@ -0,0 +1,1056 @@
+/* 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/. */
+
+/**
+ * LoginManagerStorage implementation for the JSON back-end.
+ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.sys.mjs",
+ FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ LoginStore: "resource://gre/modules/LoginStore.sys.mjs",
+});
+
+const SYNCABLE_LOGIN_FIELDS = [
+ // `nsILoginInfo` fields.
+ "hostname",
+ "formSubmitURL",
+ "httpRealm",
+ "username",
+ "password",
+ "usernameField",
+ "passwordField",
+
+ // `nsILoginMetaInfo` fields.
+ "timeCreated",
+ "timePasswordChanged",
+];
+
+// Compares two logins to determine if their syncable fields changed. The login
+// manager fires `modifyLogin` for changes to all fields, including ones we
+// don't sync. In particular, `timeLastUsed` changes shouldn't mark the login
+// for upload; otherwise, we might overwrite changed passwords before they're
+// downloaded (bug 973166).
+function isSyncableChange(oldLogin, newLogin) {
+ oldLogin.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo);
+ newLogin.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo);
+ return SYNCABLE_LOGIN_FIELDS.some(prop => oldLogin[prop] != newLogin[prop]);
+}
+
+// Returns true if the argument is for the FxA login.
+function isFXAHost(login) {
+ return login.hostname == lazy.FXA_PWDMGR_HOST;
+}
+
+export class LoginManagerStorage_json {
+ constructor() {
+ this.__crypto = null; // nsILoginManagerCrypto service
+ this.__decryptedPotentiallyVulnerablePasswords = null;
+ }
+
+ 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();
+ }
+
+ #incrementSyncCounter(login) {
+ login.syncCounter++;
+ }
+
+ async resetSyncCounter(guid, value) {
+ this._store.ensureDataReady();
+
+ // This will also find deleted items.
+ let login = this._store.data.logins.find(login => login.guid == guid);
+ if (login?.syncCounter > 0) {
+ login.syncCounter = Math.max(0, login.syncCounter - value);
+ login.everSynced = true;
+ }
+
+ this._store.saveSoon();
+ }
+
+ // Returns false if the login has marked as deleted or doesn't exist.
+ loginIsDeleted(guid) {
+ let login = this._store.data.logins.find(l => l.guid == guid);
+ return !!login?.deleted;
+ }
+
+ // Synrhronuously stores encrypted login, returns login clone with upserted
+ // uuid and updated timestamps
+ #addLogin(login) {
+ this._store.ensureDataReady();
+
+ // Throws if there are bogus values.
+ lazy.LoginHelper.checkLoginValues(login);
+
+ // 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;
+ }
+
+ // If the everSynced is already set, then this login is an incoming
+ // sync record, so there is no need to mark this as needed to be synced.
+ if (!loginClone.everSynced && !isFXAHost(loginClone)) {
+ this.#incrementSyncCounter(loginClone);
+ }
+
+ 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: loginClone.username,
+ encryptedPassword: loginClone.password,
+ guid: loginClone.guid,
+ encType: this._crypto.defaultEncType,
+ timeCreated: loginClone.timeCreated,
+ timeLastUsed: loginClone.timeLastUsed,
+ timePasswordChanged: loginClone.timePasswordChanged,
+ timesUsed: loginClone.timesUsed,
+ syncCounter: loginClone.syncCounter,
+ everSynced: loginClone.everSynced,
+ encryptedUnknownFields: loginClone.unknownFields,
+ });
+ this._store.saveSoon();
+
+ return loginClone;
+ }
+
+ async addLoginsAsync(logins, continueOnDuplicates = false) {
+ if (logins.length === 0) {
+ return logins;
+ }
+
+ const encryptedLogins = await this.#encryptLogins(logins);
+
+ const resultLogins = [];
+ for (const [login, encryptedLogin] of encryptedLogins) {
+ // check for duplicates
+ let loginData = {
+ origin: login.origin,
+ formActionOrigin: login.formActionOrigin,
+ httpRealm: login.httpRealm,
+ };
+ const existingLogins = await Services.logins.searchLoginsAsync(loginData);
+
+ const matchingLogin = existingLogins.find(l => login.matches(l, true));
+ if (matchingLogin) {
+ if (continueOnDuplicates) {
+ continue;
+ } else {
+ throw lazy.LoginHelper.createLoginAlreadyExistsError(
+ matchingLogin.guid
+ );
+ }
+ }
+
+ const resultLogin = this.#addLogin(encryptedLogin);
+
+ // restore unencrypted username and password for use in `addLogin` event
+ // and return value
+ resultLogin.username = login.username;
+ resultLogin.password = login.password;
+
+ // Send a notification that a login was added.
+ lazy.LoginHelper.notifyStorageChanged("addLogin", resultLogin);
+
+ resultLogins.push(resultLogin);
+ }
+
+ return resultLogins;
+ }
+
+ removeLogin(login, fromSync) {
+ 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) {
+ let login = this._store.data.logins[foundIndex];
+ if (!login.deleted) {
+ if (fromSync) {
+ this.#replaceLoginWithTombstone(login);
+ } else if (login.everSynced) {
+ // The login has been synced, so mark it as deleted.
+ this.#incrementSyncCounter(login);
+ this.#replaceLoginWithTombstone(login);
+ } else {
+ // The login was never synced, so just remove it from the data.
+ this._store.data.logins.splice(foundIndex, 1);
+ }
+
+ this._store.saveSoon();
+ }
+ }
+
+ lazy.LoginHelper.notifyStorageChanged("removeLogin", storedLogin);
+ }
+
+ modifyLogin(oldLogin, newLoginData, fromSync) {
+ 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 loginData = {
+ origin: newLogin.origin,
+ formActionOrigin: newLogin.formActionOrigin,
+ httpRealm: newLogin.httpRealm,
+ };
+
+ let logins = this.searchLogins(
+ lazy.LoginHelper.newPropertyBag(loginData)
+ );
+
+ let matchingLogin = logins.find(login => newLogin.matches(login, true));
+ if (matchingLogin) {
+ throw lazy.LoginHelper.createLoginAlreadyExistsError(
+ matchingLogin.guid
+ );
+ }
+ }
+
+ // Don't sync changes to the accounts password or when changes were only
+ // made to fields that should not be synced.
+ if (
+ !fromSync &&
+ !isFXAHost(newLogin) &&
+ isSyncableChange(oldLogin, newLogin)
+ ) {
+ this.#incrementSyncCounter(newLogin);
+ }
+
+ // 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.deleted) {
+ 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;
+ loginItem.syncCounter = newLogin.syncCounter;
+ this._store.saveSoon();
+ break;
+ }
+ }
+
+ lazy.LoginHelper.notifyStorageChanged("modifyLogin", [
+ oldStoredLogin,
+ newLogin,
+ ]);
+ }
+
+ // Replace the login with a tombstone. It has a guid and sync-related properties,
+ // but does not contain the login or password information.
+ #replaceLoginWithTombstone(login) {
+ login.deleted = true;
+
+ // Delete all fields except guid, timePasswordChanged, syncCounter
+ // and everSynced;
+ delete login.hostname;
+ delete login.httpRealm;
+ delete login.formSubmitURL;
+ delete login.usernameField;
+ delete login.passwordField;
+ delete login.encryptedUsername;
+ delete login.encryptedPassword;
+ delete login.encType;
+ delete login.timeCreated;
+ delete login.timeLastUsed;
+ delete login.timesUsed;
+ delete login.encryptedUnknownFields;
+ }
+
+ 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;
+ }
+
+ /**
+ * 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 getAllLogins(includeDeleted) {
+ this._store.ensureDataReady();
+
+ let [logins] = this._searchLogins({}, includeDeleted);
+ if (!logins.length) {
+ return [];
+ }
+
+ return this.#decryptLogins(logins);
+ }
+
+ async searchLoginsAsync(matchData, includeDeleted) {
+ this.log(`Searching for matching logins for origin ${matchData.origin}.`);
+ let result = this.searchLogins(
+ lazy.LoginHelper.newPropertyBag(matchData),
+ includeDeleted
+ );
+ // 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, includeDeleted) {
+ 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, includeDeleted, 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.
+ *
+ * formActionOrigin is handled specially for compatibility. If a null string
+ * is passed and other match fields are present, it is treated as if it was
+ * not present.
+ *
+ * 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,
+ includeDeleted = false,
+ aOptions = {
+ schemeUpgrades: false,
+ acceptDifferentSubdomains: false,
+ acceptRelatedRealms: false,
+ relatedRealms: [],
+ },
+ candidateLogins = this._store.data.logins
+ ) {
+ 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 == "" ||
+ (wantedValue == "" && Object.keys(matchData).length != 1)
+ ) {
+ 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":
+ case "syncCounter":
+ case "everSynced":
+ 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 (loginItem.deleted && !includeDeleted) {
+ continue; // skip deleted items
+ }
+
+ 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;
+ login.syncCounter = loginItem.syncCounter;
+ login.everSynced = loginItem.everSynced;
+
+ // 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.#removeLogins(false, true);
+ }
+
+ /**
+ * 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
+ *
+ * @param fullyRemove remove the logins rather than mark them deleted.
+ */
+ removeAllUserFacingLogins(fullyRemove) {
+ this.#removeLogins(fullyRemove, false);
+ }
+
+ /**
+ * Removes all logins from storage. If removeFXALogin is true, then the FxA Sync
+ * key is also removed.
+ *
+ * @param fullyRemove remove the logins rather than mark them deleted.
+ * @param removeFXALogin also remove the FxA Sync key.
+ */
+ #removeLogins(fullyRemove, removeFXALogin = false) {
+ this._store.ensureDataReady();
+ this.log("Removing all logins.");
+
+ let removedLogins = [];
+ let remainingLogins = [];
+ for (let login of this._store.data.logins) {
+ if (
+ !removeFXALogin &&
+ isFXAHost(login) &&
+ login.httpRealm == lazy.FXA_PWDMGR_REALM
+ ) {
+ remainingLogins.push(login);
+ } else {
+ removedLogins.push(login);
+ if (!fullyRemove && login?.everSynced) {
+ // The login has been synced, so mark it as deleted.
+ this.#incrementSyncCounter(login);
+ this.#replaceLoginWithTombstone(login);
+ remainingLogins.push(login);
+ }
+ }
+ }
+ this._store.data.logins = remainingLogins;
+
+ this._store.data.potentiallyVulnerablePasswords = [];
+ this.__decryptedPotentiallyVulnerablePasswords = null;
+ this._store.data.dismissedBreachAlertsByLoginGUID = {};
+ this._store.saveSoon();
+
+ lazy.LoginHelper.notifyStorageChanged("removeAllLogins", removedLogins);
+ }
+
+ 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);
+ }
+
+ /*
+ * Asynchronously encrypt multiple logins.
+ * Returns a promise resolving to an array of arrays containing two entries:
+ * the original login and a clone with encrypted properties.
+ */
+ async #encryptLogins(logins) {
+ if (logins.length === 0) {
+ return logins;
+ }
+
+ const plaintexts = logins.reduce(
+ (memo, { username, password, unknownFields }) =>
+ memo.concat([username, password, unknownFields]),
+ []
+ );
+ const ciphertexts = await this._crypto.encryptMany(plaintexts);
+
+ return logins.map((login, i) => {
+ const [encryptedUsername, encryptedPassword, encryptedUnknownFields] =
+ ciphertexts.slice(3 * i, 3 * i + 3);
+
+ const encryptedLogin = login.clone();
+ encryptedLogin.username = encryptedUsername;
+ encryptedLogin.password = encryptedPassword;
+ encryptedLogin.unknownFields = encryptedUnknownFields;
+
+ return [login, encryptedLogin];
+ });
+ }
+
+ /*
+ * Asynchronously decrypt multiple logins.
+ * Returns a promise resolving to an array of clones with decrypted properties.
+ */
+ async #decryptLogins(logins) {
+ if (logins.length === 0) {
+ return logins;
+ }
+
+ const ciphertexts = logins.reduce(
+ (memo, { username, password, unknownFields }) =>
+ memo.concat([username, password, unknownFields]),
+ []
+ );
+ const plaintexts = await this._crypto.decryptMany(ciphertexts);
+
+ return logins
+ .map((login, i) => {
+ // Deleted logins don't have any info to decrypt.
+ const decryptedLogin = login.clone();
+ if (this.loginIsDeleted(login.guid)) {
+ return decryptedLogin;
+ }
+
+ const [username, password, unknownFields] = plaintexts.slice(
+ 3 * i,
+ 3 * i + 3
+ );
+
+ // 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.
+ if (!username || !password) {
+ try {
+ this._crypto.decrypt(login.username);
+ this._crypto.decrypt(login.password);
+ } catch (e) {
+ // If decryption failed (corrupt entry?), just return it as it is.
+ // 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
+ }.`
+ );
+ return null;
+ }
+ throw e;
+ }
+ }
+
+ decryptedLogin.username = username;
+ decryptedLogin.password = password;
+ decryptedLogin.unknownFields = unknownFields;
+
+ return decryptedLogin;
+ })
+ .filter(Boolean);
+ }
+
+ /**
+ * 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) {
+ if (this.loginIsDeleted(login.guid)) {
+ result.push(login);
+ continue;
+ }
+
+ 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;
+ }
+}
+
+ChromeUtils.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..94e48aac6a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/LoginTestUtils.sys.mjs
@@ -0,0 +1,662 @@
+/* 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.
+ */
+ async checkLogins(expectedLogins, msg = "checkLogins", checkFn = undefined) {
+ this.assertLoginListsEqual(
+ await 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"
+ ),
+ // null formActionOrigin, empty httpRealm
+ new LoginInfo(
+ "http://example.net",
+ null,
+ "",
+ "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("<html xmlns='http://www.w3.org/1999/xhtml'>");
+ response.write(
+ "<p>Login: <span id='ok'>" +
+ (requestAuth ? "FAIL" : "PASS") +
+ "</span></p>\n"
+ );
+ response.write(
+ "<p>Proxy: <span id='proxy'>" +
+ (requestProxyAuth ? "FAIL" : "PASS") +
+ "</span></p>\n"
+ );
+ response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n");
+ response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n");
+ response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n");
+
+ if (huge) {
+ response.write("<div style='display: none'>");
+ for (let i = 0; i < 100000; i++) {
+ response.write("123456789\n");
+ }
+ response.write("</div>");
+ response.write(
+ "<span id='footnote'>This is a footnote after the huge content fill</span>"
+ );
+ }
+
+ if (plugin) {
+ response.write(
+ "<embed id='embedtest' style='width: 400px; height: 100px;' " +
+ "type='application/x-test'></embed>\n"
+ );
+ }
+
+ response.write("</html>");
+}
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ </body>
+</html>
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("<html xmlns='http://www.w3.org/1999/xhtml'>");
+ response.write(
+ "<p>Login: <span id='ok'>" +
+ (requestAuth ? "FAIL" : "PASS") +
+ "</span></p>\n"
+ );
+ response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n");
+ response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n");
+ response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n");
+ response.write("</html>");
+}
diff --git a/toolkit/components/passwordmgr/test/browser/browser.toml b/toolkit/components/passwordmgr/test/browser/browser.toml
new file mode 100644
index 0000000000..53066c92d4
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser.toml
@@ -0,0 +1,248 @@
+[DEFAULT]
+support-files = [
+ "../formsubmit.sjs",
+ "authenticate.sjs",
+ "empty.html",
+ "form_basic.html",
+ "form_basic_iframe.html",
+ "form_basic_login.html",
+ "form_basic_login_fields_with_max_length.html",
+ "form_basic_no_username.html",
+ "form_basic_password_autocomplete_off.html",
+ "form_basic_password_autocomplete_new_password.html",
+ "form_basic_prefilled_password.html",
+ "form_basic_prefilled_username.html",
+ "form_basic_signup.html",
+ "form_cross_origin_insecure_action.html",
+ "form_cross_origin_secure_action.html",
+ "form_crossframe_no_outer_login_form.html",
+ "form_expanded.html",
+ "form_multipage.html",
+ "form_multiple_passwords.html",
+ "form_same_origin_action.html",
+ "form_unmasked_password_after_pageload.html",
+ "form_username_only.html",
+ "formless_basic.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'", # Bug 1337606
+ "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
+
+["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"]
+fail-if = ["a11y_checks"] # Bug 1854452 clicked button may not be focusable
+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"]
+fail-if = ["a11y_checks"] # Bug 1854452 clicked password-notification-username-dropmaker may not be focusable and/or labeled
+
+["browser_doorhanger_autofill_then_save_password.js"]
+
+["browser_doorhanger_crossframe.js"]
+support-files = [
+ "form_crossframe.html",
+ "form_crossframe_inner.html",
+]
+
+["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",
+]
+skip-if = [
+ "os == 'linux'", # Bug 1729196
+ "win11_2009 && bits == 64", # Bug 1729196
+ "os == 'mac' && debug", # Bug 1729196
+]
+
+["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"]
+skip-if = ["a11y_checks"] # Bugs 1858041, 1854454 and 1824058 for causing intermittent crashes
+
+["browser_doorhanger_submit_telemetry.js"]
+fail-if = ["a11y_checks"] # Bug 1854452 clicked dropmaker may not be focusable
+skip-if = [
+ "tsan", # Bug 1661305
+ "os == 'linux' && debug", # Bug 1658056
+ "os == 'linux' && asan", # Bug 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_glean_pwmgr_form_autofill_result.js"]
+
+["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
+]
+
+["browser_message_onFormSubmit.js"]
+
+["browser_openPasswordManager.js"]
+
+["browser_preselect_login.js"]
+fail-if = ["a11y_checks"] # Bug 1854452 clicked ac-secondary-action may not be labeled
+skip-if = [
+ "os == 'linux' && (asan || tsan || debug)", # Bug 1840479
+ "os == 'win' && (asan || debug)", # Bug 1840479
+]
+
+["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"]
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..a7dfe071dc
--- /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.startLoadingURIString(
+ tab.linkedBrowser,
+ `data:text/html;charset=utf-8,
+ <html><body>
+ <form id="${ids.FORM1_ID}">
+ <input id="${ids.CHANGE_INPUT_ID}">
+ </form>
+ <form id="${ids.FORM2_ID}"></form>
+ </body></html>`
+ );
+ 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..fbc8a7a73a
--- /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.startLoadingURIString(
+ tab.linkedBrowser,
+ `data:text/html;charset=utf-8,
+ <html><body>
+ <form id="${ids.FORM1_ID}">
+ <input id="${ids.CHANGE_INPUT_ID}">
+ </form>
+ <form id="${ids.FORM2_ID}"></form>
+ </body></html>`
+ );
+ 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.startLoadingURIString(
+ tab.linkedBrowser,
+ `data:text/html;charset=utf-8,
+ <html><body>
+ <form id="${ids.FORM1_ID}">
+ <input id="${ids.CHANGE_INPUT_ID}">
+ </form>
+ <form id="${ids.FORM2_ID}"></form>
+ </body></html>`
+ );
+ 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.startLoadingURIString(
+ tab.linkedBrowser,
+ `data:text/html;charset=utf-8,
+ <html><body>
+ <form id="${ids.FORM1_ID}">
+ <input id="${ids.CHANGE_INPUT_ID}">
+ </form>
+ <form id="${ids.FORM2_ID}"></form>
+ </body></html>`
+ );
+ 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..fe7e849baa
--- /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.startLoadingURIString(
+ tab.linkedBrowser,
+ `data:text/html;charset=utf-8,
+ <html><body>
+ <input id="${consts.CHANGE_INPUT_ID}" />
+ </body>
+ </html>
+ `
+ );
+ 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..7d8ec9f016
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_import.js
@@ -0,0 +1,258 @@
+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 wizardTab;
+
+ 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);
+
+ EventUtils.synthesizeMouseAtCenter(importableItem, {});
+
+ wizardTab = await wizardPromise;
+ Assert.ok(wizardTab, "Wizard opened");
+ Assert.equal(
+ gTestMigrator.migrate.callCount,
+ 0,
+ "Direct migrate not used"
+ );
+
+ await closePopup(popup);
+ }
+ );
+
+ // Close the wizard tab 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.removeTab(wizardTab);
+});
+
+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..14ef8860e8
--- /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.startLoadingURIString(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.startLoadingURIString(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.startLoadingURIString(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..8f5c2e3f45
--- /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 = await 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"
+ );
+ await 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..0069c653a5
--- /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.startLoadingURIString(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..1c12a8fec5
--- /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.startLoadingURIString(
+ 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.startLoadingURIString(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..8fddea4c93
--- /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.startLoadingURIString(
+ 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..5f97a91324
--- /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-password",
+ "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..41e99443c0
--- /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 = await 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.isVisible(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.isVisible(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.isVisible(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 = await 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..f997cf3a72
--- /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.isVisible(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..a122f52845
--- /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.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.sys.mjs",
+ FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.sys.mjs",
+});
+
+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..2177a3e5e0
--- /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.isVisible(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..b30b575752
--- /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"
+ );
+ }
+ );
+
+ const 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);
+ const notif = await waitForDoorhanger(browser, expectedDoorhanger);
+
+ await checkDoorhangerUsernamePassword(expectedUsername, expectedPassword);
+
+ const 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..23afd2c6ab
--- /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 await 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.isVisible(checkbox), "Toggle checkbox visible as expected");
+ } else if (expected.toggle == "hidden") {
+ Assert.ok(
+ BrowserTestUtils.isHidden(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..3740dad1b9
--- /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(
+ (await 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 = await 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] = await Services.logins.getAllLogins();
+ info("waiting for submitForm");
+ await submitForm(browser);
+ await storageChangedPromise;
+ await 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] = await Services.logins.getAllLogins();
+ info("waiting for submitForm");
+ await submitForm(browser);
+ await storageChangedPromise;
+ await 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] = await 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;
+ await 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] = await 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;
+ await 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] = await 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;
+ await 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] = await 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;
+ await 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] = await 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;
+
+ await 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;
+ await 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
+ await 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;
+ await 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
+ await 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 } = (await Services.logins.getAllLogins())[2];
+
+ info("waiting for submitForm");
+ await submitForm(browser);
+
+ info("Waiting for modifyLogin");
+ await storageChangedPromise;
+ await 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] = await 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
+ await 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] = await 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
+ await 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] = await 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
+ await 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] = await 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..0f4ca8491f
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js
@@ -0,0 +1,310 @@
+/*
+ * 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 = await 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 = await 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 = await 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
+
+ await checkOnlyLoginWasUsedTwice({ justChanged: true });
+ logins = await 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 = await 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 = await 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);
+
+ const storageChangedPromise = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+
+ 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(
+ (await Services.logins.getAllLogins()).length,
+ 1,
+ "Should only have the HTTPS login"
+ );
+
+ await checkDoorhangerUsernamePassword("notifyu1", "notifyp1");
+ clickDoorhangerButton(notif, REMEMBER_BUTTON);
+ }
+ );
+
+ await storageChangedPromise;
+
+ let logins = await 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 = await 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..7aace425b9
--- /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.
+ await 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..414208eaa5
--- /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;
+ }
+ await 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..0911378d66
--- /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 = await 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");
+ await 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..93a16c2e3c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js
@@ -0,0 +1,1295 @@
+/*
+ * 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(
+ (await 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(
+ (await Services.logins.getAllLogins()).length,
+ 0,
+ "Should not have any logins yet"
+ );
+});
+
+add_task(async function test_clickRemember() {
+ const storageChangedPromise = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+
+ 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(
+ (await 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;
+ }
+ );
+
+ await storageChangedPromise;
+
+ let logins = await 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 = await 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(
+ (await 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(
+ (await 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(
+ (await 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(
+ (await 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(
+ (await 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(
+ (await 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 = await 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 = await 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 = await 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 = await 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 password for example.com?",
+ "Check message"
+ );
+
+ await checkDoorhangerUsernamePassword("notifyu1", "pass2");
+ clickDoorhangerButton(notif, DONT_CHANGE_BUTTON);
+ }
+ );
+
+ let logins = await 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 password 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-box"
+ );
+ }
+ );
+
+ let logins = await 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 password 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 = await 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");
+
+ await 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 = await 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 = await 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 password for example.com?";
+ Assert.equal(
+ notificationText,
+ expectedText,
+ "Checking text: " + notificationText
+ );
+ await cleanupDoorhanger(notif);
+ }
+ );
+
+ Assert.equal(
+ (await 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(
+ (await 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(
+ (await 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 = await 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 = await 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 = await 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");
+
+ await 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 = await 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");
+
+ await 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."
+ );
+
+ const storageChangedPromise = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+
+ 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 = await 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
+
+ await storageChangedPromise;
+
+ let logins = await 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"
+ );
+
+ await checkOnlyLoginWasUsedTwice({ justChanged: false });
+ let logins = await 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) {
+ const 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(
+ (await 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 = await 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..50316094b0
--- /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.
+ await 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..39b98db93b
--- /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 = await 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 <input> 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..e6c09c0382
--- /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.isVisible(passwordTextbox),
+ "The doorhanger password field is visible"
+ );
+
+ await checkDoorhangerUsernamePassword(expected.username, expected.password);
+ if (expected.toggleVisible) {
+ Assert.ok(
+ BrowserTestUtils.isVisible(toggleCheckbox),
+ "The visibility checkbox is shown"
+ );
+ } else {
+ Assert.ok(
+ BrowserTestUtils.isHidden(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..ea0437955d
--- /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 = await 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 = await 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 = await 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 = await 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 = await 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..e400dc4637
--- /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.isVisible(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.isVisible(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.isVisible(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..6a198e021a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js
@@ -0,0 +1,72 @@
+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);
+
+ const storageChangedPromise = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+
+ await testSubmittingLoginFormHTTP(
+ "subtst_notifications_1.html",
+ async () => {
+ const notif = await getCaptureDoorhangerThatMayOpen("password-save");
+ await clickDoorhangerButton(notif, REMEMBER_BUTTON);
+ }
+ );
+
+ await storageChangedPromise;
+
+ const loginEntries = (await 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 = (await 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..bb7e973db6
--- /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.startLoadingURIString(
+ 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.startLoadingURIString(
+ 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_glean_pwmgr_form_autofill_result.js b/toolkit/components/passwordmgr/test/browser/browser_glean_pwmgr_form_autofill_result.js
new file mode 100644
index 0000000000..41ca5c85e9
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_glean_pwmgr_form_autofill_result.js
@@ -0,0 +1,186 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativ.orgmons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Testing every label of the labeled counter pwmgr.form_autofill_result (Glean metric)
+ */
+
+"use strict";
+
+const { AUTOFILL_RESULT } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerChild.sys.mjs"
+);
+const gAutofillLabels = Object.values(AUTOFILL_RESULT);
+
+const gLogin = LoginTestUtils.testData.formLogin({
+ origin: "https://example.org",
+ formActionOrigin: "https://example.org/",
+ username: "username1",
+ password: "password1",
+});
+const gMultipleLogin = LoginTestUtils.testData.formLogin({
+ origin: "https://example.org",
+ formActionOrigin: "https://example.org/",
+ username: "username2",
+ password: "password2",
+});
+const gLoginInsecureAction = LoginTestUtils.testData.formLogin({
+ origin: "https://example.org",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ formActionOrigin: "http://example.org/",
+ username: "username3",
+ password: "password3",
+});
+
+const TEST_CASES = [
+ {
+ description: "Autofill result - filled",
+ autofill_result: "filled",
+ test_url: `https://example.org${DIRECTORY_PATH}form_basic_login.html`,
+ },
+ {
+ description: "Autofill result - no_password_field",
+ autofill_result: "no_password_field",
+ test_url: `https://example.org${DIRECTORY_PATH}form_multiple_passwords.html`,
+ },
+ {
+ description: "Autofill result - password_disabled_readonly",
+ autofill_result: "password_disabled_readonly",
+ test_url: `https://example.org${DIRECTORY_PATH}form_disabled_readonly_passwordField.html`,
+ form_processed_count: 2,
+ metric_count: 2,
+ },
+ {
+ description: "Autofill results no_logins_fit",
+ autofill_result: "no_logins_fit",
+ test_url: `https://example.org${DIRECTORY_PATH}form_basic_login_fields_with_max_length.html`,
+ metric_count: 2,
+ },
+ {
+ description: "Autofill results - no_saved_logins",
+ autofill_result: "no_saved_logins",
+ test_url: `https://example.com${DIRECTORY_PATH}form_basic_login.html`,
+ },
+ {
+ description: "Autofill results - existing_password",
+ autofill_result: "existing_password",
+ test_url: `https://example.org${DIRECTORY_PATH}form_basic_prefilled_password.html`,
+ },
+ {
+ description: "Autofill results - existing_username",
+ autofill_result: "existing_username",
+ test_url: `https://example.org${DIRECTORY_PATH}form_basic_prefilled_username.html`,
+ },
+ {
+ description: "Autofill results - multiple_logins",
+ autofill_result: "multiple_logins",
+ test_url: `https://example.org${DIRECTORY_PATH}form_basic_login.html`,
+ extra_login: gMultipleLogin,
+ },
+ {
+ description: "Autofill results - no_autofill_forms",
+ autofill_result: "no_autofill_forms",
+ test_url: `https://example.org${DIRECTORY_PATH}form_basic_login.html`,
+ prefs: [["signon.autofillForms", false]],
+ },
+ {
+ description: "Autofill results - autocomplete_off",
+ autofill_result: "autocomplete_off",
+ test_url: `https://example.org${DIRECTORY_PATH}form_basic_password_autocomplete_off.html`,
+ prefs: [["signon.autofillForms.autocompleteOff", false]],
+ },
+ {
+ description: "Autofill results - insecure",
+ autofill_result: "insecure",
+ test_url: `https://example.org${DIRECTORY_PATH}form_cross_origin_insecure_action.html`,
+ extra_login: gLoginInsecureAction,
+ },
+ {
+ description: "Autofill results - password_autocomplete_new_password",
+ autofill_result: "password_autocomplete_new_password",
+ test_url: `https://example.org${DIRECTORY_PATH}form_basic_password_autocomplete_new_password.html`,
+ },
+ {
+ description: "Autofill results - type_no_longer_password",
+ autofill_result: "type_no_longer_password",
+ test_url: `https://example.org${DIRECTORY_PATH}form_unmasked_password_after_pageload.html`,
+ },
+ {
+ description: "Autofill results - form_in_crossorigin_subframe",
+ autofill_result: "form_in_crossorigin_subframe",
+ test_url: `https://example.com${DIRECTORY_PATH}form_crossframe_no_outer_login_form.html`,
+ },
+ {
+ description: "Autofill result - filled_username_only_form",
+ autofill_result: "filled_username_only_form",
+ test_url: `https://example.org${DIRECTORY_PATH}form_username_only.html`,
+ },
+];
+
+add_setup(async () => {
+ await Services.logins.addLoginAsync(gLogin);
+});
+
+async function verifyAutofillResults(testCase) {
+ info(`Test case: ${testCase.description}`);
+
+ if (testCase.prefs) {
+ await SpecialPowers.pushPrefEnv({
+ set: testCase.prefs,
+ });
+ }
+ if (testCase.extra_login) {
+ await Services.logins.addLoginAsync(testCase.extra_login);
+ }
+
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ let formProcessed = listenForTestNotification(
+ "FormProcessed",
+ testCase.form_processed_count ?? 1
+ );
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ testCase.test_url
+ );
+
+ await formProcessed;
+
+ await Services.fog.testFlushAllChildren();
+
+ gBrowser.removeTab(tab);
+
+ gAutofillLabels.forEach(label => {
+ if (label != testCase.autofill_result) {
+ Assert.equal(
+ undefined,
+ Glean.pwmgr.formAutofillResult[label].testGetValue(),
+ `The counter for the label ${label} was not incremented.`
+ );
+ } else {
+ Assert.equal(
+ testCase.metric_count ?? 1,
+ Glean.pwmgr.formAutofillResult[testCase.autofill_result].testGetValue(),
+ `The counter for the label ${label} was incremented by ${
+ testCase.metric_count ?? 1
+ }.`
+ );
+ }
+ });
+
+ if (testCase.extra_login) {
+ await Services.logins.removeLogin(testCase.extra_login);
+ }
+
+ if (testCase.prefs) {
+ await SpecialPowers.popPrefEnv();
+ }
+}
+
+add_task(async function test_autofill_results() {
+ for (let tc of TEST_CASES) {
+ await verifyAutofillResults(tc);
+ }
+});
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..cc8bc63e7a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js
@@ -0,0 +1,52 @@
+"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 browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ const doc = content.document;
+ const { FormScenarios } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormScenarios.sys.mjs"
+ );
+
+ function assertSignUpForm(
+ message,
+ formId,
+ inputId,
+ expectedToBeSignUp
+ ) {
+ const isSignUpForm = !!FormScenarios.detect({
+ form: doc.getElementById(formId),
+ input: doc.getElementById(inputId),
+ }).signUpForm;
+ Assert.equal(isSignUpForm, expectedToBeSignUp, message);
+ }
+
+ assertSignUpForm(
+ "Obvious signup form is detected as sign up form",
+ "obvious-signup-form",
+ "obvious-signup-email",
+ true
+ );
+ assertSignUpForm(
+ "Obvious non signup form is detected as non sign up form",
+ "obvious-login-form",
+ "obvious-login-username",
+ false
+ );
+ assertSignUpForm(
+ "An <input> HTML element is detected as non sign up form",
+ "",
+ "standalone-username",
+ 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..af5d70e259
--- /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.gBrowser, "addTrustedTab");
+ const openingFunc = () =>
+ LoginHelper.openPasswordManager(window, {
+ filterString: "",
+ entryPoint: "mainmenu",
+ });
+ const passwordManager = await openPasswordManager(openingFunc);
+
+ const url = window.gBrowser.addTrustedTab.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.gBrowser.addTrustedTab.restore();
+ }
+);
+
+add_task(
+ async function test_url_when_opening_password_manager_with_a_filterString() {
+ sinon.spy(window.gBrowser, "addTrustedTab");
+ const openingFunc = () =>
+ LoginHelper.openPasswordManager(window, {
+ filterString: "testFilter",
+ entryPoint: "mainmenu",
+ });
+ const passwordManager = await openPasswordManager(openingFunc);
+
+ const url = window.gBrowser.addTrustedTab.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.gBrowser.addTrustedTab.restore();
+ }
+);
+
+add_task(
+ async function test_url_when_opening_password_manager_without_filterString_or_entryPoint() {
+ sinon.spy(window.gBrowser, "addTrustedTab");
+ const openingFunc = () =>
+ LoginHelper.openPasswordManager(window, {
+ filterString: "",
+ entryPoint: "",
+ });
+ const passwordManager = await openPasswordManager(openingFunc);
+
+ const url = window.gBrowser.addTrustedTab.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.gBrowser.addTrustedTab.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..5847e84282
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_preselect_login.js
@@ -0,0 +1,247 @@
+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.isVisible(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("login-list-item[aria-selected='true']")?.dataset
+ ?.guid === expectedGuid,
+ "Wait for login item to be selected"
+ );
+
+ Assert.equal(
+ loginList.querySelector("login-list-item[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 secondLoginItemSecondaryAction = secondLoginItem.querySelector(
+ ".ac-secondary-action"
+ );
+
+ Assert.ok(
+ !secondLoginItemSecondaryAction.checkVisibility({
+ checkVisibilityCSS: true,
+ }),
+ "Secondary action 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(
+ secondLoginItemSecondaryAction.checkVisibility({
+ checkVisibilityCSS: true,
+ }),
+ "Secondary action should be visible when item is active"
+ );
+
+ const aboutLoginsTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ url => url.includes(ABOUT_LOGINS_ORIGIN),
+ true
+ );
+
+ EventUtils.synthesizeMouseAtCenter(secondLoginItemSecondaryAction, {});
+ 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);
+ }
+ );
+ }
+);
+
+add_task(async function test_new_login_url_has_correct_hash() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:logins",
+ },
+ async function (gBrowser) {
+ await SpecialPowers.spawn(gBrowser, [], async () => {
+ const loginList =
+ content.document.querySelector("login-list").shadowRoot;
+ const createLoginButton = loginList
+ .querySelector("create-login-button")
+ .shadowRoot.querySelector("button");
+
+ createLoginButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ ContentTaskUtils.isVisible(
+ loginList.querySelector("#new-login-list-item")
+ ),
+ "Wait for new login-list-item to become visible"
+ );
+
+ Assert.equal(
+ content.location.hash,
+ "",
+ "Location hash should be empty"
+ );
+ });
+ }
+ );
+});
+
+add_task(async function test_no_logins_empty_url_hash() {
+ Services.logins.removeAllUserFacingLogins();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_URL_PATH,
+ },
+ async function () {
+ await waitForAppMenu();
+
+ const appMenuPasswordsButton = document.getElementById(
+ "appMenu-passwords-button"
+ );
+
+ const aboutLoginsTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ url => new URL(url).hash === "",
+ true
+ );
+
+ EventUtils.synthesizeMouseAtCenter(appMenuPasswordsButton, {});
+
+ const aboutLoginsTab = await aboutLoginsTabPromise;
+
+ 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..31fe82cf8b
--- /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.docViewer.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.startLoadingURIString(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(
+ (await 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(
+ (await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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 = await 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(
+ (await 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.startLoadingURIString(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(
+ (await 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((await 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(
+ (await 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..ea5a25db79
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js
@@ -0,0 +1,534 @@
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+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 collectRelayTelemeryEvent = sameFlow => {
+ const collectedEvents = TelemetryTestUtils.getEvents(
+ { category: "relay_integration" },
+ { process: "parent" }
+ );
+
+ return sameFlow
+ ? collectedEvents.filter((event, _, arr) => event.value === arr[0].value)
+ : collectedEvents;
+};
+
+const waitForEvents = async (expectedEvents, sameFlow) => {
+ await TestUtils.waitForCondition(
+ () =>
+ (collectRelayTelemeryEvent(sameFlow)?.length ?? 0) >=
+ (expectedEvents.length ?? 0),
+ "Wait for telemetry to be collected",
+ 100,
+ 100
+ );
+ return collectRelayTelemeryEvent(sameFlow);
+};
+
+async function assertEvents(expectedEvents, sameFlow = true) {
+ // To avoid intermittent failures, we wait for telemetry to be collected
+ const events = await waitForEvents(expectedEvents, sameFlow);
+ 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-value");
+
+ Assert.ok(
+ gRelayACOptionsTitles.some(title => title.value === popupItem),
+ "AC Popup has an item Relay option shown in popup"
+ );
+
+ const promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ popup.firstChild.getItemAtIndex(0).click();
+ await promiseHidden;
+}
+
+// Bug 1832782: On OSX opt verify mode, the test exceeds the default timeout.
+requestLongerTimeout(2);
+
+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 Promise.all([
+ notificationHidden,
+ BrowserTestUtils.waitForEvent(ConfirmationHint._panel, "popuphidden"),
+ TestUtils.waitForPrefChange("signon.firefoxRelay.feature"),
+ ]);
+
+ await assertEvents([
+ {
+ object: "offer_relay",
+ method: "shown",
+ extra: { scenario: "SignUpFormScenario" },
+ },
+ {
+ object: "offer_relay",
+ method: "clicked",
+ extra: { scenario: "SignUpFormScenario" },
+ },
+ { object: "opt_in_panel", method: "shown" },
+ { object: "opt_in_panel", method: "enabled" },
+ ]);
+
+ Services.telemetry.clearEvents();
+
+ // Retrigger AC popup
+ await SpecialPowers.spawn(browser, [], async function () {
+ const usernameInput = content.document.querySelector(
+ "#form-basic-username"
+ );
+ usernameInput.blur();
+ usernameInput.focus();
+ });
+
+ await assertEvents([
+ {
+ 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..dc8ac9c081
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js
@@ -0,0 +1,53 @@
+"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) {
+ 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;
+
+ 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..126ea20968
--- /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.docViewer.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 = await 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 = await 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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Empty file</title>
+</head>
+<body>
+</body>
+</html>
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(`
+ <!DOCTYPE html><html><body>
+ <form id="early_focus_form" action="https://autocomplete:8888/formtest.js">
+ <input type="text" id="uname" name="uname">
+ <input type="password" id="pword" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+ <script>document.querySelector("#uname").focus();</script>
+ `);
+
+ setTimeout(function finishOutput() {
+ response.write(`</body></html>`);
+ 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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head>
+<body>
+<form id="form-basic">
+ <input id="form-basic-username" name="username" autofocus>
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+<iframe src="/document-builder.sjs?html=<html><body>Hi</body></html>"></iframe>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head>
+<body onload="document.getElementById('form-basic-username').focus();">
+<!-- Username field is focused by js onload -->
+<form id="form-basic">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simplest form with username and password fields. -->
+<form id="form-basic">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+</body></html>
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 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="utf-8">
+</head>
+
+<body>
+ <!-- Form in an iframe -->
+ <iframe src="https://example.org/browser/toolkit/components/passwordmgr/test/browser/form_basic.html" id="test-iframe"></iframe>
+
+ <!-- Form in a fully sandboxed iframe -->
+ <iframe src="https://example.org/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"
+ sandbox=""
+ id="test-iframe-sandbox"></iframe>
+
+ <!-- Form in an "allow-same-origin" sandboxed iframe -->
+ <iframe src="https://example.org/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"
+ sandbox="allow-same-origin"
+ id="test-iframe-sandbox-same-origin"></iframe>
+</body>
+
+</html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simplest login form with username and password fields. -->
+<form id="form-basic-login">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit" value="sign in">
+</form>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_login_fields_with_max_length.html b/toolkit/components/passwordmgr/test/browser/form_basic_login_fields_with_max_length.html
new file mode 100644
index 0000000000..23c8efcc2a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_basic_login_fields_with_max_length.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Simplest login form with username's maxlength attribute set -->
+ <form id="form-basic-max-password">
+ <input id="form-basic-username" name="username" maxlength="5">
+ <input id="form-basic-password" name="password" type="password" >
+ <input id="form-basic-submit" type="submit">
+ </form>
+
+ <!-- Simplest login form with password's maxlength attribute set -->
+ <form id="form-basic-max-username">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password" maxlength="5">
+ <input id="form-basic-submit" type="submit">
+ </form>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simplest form with just password field. -->
+<form id="form-basic">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_password_autocomplete_new_password.html b/toolkit/components/passwordmgr/test/browser/form_basic_password_autocomplete_new_password.html
new file mode 100644
index 0000000000..53d821ddbb
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_basic_password_autocomplete_new_password.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Simplest login form with autocomplete="new-password" -->
+ <form id="form-basic-login">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password" autocomplete="new-password">
+ <input id="form-basic-submit" type="submit">
+ </form>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_password_autocomplete_off.html b/toolkit/components/passwordmgr/test/browser/form_basic_password_autocomplete_off.html
new file mode 100644
index 0000000000..3ecf66d0cc
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_basic_password_autocomplete_off.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Simplest login form with autocomplete="off" -->
+ <form id="form-basic-login">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password" autocomplete="off">
+ <input id="form-basic-submit" type="submit">
+ </form>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_prefilled_password.html b/toolkit/components/passwordmgr/test/browser/form_basic_prefilled_password.html
new file mode 100644
index 0000000000..7f2a8356f6
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_basic_prefilled_password.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Simplest login form with prefilled password field. -->
+ <form id="form-basic-login">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password" value="filled-secret-password">
+ <input id="form-basic-submit" type="submit">
+ </form>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_basic_prefilled_username.html b/toolkit/components/passwordmgr/test/browser/form_basic_prefilled_username.html
new file mode 100644
index 0000000000..23b1e16a98
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_basic_prefilled_username.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Simplest login form with prefilled username field. -->
+ <form id="form-basic-login">
+ <input id="form-basic-username" name="username" value="filled-username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+ </form>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<form id="form-basic-signup">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password" autocomplete="new-password">
+ <input id="form-basic-submit" type="submit" value="sign up">
+</form>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simplest form with username, password and confirm password fields. -->
+<form id="form-basic">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-confirm-password" name="confirm-password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simplest form with username and password fields. -->
+<form id="form-basic" action="http://example.org/custom_action.html">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simplest form with username and password fields. -->
+<form id="form-basic" action="https://example.org/custom_action.html">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+</body></html>
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 @@
+<html><body>
+
+<button onclick="window.location = 'about:blank'">Next</button>
+<iframe src="https://test2.example.org:443/browser/toolkit/components/passwordmgr/test/browser/form_crossframe_inner.html"
+ width="300" height="300"></iframe>
+<form id="outer-form" action="formsubmit.sjs">
+ <input id="outer-username" name="outer-username">
+ <input id="outer-password" name="outer-password" type="password">
+ <input id="outer-submit" type="submit">
+ <button id="outer-gobutton" onclick="this.location = 'about:blank'">Go</button>
+</form>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Form for use inside a frame. -->
+<form id="inner-form" action="formsubmit.sjs">
+ <input id="inner-username" name="username">
+ <input id="inner-password" name="password" type="password">
+ <input id="inner-submit" type="submit">
+</form>
+<button id="inner-gobutton" onclick="document.location = 'about:blank'">Go</button>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_crossframe_no_outer_login_form.html b/toolkit/components/passwordmgr/test/browser/form_crossframe_no_outer_login_form.html
new file mode 100644
index 0000000000..e98f43f48d
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_crossframe_no_outer_login_form.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html><html><head><meta charset=`utf-8`></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Crossframe with login form -->
+ <iframe src="https://test2.example.org:443/browser/toolkit/components/passwordmgr/test/browser/form_crossframe_inner.html"
+ width="600" height="600"></iframe>
+</body></html>
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 @@
+<!DOCTYPE html><body>
+ <form id="login_form_disabled_password">
+ <input type="text" name="username" autocomplete="username">
+ <input type="password" name="password" autocomplete="password" disabled>
+ <button type="submit">Log in</button>
+ </form>
+ <form id="login_form_readonly_password">
+ <input type="text" name="username" autocomplete="username">
+ <input type="password" name="password" autocomplete="password" readonly>
+ <button type="submit">Log in</button>
+ </form>
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Expanded page containing a form + several unrelated elements -->
+
+<form id="form-basic">
+ <input id="form-expanded-search">
+ <input id="form-expanded-username" name="username">
+ <input id="form-expanded-password" name="password" type="password">
+ <input id="form-expanded-submit" type="submit">
+ <input id="form-expanded-captcha">
+</form>
+<input id="form-expanded-non-form-input">
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Form with a username-only field. -->
+<form id="form-basic">
+ <input id="form-basic-username" type="text" name="username" autocomplete="username">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+<script>
+ const form = document.getElementById( "form-basic" );
+ form.addEventListener( "submit", function onFormSubmit(event) {
+ event.preventDefault()
+ document.getElementById("form-basic").remove();
+
+ // Create the password-only form after the username-only form is submitted.
+ var form = document.createElement("form");
+ form.id = "form-basic";
+ var password = document.createElement("input");
+ password.id = "form-basic-password";
+ password.type = "password";
+ form.appendChild(password);
+ var submit = document.createElement("input");
+ submit.id = "form-basic-submit";
+ submit.type = "submit";
+ form.appendChild(submit);
+ document.body.appendChild(form);
+ });
+</script>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_multiple_passwords.html b/toolkit/components/passwordmgr/test/browser/form_multiple_passwords.html
new file mode 100644
index 0000000000..303f88933e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_multiple_passwords.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Simplest form with password field count over threshold -->
+ <form id="form-basic-login">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+ </form>
+
+</body></html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for Login Manager notifications w/ new password</title>
+</head>
+<body>
+<h2>Test for Login Manager notifications w/ new password</h2>
+
+<form id="form" action="formsubmit.sjs">
+ <input id="pass" name="pass" type="password">
+ <input id="newpass" name="newpass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+</body>
+</html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simplest form with username and password fields. -->
+<form id="form-basic" action="./custom_action.html">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+</body></html>
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..96cf2967de
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_signup_detection.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+</head>
+<body>
+ <h1>Sign up</h1>
+ <form id="obvious-signup-form">
+ <label for="obvious-signup-email">Email</label>
+ <input id="obvious-signup-email" type="email">
+ <label for="obvious-signup-username">Username</label>
+ <input id="obvious-signup-username">
+ <label for="obvious-signup-password">Password</label>
+ <input id="obvious-signup-password" type="password" autocomplete="new-password">
+ <input id= "obvious-signup-privacyPolicy" type="checkbox">
+ <label for="obvious-signup-privacyPolicy">I have read and agree to the privacy policy.</label>
+ <input id="obvious-signup-submit" type="submit" value="Sign up">
+ </form>
+ <h1>Login</h1>
+ <form id="obvious-login-form">
+ <label for="obvious-login-username">Username</label>
+ <input id="obvious-login-username">
+ <label for="obvious-login-password">Password</label>
+ <input id="obvious-login-password" type="password" autocomplete="current-password">
+ <a>Password forgotten?</a>
+ <input id= "obvious-login-rememberMe" type="checkbox">
+ <label for="obvious-login-rememberMe">Remember me</label>
+ <input id="obvious-login-submit" type="submit" value="Login">
+ </form>
+ <h1>Standalone input</h1>
+ <label for="standalone-username">Username</label>
+ <input id="standalone-username">
+</body>
+</html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_unmasked_password_after_pageload.html b/toolkit/components/passwordmgr/test/browser/form_unmasked_password_after_pageload.html
new file mode 100644
index 0000000000..7a5acd4fd1
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_unmasked_password_after_pageload.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Unmasks the password field after page load -->
+ <body onload="document.getElementById(`form-basic-password`).type = ``">
+
+ <!-- Simplest login form with username and password fields. -->
+ <form id="form-basic-login">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit" value="sign in">
+ </form>
+
+ </body>
+</html>
diff --git a/toolkit/components/passwordmgr/test/browser/form_username_only.html b/toolkit/components/passwordmgr/test/browser/form_username_only.html
new file mode 100644
index 0000000000..d3bb2844b1
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/form_username_only.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+ <!-- Simplest username-only form. -->
+ <form id="form-username-only">
+ <input id="form-basic-username" name="username" type="text">
+ <input id="form-basic-submit" type="submit">
+ </form>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+
+<!-- Simplest form with username and password fields. -->
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+
+ <button id="add">Add input[type=password]</button>
+
+ <script>
+ document.getElementById("add").addEventListener("click", function() {
+ var node = document.createElement("input");
+ node.setAttribute("type", "password");
+ document.querySelector("body").appendChild(node);
+ });
+ </script>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/head.js b/toolkit/components/passwordmgr/test/browser/head.js
new file mode 100644
index 0000000000..a84a56f2e2
--- /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
+ */
+async function verifyLogins(expectedLogins = []) {
+ let allLogins = await 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);
+}
+
+async 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 = await 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.isVisible(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.isVisible(generatedPasswordItem),
+ "generated password item is visible"
+ );
+ Assert.ok(
+ BrowserTestUtils.isVisible(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.isVisible(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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- This frame is initially loaded over HTTP. -->
+<iframe id="test-iframe"
+ src="http://example.org/browser/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html"/>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html b/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html
new file mode 100644
index 0000000000..42411ff6bc
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simplest form with username and password fields. -->
+<form id="form-basic" action="https://example.org/custom_action.html">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+</form>
+
+<!-- Link to reload this page over HTTPS. -->
+<a id="test-link"
+ href="https://example.org/browser/toolkit/components/passwordmgr/test/browser/insecure_test_subframe.html">HTTPS</a>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/browser/multiple_forms.html b/toolkit/components/passwordmgr/test/browser/multiple_forms.html
new file mode 100644
index 0000000000..33c5de8a0e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/browser/multiple_forms.html
@@ -0,0 +1,145 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+
+<form class="test-form"
+ description="Password only form">
+ <input id='test-password-1' type='password' name='pname' value=''>
+ <input type='submit'>Submit</input>
+</form>
+
+<!-- This is a username-only form -->
+<form class="test-form"
+ description="Username only form">
+ <input id='test-username-1' type='text' name='uname' autocomplete='username' value=''>
+ <input type='submit'>Submit</input>
+</form>
+
+<!-- This is NOT a username-only form -->
+<form class="test-form"
+ description="text input only form">
+ <input id='test-input-2' type='text' name='uname' value=''>
+ <input type='submit'>Submit</input>
+</form>
+
+<form class="test-form"
+ description="Simple username and password blank form">
+ <input id='test-username-3' type='text' name='uname' value=''>
+ <input id='test-password-3' type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="Simple username and password form, prefilled username">
+ <input id='test-username-4' type='text' name='uname' value='testuser'>
+ <input id='test-password-4' type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="Simple username and password form, prefilled username and password">
+ <input id='test-username-5' type='text' name='uname' value='testuser'>
+ <input id='test-password-5' type='password' name='pname' value='testpass'>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="One username and two passwords empty form">
+ <input id='test-username-6' type='text' name='uname'>
+ <input id='test-password-6' type='password' name='pname'>
+ <input id='test-password2-6' type='password' name='pname2'>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="One username and two passwords form, fields prefiled">
+ <input id='test-username-7' type='text' name='uname' value="testuser">
+ <input id='test-password-7' type='password' name='pname' value="testpass">
+ <input id='test-password2-7' type='password' name='pname2' value="testpass">
+ <button type='submit'>Submit</button>
+</form>
+
+
+<div class="test-form"
+ description="Username and password fields with no form">
+ <input id='test-username-8' type='text' name='uname' value="testuser">
+ <input id='test-password-8' type='password' name='pname' value="testpass">
+</div>
+
+
+<form class="test-form"
+ description="Simple username and password blank form, with disabled password">
+ <input id='test-username-9' type='text' name='uname' value=''>
+ <input id='test-password-9' type='password' name='pname' value='' disabled>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="Simple username and password blank form, with disabled username">
+ <input id='test-username-10' type='text' name='uname' value='' disabled>
+ <input id='test-password-10' type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="Simple username and password blank form, with readonly password">
+ <input id='test-username-11' type='text' name='uname' value=''>
+ <input id='test-password-11' type='password' name='pname' value='' readonly>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="Simple username and password blank form, with readonly username">
+ <input id='test-username-12' type='text' name='uname' value='' readonly>
+ <input id='test-password-12' type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="Two username and one passwords form, fields prefiled">
+ <input id='test-username-13' type='text' name='uname' value="testuser">
+ <input id='test-username2-13' type='text' name='uname2' value="testuser">
+ <input id='test-password-13' type='password' name='pname' value="testpass">
+ <button type='submit'>Submit</button>
+</form>
+
+
+<form class="test-form"
+ description="Two username and one passwords form, one disabled username field">
+ <input id='test-username-14' type='text' name='uname'>
+ <input id='test-username2-14' type='text' name='uname2' disabled>
+ <input id='test-password-14' type='password' name='pname'>
+ <button type='submit'>Submit</button>
+</form>
+
+
+<div class="test-form"
+ description="Second username and password fields with no form">
+ <input id='test-username-15' type='text' name='uname'>
+ <input id='test-password-15' type='password' name='pname' expectedFail>
+</div>
+
+<form class="test-form"
+ description="Simple username and password blank form with the password field unmasked by JS">
+ <input id='test-username-16' type='text' name='uname' value=''>
+ <input id='test-password-16' type='password' name='pname' value='' data-type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<!-- Form in an iframe -->
+<iframe src="https://example.org/browser/toolkit/components/passwordmgr/test/browser/form_basic.html" id="test-iframe"></iframe>
+
+<script>
+ document.getElementById("test-password-16").type = "text";
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications - Basic 1un 1pw</title>
+</head>
+<body>
+<h2>Subtest 1</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(userField).setUserInput("notifyu1");
+ SpecialPowers.wrap(passField).setUserInput("notifyp1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications</title>
+</head>
+<body>
+<h2>Subtest 10</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(passField).setUserInput("notifyp1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications - Popup Windows</title>
+</head>
+<body>
+<h2>Subtest 11 (popup windows)</h2>
+<script>
+
+// Ignore the '?' and split on |
+let [username, password, features, autoClose] = window.location.search.substring(1).split("|");
+
+var url = "subtst_notifications_11_popup.html?" + username + "|" + password;
+var popupWin = window.open(url, "subtst_11", features);
+
+// Popup window will call this function on form submission.
+function formSubmitted() {
+ if (autoClose) {
+ popupWin.close();
+ }
+}
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications</title>
+</head>
+<body>
+<h2>Subtest 11</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ // Get the password from the query string (exclude '?').
+ let [username, password] = window.location.search.substring(1).split("|");
+ SpecialPowers.wrap(userField).setUserInput(username);
+ SpecialPowers.wrap(passField).setUserInput(password);
+ form.submit();
+ window.opener.formSubmitted();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>target="_blank" subtest for Login Manager notifications</title>
+</head>
+<body>
+<h2>Subtest 12 - target="_blank"</h2>
+<form id="form" action="formsubmit.sjs" target="_blank">
+ <input id="user" name="user">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ // Get the password from the query string (exclude '?').
+ let [username, password] = window.location.search.substring(1).split("|");
+ SpecialPowers.wrap(userField).setUserInput(username);
+ SpecialPowers.wrap(passField).setUserInput(password);
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications - autocomplete=off on the username field</title>
+</head>
+<body>
+<h2>Subtest 2</h2>
+(username autocomplete=off)
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user" autocomplete="off">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(userField).setUserInput("notifyu1");
+ SpecialPowers.wrap(passField).setUserInput("notifyp1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications with 2 password fields and no username</title>
+</head>
+<body>
+<h2>Subtest 24</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="pass1" name="pass1" type="password" value="staticpw">
+ <input id="pass" name="pass" type="password">
+ <button type="submit">Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(pass).setUserInput("notifyp1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var pass = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications with 2 password fields and 1 username field and one other text field before the first password field</title>
+</head>
+<body>
+<h2>1 username field followed by a text field followed by 2 username fields</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user" value="staticpw">
+ <input id="city" name="city" value="city">
+ <input id="pass" name="pass" type="password">
+ <input id="pin" name="pin" type="password" value="static-pin">
+ <button type="submit">Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(userField).setUserInput("notifyu1");
+ SpecialPowers.wrap(passField).setUserInput("notifyp1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications - autocomplete=off on the password field</title>
+</head>
+<body>
+<h2>Subtest 3</h2>
+(password autocomplete=off)
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user">
+ <input id="pass" name="pass" type="password" autocomplete="off">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(userField).setUserInput("notifyu1");
+ SpecialPowers.wrap(passField).setUserInput("notifyp1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" >
+ <title>Subtest for Login Manager notifications</title>
+</head>
+<body>
+<h2>Subtest 4</h2>
+(form autocomplete=off)
+<form id="form" action="formsubmit.sjs" autocomplete="off">
+ <input id="user" name="user">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(userField).setUserInput("notifyu1");
+ SpecialPowers.wrap(passField).setUserInput("notifyp1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications - Form with only a username field</title>
+</head>
+<body>
+<h2>Subtest 5</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(userField).setUserInput("notifyu1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications</title>
+</head>
+<body>
+<h2>Subtest 6</h2>
+(password-only form)
+<form id="form" action="formsubmit.sjs">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(passField).setUserInput("notifyp1");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications</title>
+</head>
+<body>
+<h2>Subtest 8</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(userField).setUserInput("notifyu1");
+ SpecialPowers.wrap(passField).setUserInput("pass2");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications</title>
+</head>
+<body>
+<h2>Subtest 9</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(userField).setUserInput("");
+ SpecialPowers.wrap(passField).setUserInput("pass2");
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for Login Manager notifications</title>
+</head>
+<body>
+<h2>Change password</h2>
+<form id="form" action="formsubmit.sjs">
+ <input id="pass_current" name="pass_current" type="password" value="notifyp1">
+ <input id="pass" name="pass" type="password">
+ <input id="pass_confirm" name="pass_confirm" type="password">
+ <button type='submit'>Submit</button>
+</form>
+
+<script>
+function submitForm() {
+ SpecialPowers.wrap(passField).setUserInput("pass2");
+ SpecialPowers.wrap(passConfirmField).setUserInput("pass2");
+
+ form.submit();
+}
+
+window.onload = submitForm;
+var form = document.getElementById("form");
+var userField = document.getElementById("user");
+var passField = document.getElementById("pass");
+var passConfirmField = document.getElementById("pass_confirm");
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test Login Manager notifications</title>
+</head>
+<body>
+<h2>Test Login Manager notifications</h2>
+
+<form id="form" action="formsubmit.sjs">
+ <input id="user" name="user" type="text">
+ <input id="pass" name="pass" type="password">
+ <button type='submit'>Submit</button>
+</form>
+</body>
+</html>
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("<html xmlns='http://www.w3.org/1999/xhtml'>");
+ response.write("<p>User: <span id='user'>" + user + "</span></p>\n");
+ response.write("<p>Pass: <span id='pass'>" + pass + "</span></p>\n");
+ response.write("</html>");
+}
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("<html xmlns='http://www.w3.org/1999/xhtml'>");
+ response.write(
+ "<p>Login: <span id='ok'>" +
+ (requestAuth ? "FAIL" : "PASS") +
+ "</span></p>\n"
+ );
+ response.write(
+ "<p>Proxy: <span id='proxy'>" +
+ (requestProxyAuth ? "FAIL" : "PASS") +
+ "</span></p>\n"
+ );
+ response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n");
+ response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n");
+ response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n");
+
+ if (huge) {
+ response.write("<div style='display: none'>");
+ for (let i = 0; i < 100000; i++) {
+ response.write("123456789\n");
+ }
+ response.write("</div>");
+ response.write(
+ "<span id='footnote'>This is a footnote after the huge content fill</span>"
+ );
+ }
+
+ if (plugin) {
+ response.write(
+ "<embed id='embedtest' style='width: 400px; height: 100px;' " +
+ "type='application/x-test'></embed>\n"
+ );
+ }
+
+ response.write("</html>");
+}
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ This page should navigate back in history upon load.
+ <script>
+ window.onload = function goBack() {
+ window.history.back();
+ };
+ </script>
+ </body>
+</html>
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..852ff950b6
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/form_basic_bfcache.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script>
+ var bc = new BroadcastChannel("form_basic_bfcache");
+ bc.onmessage = function(event) {
+ if (event.data == "nextpage") {
+ location.href = "https://example.org/tests/toolkit/components/passwordmgr/test/mochitest/file_history_back.html";
+ } else if (event.data == "close") {
+ bc.postMessage("closed");
+ bc.close();
+ window.close();
+ SimpleTest.finish();
+ }
+ }
+
+ function is(val1, val2, msg) {
+ bc.postMessage({type: "is", value1: val1, value2: val2, message: msg});
+ }
+
+ function ok(val, msg) {
+ bc.postMessage({type: "ok", value: val, message: msg});
+ }
+ </script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script>
+ // Need to use waitForExplicitFinish also in this support file to
+ // stop SimpleTest complaining about missing checks.
+ // pwmgr_common.js uses internally some helper methods from SimpleTest.
+ SimpleTest.waitForExplicitFinish();
+
+ runChecksAfterCommonInit();
+
+ onpageshow = async function(pageShow) {
+ if (!pageShow.persisted) {
+ // This is the initial page load.
+ await setStoredLoginsAsync([location.origin, "", null, "autofilled", "pass1", "", ""]);
+ } else {
+ await promiseFormsProcessedInSameProcess();
+ let uname = document.getElementById("form-basic-username");
+ let pword = document.getElementById("form-basic-password");
+ checkLoginForm(uname, "autofilled", pword, "pass1");
+ }
+
+ bc.postMessage({type: pageShow.type, persisted: pageShow.persisted});
+ }
+ </script>
+ </head>
+ <body>
+ <form id="form-basic">
+ <input id="form-basic-username" name="username">
+ <input id="form-basic-password" name="password" type="password">
+ <input id="form-basic-submit" type="submit">
+ </form>
+ </body>
+</html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with username and password fields together in a shadow root with a <form> ancestor -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/form_basic.html -->
+<form id="both-fields-together-in-a-shadow-root">
+ <!-- username and password inputs generated programmatically below -->
+ <input id="submit" type="submit">
+</form>
+
+<script>
+ const form = document.getElementById("both-fields-together-in-a-shadow-root");
+ const submitButton = document.getElementById("submit");
+ const wrapper = document.createElement("span");
+ wrapper.id = "wrapper-un-and-pw";
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ shadow.append(inputEle);
+ }
+ submitButton.before(wrapper);
+</script>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with username and password fields each in their own shadow root -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/form_basic.html -->
+<form id="each-field-its-own-shadow">
+ <!-- username and password inputs generated programmatically below -->
+ <input id="submit" type="submit">
+</form>
+
+<script>
+ const form = document.getElementById("each-field-its-own-shadow");
+ const submitButton = document.getElementById("submit");
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ const wrapper = document.createElement("span");
+ wrapper.id = `wrapper-${field}`;
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ shadow.append(inputEle);
+ submitButton.before(wrapper);
+ }
+</script>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with form, username and password fields together in a shadow root -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/form_basic.html -->
+<span id="wrapper">
+ <!-- form and all inputs generated programmatically below -->
+</span>
+
+<script>
+ const wrapper = document.getElementById("wrapper");
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ const form = document.createElement("form");
+ form.id = "form-and-fields-in-a-shadow-root";
+ const submitButton = document.createElement("input");
+ submitButton.id = "submit";
+ submitButton.type = "submit";
+ shadow.append(form);
+ form.append(submitButton);
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ submitButton.before(inputEle);
+ }
+</script>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with username and password fields together in nested shadow roots -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/form_basic.html -->
+<form id="each-field-its-own-shadow">
+ <span id="outer-wrapper">
+ <!-- username and password inputs generated programmatically below -->
+ </span>
+ <input id="submit" type="submit">
+</form>
+
+<script>
+ const submitButton = document.getElementById("submit");
+ const innerWrapper = document.createElement("span");
+ innerWrapper.id = "inner-wrapper";
+ const innerShadow = innerWrapper.attachShadow({mode: "closed"});
+ const outerWrapper = document.getElementById("outer-wrapper");
+ const outerShadow = outerWrapper.attachShadow({mode: "closed"});
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ innerShadow.append(inputEle);
+ }
+ outerShadow.append(innerWrapper);
+</script>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with username and password fields each in their own nested shadow roots -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/form_basic.html -->
+<form id="each-field-its-own-shadow">
+ <span id="outer-wrapper-username">
+ <!-- username input generated programmatically below -->
+ </span>
+ <span id="outer-wrapper-password">
+ <!-- password input generated programmatically below -->
+ </span>
+ <input id="submit" type="submit">
+</form>
+
+<script>
+ const submitButton = document.getElementById("submit");
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ const wrapper = document.createElement("span");
+ wrapper.id = `wrapper-${field}`;
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ shadow.append(inputEle);
+
+ const outerWrapper = document.getElementById(`outer-wrapper-${field}`);
+ const outerShadow = outerWrapper.attachShadow({mode: "closed"});
+ outerShadow.append(wrapper);
+ }
+</script>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with form, username and password fields together in nested shadow roots -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/form_basic.html -->
+<span id="outer-wrapper">
+ <!-- form and all inputs generated programmatically below -->
+</span>
+
+<script>
+ const outerWrapper = document.getElementById("outer-wrapper");
+ const innerWrapper = document.createElement("span");
+ innerWrapper.id = "inner-wrapper";
+ const innerShadow = innerWrapper.attachShadow({mode: "closed"});
+ const outerShadow = outerWrapper.attachShadow({mode: "closed"});
+ const form = document.createElement("form");
+ form.id = "form-and-fields-in-a-shadow-root";
+ const submitButton = document.createElement("input");
+ submitButton.id = "submit";
+ submitButton.type = "submit";
+ innerShadow.append(form);
+ form.append(submitButton);
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ submitButton.before(inputEle);
+ }
+ outerShadow.append(innerWrapper);
+</script>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with username and password fields together in a shadow root -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/formless_basic.html -->
+<!-- username and password inputs generated programmatically below -->
+<input id="submit" type="submit">
+
+<script>
+ const submitButton = document.getElementById("submit");
+ const wrapper = document.createElement("span");
+ wrapper.id = "wrapper-un-and-pw";
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ shadow.append(inputEle);
+ }
+ submitButton.before(wrapper);
+</script>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with username and password fields each in their own shadow root -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/formless_basic.html -->
+<!-- username and password inputs generated programmatically below -->
+<input id="submit" type="submit">
+
+<script>
+ const submitButton = document.getElementById("submit");
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ const wrapper = document.createElement("span");
+ wrapper.id = `wrapper-${field}`;
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ shadow.append(inputEle);
+ submitButton.before(wrapper);
+ }
+</script>
+
+</body></html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Simple form with form, username and password fields together in a shadow root -->
+<!-- This form is based off of toolkit/components/passwordmgr/test/browser/formless_basic.html -->
+<span id="wrapper">
+</span>
+<!-- username, password and submit inputs generated programmatically below -->
+
+<script>
+ const wrapper = document.getElementById("wrapper");
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ shadow.append(inputEle);
+ }
+ const submitButton = document.createElement("input");
+ submitButton.id = "submit";
+ submitButton.type = "submit";
+ shadow.append(submitButton);
+</script>
+
+</body></html>
diff --git a/toolkit/components/passwordmgr/test/mochitest/mochitest.toml b/toolkit/components/passwordmgr/test/mochitest/mochitest.toml
new file mode 100644
index 0000000000..86d5d83912
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/mochitest.toml
@@ -0,0 +1,419 @@
+[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",
+ "security.webauth.webauthn_enable_softtoken=true",
+ "security.webauth.webauthn_enable_usbtoken=false",
+ "security.webauthn.enable_conditional_mediation=true",
+]
+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 = ["os == 'android'"] # Don't run on GeckoView
+
+# Note: new tests should use scheme = https unless they have a specific reason not to
+
+["test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html"]
+scheme = "https"
+support-files = [
+ "slow_image.sjs",
+ "slow_image.html",
+]
+
+["test_LoginManagerContent_passwordEditedOrGenerated.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # password generation
+
+["test_autocomplete_autofill_related_realms_no_dupes.html"]
+skip-if = ["xorigin"] # Bug 1716412 - New fission platform triage
+scheme = "https"
+
+["test_autocomplete_basic_form.html"]
+skip-if = [
+ "os == 'android'", # autocomplete
+ "xorigin", # Bug 1716412 - New fission platform triage
+ "display == 'wayland' && os_version == '22.04'", # Bug 1857071
+]
+scheme = "https"
+
+["test_autocomplete_basic_form_formActionOrigin.html"]
+skip-if = [
+ "os == 'android'", # android:autocomplete.
+ "xorigin", # Bug 1716412 - New fission platform triage
+]
+scheme = "https"
+
+["test_autocomplete_basic_form_insecure.html"]
+skip-if = [
+ "os == 'android'", # autocomplete
+ "xorigin", # Bug 1716412 - New fission platform triage
+]
+
+["test_autocomplete_basic_form_related_realms.html"]
+skip-if = ["xorigin"] # Bug 1716412 - New fission platform triage
+scheme = "https"
+
+["test_autocomplete_hasBeenTypePassword.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # autocomplete
+
+["test_autocomplete_highlight.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # autocomplete
+
+["test_autocomplete_highlight_non_login.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # autocomplete
+
+["test_autocomplete_highlight_username_only_form.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # autocomplete
+
+["test_autocomplete_https_downgrade.html"]
+scheme = "http" # Tests downgrading
+skip-if = [
+ "os == 'android'", # autocomplete
+ "os == 'linux' && debug", # Bug 1554959
+ "xorigin", # Bug 1716412 - New fission platform triage
+]
+
+["test_autocomplete_https_upgrade.html"]
+scheme = "https"
+skip-if = [
+ "verify",
+ "os == 'android'", # autocomplete
+ "os == 'linux' && debug", # Bug 1554959 for linux debug disable
+]
+
+["test_autocomplete_password_generation.html"]
+scheme = "https"
+skip-if = [
+ "xorigin",
+ "os == 'android'", # autocomplete
+]
+
+["test_autocomplete_password_generation_confirm.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # autocomplete
+
+["test_autocomplete_password_generation_telemetry.html"]
+scheme = "https"
+skip-if = [
+ "xorigin",
+ "os == 'android'", # autocomplete
+]
+
+["test_autocomplete_password_open.html"]
+scheme = "https"
+skip-if = [
+ "os == 'android'", # autocomplete
+ "verify",
+]
+
+["test_autocomplete_sandboxed.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # autocomplete
+
+["test_autocomplete_tab_between_fields.html"]
+scheme = "https"
+skip-if = [
+ "xorigin",
+ "os == 'android'", # autocomplete
+]
+
+["test_autofill_autocomplete_types.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # bug 1533965
+
+["test_autofill_different_formActionOrigin.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # Bug 1259768
+
+["test_autofill_different_subdomain.html"]
+scheme = "https"
+skip-if = [
+ "os == 'android'", # Bug 1259768
+ "http3",
+ "http2",
+]
+
+["test_autofill_from_bfcache.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # bug 1527403
+support-files = ["form_basic_bfcache.html"]
+
+["test_autofill_hasBeenTypePassword.html"]
+scheme = "https"
+
+["test_autofill_highlight.html"]
+scheme = "https"
+skip-if = ["os == '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",
+ "http2",
+]
+
+["test_autofill_https_upgrade.html"]
+skip-if = [
+ "os == 'android'", # Bug 1259768
+ "http3",
+ "http2",
+]
+
+["test_autofill_password-only.html"]
+
+["test_autofill_sandboxed.html"]
+scheme = "https"
+skip-if = ["os == 'android'"]
+
+["test_autofill_tab_between_fields.html"]
+scheme = "https"
+
+["test_autofill_username-only.html"]
+
+["test_autofill_username-only_threshold.html"]
+
+["test_autofocus_js.html"]
+scheme = "https"
+skip-if = ["os == '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",
+ "os == '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 = [
+ "os == 'android'", # Tests desktop prompts
+ "http3",
+ "http2",
+]
+
+["test_bug_776171.html"]
+
+["test_case_differences.html"]
+skip-if = ["os == 'android'"] # autocomplete
+scheme = "https"
+
+["test_dismissed_doorhanger_in_shadow_DOM.html"]
+skip-if = ["os == 'android'"] # Tests desktop prompt
+scheme = "https"
+
+["test_formLike_rootElement_with_Shadow_DOM.html"]
+scheme = "https"
+
+["test_form_action_1.html"]
+
+["test_form_action_2.html"]
+
+["test_form_action_javascript.html"]
+
+["test_formless_autofill.html"]
+skip-if = [
+ "xorigin",
+ "http3",
+ "http2",
+]
+
+["test_formless_submit.html"]
+skip-if = [
+ "os == 'android' && debug", # bug 1397615
+ "http3",
+ "http2",
+]
+
+["test_formless_submit_form_removal.html"]
+skip-if = [
+ "http3",
+ "http2",
+]
+
+["test_formless_submit_form_removal_negative.html"]
+skip-if = [
+ "http3",
+ "http2",
+]
+
+["test_formless_submit_navigation.html"]
+skip-if = [
+ "os == 'android' && debug", # bug 1397615
+ "http3",
+ "http2",
+]
+
+["test_formless_submit_navigation_negative.html"]
+skip-if = [
+ "os == 'android' && debug", # bug 1397615
+ "http3",
+ "http2",
+]
+
+["test_include_other_subdomains_in_lookup.html"]
+skip-if = ["os == 'android'"] # android:autocomplete.
+scheme = "https"
+
+["test_input_events.html"]
+skip-if = ["xorigin"]
+
+["test_input_events_for_identical_values.html"]
+
+["test_insecure_form_field_no_saved_login.html"]
+skip-if = ["os == 'android'"] # android:autocomplete.
+
+["test_maxlength.html"]
+
+["test_munged_values.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # bug 1527403
+
+["test_one_doorhanger_per_un_pw.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # bug 1535505
+
+["test_onsubmit_value_change.html"]
+
+["test_password_field_autocomplete.html"]
+skip-if = ["os == 'android'"] # android:autocomplete.
+
+["test_password_length.html"]
+scheme = "https"
+skip-if = ["os == 'android'"] # bug 1527403
+
+["test_passwords_in_type_password.html"]
+
+["test_primary_password.html"]
+scheme = "https"
+run-if = ["os == 'mac'"]
+skip-if = [
+ "verify",
+ "xorigin", # Tests desktop prompts and bug 1333264
+]
+support-files = [
+ "chrome_timeout.js",
+ "subtst_primary_pass.html",
+]
+
+["test_prompt.html"]
+skip-if = [
+ "os == 'linux'",
+ "os == 'android'", # Tests desktop prompts
+]
+
+["test_prompt_async.html"]
+skip-if = [
+ "os == 'android'", # Tests desktop prompts
+ "http3",
+ "http2",
+]
+support-files = ["subtst_prompt_async.html"]
+
+["test_prompt_http.html"]
+skip-if = [
+ "os == 'android'", # Tests desktop prompts
+ "os == 'linux'",
+ "xorigin", # Bug 1716412 - New fission platform triage
+]
+
+["test_prompt_noWindow.html"]
+skip-if = ["os == 'android'"] # Tests desktop prompts.
+
+["test_prompt_promptAuth.html"]
+skip-if = [
+ "os == 'linux'",
+ "os == 'android'", # Tests desktop prompts
+]
+
+["test_prompt_promptAuth_proxy.html"]
+skip-if = [
+ "os == 'linux'",
+ "os == 'android'", # Tests desktop prompts
+]
+
+["test_recipe_login_fields.html"]
+skip-if = ["xorigin"]
+
+["test_set_stored_logins_during_task.html"]
+
+["test_submit_without_field_modifications.html"]
+support-files = ["subtst_prefilled_form.html"]
+skip-if = [
+ "xorigin",
+ "http3",
+ "http2",
+]
+
+["test_username_focus.html"]
+skip-if = [
+ "xorigin",
+ "os == 'android'", # android:autocomplete.
+]
+
+["test_xhr.html"]
+skip-if = ["os == 'android'"] # Tests desktop prompts
+
+["test_xhr_2.html"]
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Page with multiple forms containing the following Shadow DOM variants: -->
+<!-- Case 1: Each field (username and password) in its own shadow root -->
+<!-- Case 2: Both fields (username and password) together in a shadow root with a form ancestor -->
+<!-- Case 3: Form and fields (username and password) together in a shadow root -->
+<span id="outer-wrapper">
+</span>
+
+<script>
+ const outerWrapper = document.getElementById("outer-wrapper");
+ const outerShadow = outerWrapper.attachShadow({mode: "closed"});
+
+ function makeFormlessOuterForm(scenario) {
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = `${field}-${scenario}`;
+ inputEle.name = `${field}-${scenario}`;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ outerShadow.append(inputEle);
+ }
+ const submitButton = document.createElement("input");
+ submitButton.id = `submit-${scenario}`;
+ submitButton.type = "submit";
+ outerShadow.append(submitButton);
+ }
+
+ function makeFormEachFieldInItsOwnShadowRoot(scenario) {
+ const form = document.createElement("form");
+ form.id = scenario;
+ const submitButton = document.createElement("input");
+ submitButton.id = `submit-${scenario}`;
+ submitButton.type = "submit";
+ form.append(submitButton);
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = `${field}-${scenario}`;
+ inputEle.name = `${field}-${scenario}`;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ const wrapper = document.createElement("span");
+ wrapper.id = `wrapper-${field}-${scenario}`;
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ shadow.append(inputEle);
+ submitButton.before(wrapper);
+ }
+ outerShadow.append(form);
+ }
+
+ function makeFormBothFieldsTogetherInAShadowRoot(scenario) {
+ const form = document.createElement("form");
+ form.id = scenario;
+ const submitButton = document.createElement("input");
+ submitButton.id = `submit-${scenario}`;
+ submitButton.type = "submit";
+ form.append(submitButton);
+ const wrapper = document.createElement("span");
+ wrapper.id = `wrapper-${scenario}`;
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = `${field}-${scenario}`;
+ inputEle.name = `${field}-${scenario}`;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ shadow.append(inputEle);
+ }
+ submitButton.before(wrapper);
+ outerShadow.append(form);
+ }
+
+ function makeFormFormAndFieldsTogetherInAShadowRoot(scenario) {
+ const wrapper = document.createElement("span");
+ wrapper.id = `wrapper-${scenario}`;
+ const shadow = wrapper.attachShadow({mode: "closed"});
+ const form = document.createElement("form");
+ form.id = scenario;
+ shadow.append(form);
+ const submitButton = document.createElement("input");
+ submitButton.id = `submit-${scenario}`;
+ submitButton.type = "submit";
+ form.append(submitButton);
+ const fields = ["username", "password"];
+ for (let field of fields) {
+ const inputEle = document.createElement("input");
+ inputEle.id = field;
+ inputEle.name = field;
+ if (field === "password") {
+ inputEle.type = field;
+ }
+ submitButton.before(inputEle);
+ }
+ outerShadow.append(wrapper);
+ }
+
+ makeFormlessOuterForm("formless-case-2");
+ makeFormEachFieldInItsOwnShadowRoot("form-case-1");
+ makeFormBothFieldsTogetherInAShadowRoot("form-case-2");
+ makeFormFormAndFieldsTogetherInAShadowRoot("form-case-3");
+</script>
+
+</body></html>
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..8b125897a5
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common.js
@@ -0,0 +1,1175 @@
+/**
+ * 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-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;
+ }
+
+ 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, "Manage Passwords", "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);
+}
+
+/**
+ * Wait for autocomplete popup to get closed
+ * @return {Promise} resolving when the AC popup is closed
+ */
+async function untilAutocompletePopupClosed() {
+ return SimpleTest.promiseWaitForCondition(async () => {
+ const popupState = await getPopupState();
+ return !popupState.open;
+ }, "Wait for autocomplete popup to be closed");
+}
+
+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.name ||= "uname";
+ username.type ||= "text";
+ username.id ||= null;
+ username.value ||= null;
+ username.autocomplete ||= null;
+
+ password.name ||= "pword";
+ password.type ||= "password";
+ password.id ||= null;
+ password.value ||= null;
+ password.label ||= null;
+ password.autocomplete ||= null;
+ password.readonly ||= null;
+ password.disabled ||= 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");
+
+ usernameInput.type = username.type;
+ usernameInput.name = username.name;
+
+ if (username.id != null) {
+ usernameInput.id = username.id;
+ }
+ 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");
+
+ passwordInput.type = password.type;
+ passwordInput.name = password.name;
+
+ if (password.id != null) {
+ passwordInput.id = password.id;
+ }
+ if (password.value != null) {
+ passwordInput.value = password.value;
+ }
+ if (password.autocomplete != null) {
+ passwordInput.setAttribute("autocomplete", password.autocomplete);
+ }
+ if (password.readonly != null) {
+ passwordInput.setAttribute("readonly", password.readonly);
+ }
+ if (password.disabled != null) {
+ passwordInput.setAttribute("disabled", password.disabled);
+ }
+
+ 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.
+ *
+ * <form id="form#">
+ * 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.
+ *
+ * <form id="form#">
+ * 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.
+ *
+ * <form id="form#">
+ * 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, callback) {
+ 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(() => {
+ callback?.();
+ 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) {
+ 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;
+}
+
+async function getTelemetryEvents(options) {
+ let events = await PWMGR_COMMON_PARENT.sendQuery(
+ "getTelemetryEvents",
+ options
+ );
+ info("CONTENT: getTelemetryEvents gotResult: " + JSON.stringify(events));
+ return events;
+}
+
+function loadRecipes(recipes) {
+ info("Loading recipes");
+ return PWMGR_COMMON_PARENT.sendQuery("loadRecipes", recipes);
+}
+
+function resetRecipes() {
+ info("Resetting recipes");
+ return PWMGR_COMMON_PARENT.sendQuery("resetRecipes");
+}
+
+async function promiseStorageChanged(expectedChangeTypes) {
+ let result = await PWMGR_COMMON_PARENT.sendQuery("storageChanged", {
+ expectedChangeTypes,
+ });
+
+ if (result) {
+ ok(false, result);
+ }
+}
+
+async function promisePromptShown(expectedTopic) {
+ let topic = await PWMGR_COMMON_PARENT.sendQuery("promptShown");
+ is(topic, expectedTopic, "Check expected prompt topic");
+}
+
+/**
+ * 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("getLogins", async () => {
+ const logins = await Services.logins.getAllLogins();
+ return logins.map(
+ ({
+ origin,
+ formActionOrigin,
+ httpRealm,
+ username,
+ password,
+ usernameField,
+ passwordField,
+ }) => [
+ origin,
+ formActionOrigin,
+ httpRealm,
+ username,
+ password,
+ usernameField,
+ passwordField,
+ ]
+ );
+ });
+
+ /* 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;
+}
+
+/**
+ * Sets given logins for the duration of the test. Existing logins are first
+ * removed and finally restored when the test is finished.
+ * The logins are added within the parent chrome process.
+ * @param {array} logins - a list of logins to add. Each login is an array of the arguments
+ * that would be passed to nsLoginInfo.init().
+ */
+async function setStoredLoginsDuringTest(...logins) {
+ const script = manageLoginsInParent();
+ const loginsBefore = await script.sendQuery("getLogins");
+ await script.sendQuery("removeAllUserFacingLogins");
+ await script.sendQuery("addLogins", logins);
+ SimpleTest.registerCleanupFunction(async () => {
+ await script.sendQuery("removeAllUserFacingLogins");
+ await script.sendQuery("addLogins", loginsBefore);
+ });
+}
+
+/**
+ * Sets given logins for the duration of the task. Existing logins are first
+ * removed and finally restored when the task is finished.
+ * @param {array} logins - a list of logins to add. Each login is an array of the arguments
+ * that would be passed to nsLoginInfo.init().
+ */
+async function setStoredLoginsDuringTask(...logins) {
+ const script = manageLoginsInParent();
+ const loginsBefore = await script.sendQuery("getLogins");
+ await script.sendQuery("removeAllUserFacingLogins");
+ await script.sendQuery("addLogins", logins);
+ SimpleTest.registerTaskCleanupFunction(async () => {
+ await script.sendQuery("removeAllUserFacingLogins");
+ await script.sendQuery("addLogins", loginsBefore);
+ });
+}
+
+/** Returns a promise which resolves to a list of logins
+ */
+function getLogins() {
+ const script = manageLoginsInParent();
+ return script.sendQuery("getLogins");
+}
+
+/*
+ * 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 }
+) {
+ 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 if supplied.
+ *
+ * @param {Function} aFunction The test function to run
+ */
+async function runChecksAfterCommonInit(aFunction = null) {
+ SimpleTest.waitForExplicitFinish();
+ await PWMGR_COMMON_PARENT.sendQuery("setupParent", {
+ testDependsOnDeprecatedLogin: gTestDependsOnDeprecatedLogin,
+ });
+
+ if (aFunction) {
+ await registerRunTests(0, aFunction);
+ }
+
+ 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,
+ });
+ };
+ },
+ }
+);
+
+/**
+ * Set the inner html of the content div and ensure it gets reset after current
+ * task finishes.
+ * Returns the first child node of the newly created content div for convenient
+ * access of the newly created dom node.
+ *
+ * @param {String} html
+ * string of dom content or dom element to be inserted into content element
+ */
+function setContentForTask(html) {
+ const content = document.querySelector("#content");
+ const innerHTMLBefore = content.innerHTML || "";
+ SimpleTest.registerCurrentTaskCleanupFunction(
+ () => (content.innerHTML = innerHTMLBefore)
+ );
+ if (html.content?.cloneNode) {
+ const clone = html.content.cloneNode(true);
+ content.replaceChildren(clone);
+ } else {
+ content.innerHTML = html;
+ }
+ return content.firstElementChild;
+}
+
+/*
+ * Set preferences via SpecialPowers.pushPrefEnv and reset them after current
+ * task has finished.
+ *
+ * @param {*Object} preferences
+ * */
+async function setPreferencesForTask(...preferences) {
+ await SpecialPowers.pushPrefEnv({
+ set: preferences,
+ });
+ SimpleTest.registerCurrentTaskCleanupFunction(() => SpecialPowers.popPrefEnv);
+}
+
+// capture form autofill results between tasks
+let gPwmgrCommonCapturedAutofillResults = {};
+PWMGR_COMMON_PARENT.addMessageListener(
+ "formProcessed",
+ ({ formId, autofillResult }) => {
+ if (formId === "observerforcer") {
+ return;
+ }
+
+ gPwmgrCommonCapturedAutofillResults[formId] = autofillResult;
+ }
+);
+SimpleTest.registerTaskCleanupFunction(() => {
+ gPwmgrCommonCapturedAutofillResults = {};
+});
+
+/**
+ * Create a promise that resolves when the form has been processed.
+ * Works with forms processed in the past since the task started and in the future,
+ * across parent and child processes.
+ *
+ * @param {String} formId / the id of the form of which to expect formautofill events
+ * @returns promise, resolving with the autofill result.
+ */
+async function formAutofillResult(formId) {
+ if (formId in gPwmgrCommonCapturedAutofillResults) {
+ const autofillResult = gPwmgrCommonCapturedAutofillResults[formId];
+ delete gPwmgrCommonCapturedAutofillResults[formId];
+ return autofillResult;
+ }
+ return new Promise((resolve, reject) => {
+ PWMGR_COMMON_PARENT.addMessageListener(
+ "formProcessed",
+ ({ formId: id, autofillResult }) => {
+ if (id !== formId) {
+ return;
+ }
+ delete gPwmgrCommonCapturedAutofillResults[formId];
+ resolve(autofillResult);
+ },
+ { once: true }
+ );
+ });
+}
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..09ad80f467
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/pwmgr_common_parent.js
@@ -0,0 +1,247 @@
+/**
+ * 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.
+ */
+async function commonInit(testDependsOnDeprecatedLogin) {
+ var pwmgr = Services.logins;
+ assert.ok(pwmgr != null, "Access LoginManager");
+
+ // Check that initial state has no logins
+ var logins = await 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 = await 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");
+}
+
+async function dumpLogins() {
+ let logins = await 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);
+}
+
+addMessageListener("storageChanged", async function ({ expectedChangeTypes }) {
+ return new Promise((resolve, reject) => {
+ function storageChanged(subject, topic, data) {
+ let changeType = expectedChangeTypes.shift();
+ if (data != changeType) {
+ resolve("Unexpected change type " + data + ", expected " + changeType);
+ } else if (expectedChangeTypes.length === 0) {
+ Services.obs.removeObserver(
+ storageChanged,
+ "passwordmgr-storage-changed"
+ );
+ resolve();
+ }
+ }
+
+ Services.obs.addObserver(storageChanged, "passwordmgr-storage-changed");
+ });
+});
+
+addMessageListener("promptShown", async function () {
+ return new Promise(resolve => {
+ function promptShown(subject, topic, data) {
+ Services.obs.removeObserver(promptShown, "passwordmgr-prompt-change");
+ Services.obs.removeObserver(promptShown, "passwordmgr-prompt-save");
+ resolve(topic);
+ }
+
+ Services.obs.addObserver(promptShown, "passwordmgr-prompt-change");
+ Services.obs.addObserver(promptShown, "passwordmgr-prompt-save");
+ });
+});
+
+addMessageListener("cleanup", () => {
+ Services.logins.removeAllUserFacingLogins();
+});
+
+// Begin message listeners
+
+addMessageListener(
+ "setupParent",
+ async ({ testDependsOnDeprecatedLogin = false } = {}) => {
+ return commonInit(testDependsOnDeprecatedLogin);
+ }
+);
+
+addMessageListener("loadRecipes", async function (recipes) {
+ var recipeParent = await LoginManagerParent.recipeParentPromise;
+ await recipeParent.load(recipes);
+ return recipes;
+});
+
+addMessageListener("resetRecipes", async function () {
+ let recipeParent = await LoginManagerParent.recipeParentPromise;
+ await recipeParent.reset();
+});
+
+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;
+ });
+
+ return events;
+});
+
+addMessageListener("proxyLoginManager", async 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 = await 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", data);
+ }
+});
+
+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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <img src="slow_image.sjs" />
+ </body>
+</html>
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 @@
+<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!-- Form with username and password fields pre-populated.
+ With a couple elements where .defaultValue !== .value -->
+<form id="form-basic">
+ <input id="form-basic-username" name="username" value="user">
+ <input id="form-basic-password" name="password" type="password" value="pass">
+ <select name="picker">
+ <option value="foo">0</option>
+ <option value="bar" selected>1</option>
+ </select>
+ <input id="form-basic-submit" type="submit">
+ <button id="form-basic-reset" type="reset">Reset</button>
+</form>
+
+</body></html>
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 @@
+<h2>MP subtest</h2>
+This form triggers a MP and gets filled in.<br>
+<form>
+Username: <input type="text" id="userfield" name="u"><br>
+Password: <input type="password" id="passfield" name="p"
+ oninput="parent.postMessage('filled', '*');"><br>
+</form>
+<iframe></iframe>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Multiple auth request</title>
+</head>
+<body>
+ <iframe id="iframe1" src="authenticate.sjs?r=1&user=user3name&pass=user3pass&realm=mochirealm3&proxy_user=proxy_user2&proxy_pass=proxy_pass2&proxy_realm=proxy_realm2"></iframe>
+ <iframe id="iframe2" src="authenticate.sjs?r=2&user=user3name&pass=user3pass&realm=mochirealm3&proxy_user=proxy_user2&proxy_pass=proxy_pass2&proxy_realm=proxy_realm2"></iframe>
+ <iframe id="iframe3" src="authenticate.sjs?r=3&user=user3name&pass=user3pass&realm=mochirealm3&proxy_user=proxy_user2&proxy_pass=proxy_pass2&proxy_realm=proxy_realm2"></iframe>
+</body>
+</html>
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..3ab40e92af
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_DOMInputPasswordAdded_fired_between_DOMContentLoaded_and_load_events.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test the password manager code called on DOMInputPasswordAdded runs when it occurs between DOMContentLoaded and load events</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+ <!-- In cases where the "DOMContentLoaded" event for a page has occured but not the "load" event when
+ "DOMInputPasswordAdded" fires, we want to make sure that the Password Manager code (i.e.
+ _fetchLoginsFromParentAndFillForm) still runs on the page.
+ This scenario can happen for example when a page has very little initial HTML, but extensive JS that
+ adds Custom Elements or other HTML later, or when other subresources, like images, take a while to load.
+ In this test, we delay the page load with a delayed response for an image source. -->
+<script type="application/javascript">
+
+let DEFAULT_ORIGIN = window.location.origin;
+let FILE_PATH = "/tests/toolkit/components/passwordmgr/test/mochitest/slow_image.html";
+
+async function openDocumentInWindow(win) {
+ let DOMContentLoadedPromise = new Promise((resolve) => {
+ win.addEventListener("DOMContentLoaded", function() {
+ resolve();
+ }, {once: true});
+ });
+ win.location = DEFAULT_ORIGIN + FILE_PATH;
+ await DOMContentLoadedPromise;
+}
+
+add_setup(async () => {
+ await setStoredLoginsAsync([DEFAULT_ORIGIN, DEFAULT_ORIGIN, null, "user", "omgsecret!"]);
+});
+
+add_task(async function test_password_autofilled() {
+ let numLogins = await LoginManager.countLogins(DEFAULT_ORIGIN, DEFAULT_ORIGIN, null);
+ is(numLogins, 1, "Correct number of logins");
+
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await openDocumentInWindow(win);
+ let processedPromise = promiseFormsProcessed();
+ await SpecialPowers.spawn(win, [], function() {
+ let doc = this.content.document;
+ info("Adding password input field to the page to trigger DOMInputPasswordAdded");
+ let passwordField = doc.createElement("input");
+ passwordField.type = "password";
+ is(doc.readyState, "interactive", "Make sure 'DOMContentLoaded' has fired but not 'load'");
+ doc.body.append(passwordField);
+ });
+ info("Waiting for the password field to be autofilled");
+ await processedPromise;
+ let expectedValue = "omgsecret!";
+ await SpecialPowers.spawn(win, [expectedValue], expectedValueF => {
+ is(this.content.document.querySelector("input[type='password']").value, expectedValueF, "Ensure the password field is autofilled");
+ });
+});
+</script>
+</body>
+</html>
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..9d23177924
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_LoginManagerContent_passwordEditedOrGenerated.html
@@ -0,0 +1,160 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test behavior of unmasking in LMC._passwordEditedOrGenerated</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test"></pre>
+<script>
+const { LoginManagerChild } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerChild.sys.mjs"
+);
+
+function preventDefaultAndStopProgagation(event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["signon.generation.available", true],
+ ["signon.generation.enabled", true],
+ ]});
+ await setStoredLoginsAsync(
+ [location.origin, "https://autofill", null, "user1", "pass1"],
+ [location.origin, "https://autofill", null, "user2", "pass2"]
+ );
+});
+
+add_task(async function prevent_default_and_stop_propagation() {
+ const form = createLoginForm({
+ action: "https://autofill"
+ });
+ await promiseFormsProcessedInSameProcess();
+ form.pword.focus();
+
+ form.pword.addEventListener("focus", preventDefaultAndStopProgagation);
+ form.pword.addEventListener("focus", preventDefaultAndStopProgagation, true);
+ form.pword.addEventListener("blur", preventDefaultAndStopProgagation);
+ form.pword.addEventListener("blur", preventDefaultAndStopProgagation, true);
+
+ SpecialPowers.wrap(form.pword).setUserInput("generatedpass");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "Before first fill");
+ LoginManagerChild.forWindow(window)._passwordEditedOrGenerated(form.pword, { triggeredByFillingGenerated: true });
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "After first fill");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After blur");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "After shift-tab to focus again");
+});
+
+add_task(async function fields_masked_after_saved_login_fill() {
+ const form = createLoginForm({
+ action: "https://autofill"
+ });
+ await promiseFormsProcessedInSameProcess();
+ form.pword.focus();
+
+ SpecialPowers.wrap(form.pword).setUserInput("generatedpass");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "Before first fill");
+ LoginManagerChild.forWindow(window)._passwordEditedOrGenerated(form.pword, { triggeredByFillingGenerated: true });
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "After first fill");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // blur pw, focus un
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After blur");
+
+ info("Filling username matching saved login");
+ sendString("user1");
+
+ let processedPromise = promiseFormsProcessedInSameProcess();
+ synthesizeKey("KEY_Tab"); // focus again and trigger a fill of the matching password
+ await processedPromise;
+ is(form.pword.value, "pass1", "Saved password was filled")
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After filling a saved login");
+});
+
+add_task(async function fields_masked_after_replacing_whole_value() {
+ const form = createLoginForm({
+ action: "https://autofill"
+ });
+ await promiseFormsProcessedInSameProcess();
+ form.pword.focus();
+
+ SpecialPowers.wrap(form.pword).setUserInput("generatedpass");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "Before first fill");
+ LoginManagerChild.forWindow(window)._passwordEditedOrGenerated(form.pword, { triggeredByFillingGenerated: true });
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "After first fill");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // blur pw, focus un
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After blur");
+
+ synthesizeKey("KEY_Tab"); // focus again and replace the whole password value
+ info("Replacing password field value with arbitrary string");
+ sendString("some_other_password");
+ is(form.pword.value, "some_other_password", "Whole password replaced")
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "Replaced password value");
+
+ synthesizeKey("KEY_Tab"); // blur pw
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After blur");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus pw again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After focus again");
+});
+
+add_task(async function fields_unmasked_after_adding_character() {
+ const form = createLoginForm({
+ action: "https://autofill"
+ });
+ await promiseFormsProcessedInSameProcess();
+ form.pword.focus();
+
+ SpecialPowers.wrap(form.pword).setUserInput("generatedpass");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "Before first fill");
+ LoginManagerChild.forWindow(window)._passwordEditedOrGenerated(form.pword, { triggeredByFillingGenerated: true });
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "After first fill");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // blur pw, focus un
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After blur");
+
+ synthesizeKey("KEY_Tab"); // focus again
+ synthesizeKey("KEY_ArrowRight"); // Remove the selection
+ info("Adding a character to the end of the password");
+ sendString("@");
+ is(form.pword.value, "generatedpass@", "Character was added to the value")
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "Added @");
+
+ synthesizeKey("KEY_Tab"); // blur pw
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After blur after @");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus pw again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "After focus after @");
+});
+
+add_task(async function type_not_password() {
+ const form = createLoginForm({
+ action: "https://autofill"
+ });
+ await promiseFormsProcessedInSameProcess();
+ form.pword.focus();
+
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "Before first fill");
+ SpecialPowers.wrap(form.pword).setUserInput("generatedpass");
+ LoginManagerChild.forWindow(window)._passwordEditedOrGenerated(form.pword, { triggeredByFillingGenerated: true });
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "After first fill");
+
+ // Simulate a website doing their own unmasking and re-masking
+ form.pword.type = "text";
+ await new Promise(resolve => SimpleTest.executeSoon(resolve));
+ form.pword.type = "password";
+
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "After blur");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "After shift-tab to focus again");
+});
+</script>
+</body>
+</html>
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..b229993a42
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_autofill_related_realms_no_dupes.html
@@ -0,0 +1,112 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Login Manager: Related Realms</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Testing related realms
+
+Related realms is feature to provide login suggestion based on similar domains.
+
+Out of the scope of this feature is subdomain handling, which is already part
+of the base functionality. The intent is to cover the ebay.com/ebay.co.uk and
+all other country TLD cases where the sign in page is actually
+signin.ebay.com/signin.ebay.co.uk but credentials could have manually been
+entered for ebay.com/ebay.co.uk or automatically stored as
+signin.ebay.com/sigin.ebay.co.uk
+
+The related realms feature can be enabled via the preference
+signon.relatedRealms.enabled. It is disabled by default.
+
+This test proves that related logins show up in the autocomplete menu when
+the feature is enabled and that the related logins do not count as duplicates
+and therefore the form gets filled directly.
+
+<template id="form1-template">
+ <form id="form1" action="https://www.example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const formTemplate = document.getElementById("form1-template");
+
+ add_setup(async () => {
+ await setStoredLoginsDuringTest(
+ ["https://example.com", "https://example.com", null, "example.com-user", "password", "uname", "pword"],
+
+ // Related domain relationship where example.com and other-example.com are in the related domains list
+ ["https://other-example.com", "https://other-example.com", null, "other-example.com-user", "password", "uname", "pword"],
+
+ // example.com and example.co.uk are related, so sub.example.co.uk is also related
+ ["https://sub.example.co.uk", "https://sub.example.co.uk", null, "sub.example.co.uk-user", "password", "uname", "pword"],
+
+ // www subdomain on same TLD
+ ["https://www.example.com", "https://www.example.com", null, "www.example.com-user", "password", "uname", "pword"],
+
+ // other subdomain on same TLD
+ ["https://sub.example.com", "https://sub.example.com", null, "sub.example.com-user", "password", "uname", "pword"],
+ );
+ });
+
+ add_named_task("with relatedRealms disabled, related logins do not show up in autocomplete menu", async () => {
+ await setPreferencesForTask(["signon.relatedRealms.enabled", false]);
+
+ const form = setContentForTask(formTemplate);
+
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ // reset the form and open autocomplete popup by focusing username input
+ form.reset();
+ const results = await popupBy(() => form.uname.focus());
+ checkAutoCompleteResults(results, [
+ "example.com-user",
+ "sub.example.com-user",
+ "www.example.com-user",
+ ], window.location.host, "all logins are present and in order");
+ });
+
+ add_named_task("with relatedRealms enabled, related logins show up in autocomplete menu", async () => {
+ await setPreferencesForTask(["signon.relatedRealms.enabled", true]);
+
+ const form = setContentForTask(formTemplate);
+
+ const result = await formAutofillResult(form.id);
+ is(result, "filled", "form has been filled");
+
+ // reset the form and open autocomplete popup by focusing username input
+ form.reset();
+ const results = await popupBy(() => form.uname.focus());
+ checkAutoCompleteResults(results, [
+ "example.com-user",
+ "other-example.com-user",
+ "sub.example.co.uk-user",
+ "sub.example.com-user",
+ "www.example.com-user",
+ ], window.location.host, "all logins are present and in order");
+ });
+
+ add_named_task("even with relatedRealms enabled, related logins are not considered duplicates, so form will be filled", async () => {
+ await setPreferencesForTask(["signon.relatedRealms.enabled", true]);
+
+ const form = setContentForTask(formTemplate);
+
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ is(form.uname.value, "example.com-user", "username from exact tld is filled in");
+ });
+</script>
+</pre>
+</body>
+</html>
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..cfa695e002
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form.html
@@ -0,0 +1,935 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Login Manager: test basic login autocomplete</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../../../dom/webauthn/tests/u2futil.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Test for Login Manager: login autocomplete, with secure connection. This tests autocomplete menu items, its navigation, the selection and deletion of entries as well as sending untrusted events.
+
+This tests the login manager in a secure setting using https. A similar test file exists for using an insecure connection: test_autocomplete_basic_form_insecure.html.
+
+<template id="form1-template">
+ <form id="form1" action="https://autocomplete:8888/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const formTemplate = document.getElementById("form1-template");
+
+ // Restore the form to the default state.
+ function restoreForm(form) {
+ form.uname.value = "";
+ form.pword.value = "";
+ form.uname.focus();
+ }
+
+ add_setup(async () => {
+ listenForUnexpectedPopupShown();
+ });
+
+ add_named_task("form is initially empty and popup closed", async () => {
+ const form = setContentForTask(formTemplate);
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is closed");
+ });
+
+ add_named_task("menuitems, telemetry events, selection and escape", async () => {
+ await setStoredLoginsDuringTask(
+ // login 0 has no username, so should be filtered out from the autocomplete list.
+ [location.origin, "https://autocomplete:8888", null, "", "pass0", "", "pword"],
+
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-3", "pass-3", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ form.uname.focus();
+ const autocompleteItems = await popupByArrowDown();
+
+ const popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+ const expectedMenuItems = ["user-1",
+ "user-2",
+ "user-3"];
+ checkAutoCompleteResults(autocompleteItems, expectedMenuItems,
+ window.location.host, "Check all menuitems are displayed correctly.");
+
+ const acEvents = await getTelemetryEvents({ process: "parent", filterProps: TelemetryFilterPropsAC, clear: true });
+ is(acEvents.length, 1, "One autocomplete event");
+ checkACTelemetryEvent(acEvents[0], form.uname, {
+ "hadPrevious": "0",
+ "login": expectedMenuItems.length + "",
+ "loginsFooter": "1"
+ });
+
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Escape");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("select first entry", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ const popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+ synthesizeKey("KEY_ArrowDown"); // first
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "pass-1", "password is set");
+ });
+
+ add_named_task("select second entry", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("wrap around first entry", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_ArrowDown"); // footer
+ synthesizeKey("KEY_ArrowDown"); // deselects
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "pass-1", "password is set");
+ });
+
+ add_named_task("wrap around up last entry", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_ArrowUp"); // last (fourth)
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("wrap around up down up up", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown"); // select first entry
+ synthesizeKey("KEY_ArrowUp"); // selects nothing!
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_ArrowUp"); // select last entry
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("wrap around up down up last", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowUp"); // deselects
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_ArrowUp"); // last entry
+ synthesizeKey("KEY_ArrowUp"); // first entry
+ synthesizeKey("KEY_ArrowUp"); // deselects
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_ArrowUp"); // last entry
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("fill username without autofill right", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ // Set first entry w/o triggering autocomplete
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowRight");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("fill username without autofill left", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ // Set first entry w/o triggering autocomplete
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowLeft");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("page up first", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ // Check first entry (page up)
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_PageUp"); // first
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "pass-1", "password is set");
+ });
+
+ add_named_task("page down last", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ // Check last entry (page down)
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_PageDown"); // footer
+ synthesizeKey("KEY_ArrowUp"); // last
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("untrusted event", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("delete", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-3", "pass-3", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+
+ // XXX tried sending character "t" before/during dropdown to test
+ // filtering, but had no luck. Seemed like the character was getting lost.
+ // Setting uname.value didn't seem to work either. This works with a human
+ // driver, so I'm not sure what's up.
+
+ // Delete the first entry (of 3), "user-1"
+ synthesizeKey("KEY_ArrowDown");
+ const numLoginsBeforeDeletion = await LoginManager.countLogins(location.origin, "https://autocomplete:8888", null);
+ is(numLoginsBeforeDeletion, 3, "Correct number of logins before deleting one");
+
+ const countChangedPromise = notifyMenuChanged(3);
+ const deletionPromise = promiseStorageChanged(["removeLogin"]);
+ // On OS X, shift-backspace and shift-delete work, just delete does not.
+ // On Win/Linux, shift-backspace does not work, delete and shift-delete do.
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ await deletionPromise;
+
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const numLoginsAfterDeletion = await LoginManager.countLogins(location.origin, "https://autocomplete:8888", null);
+ is(numLoginsAfterDeletion, 2, "Correct number of logins after deleting one");
+ await countChangedPromise;
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("delete second", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-3", "pass-3", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+
+ // Delete the second entry (of 3), "user-2"
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const numLoginsAfterDeletion = await LoginManager.countLogins(location.origin, "https://autocomplete:8888", null);
+ is(numLoginsAfterDeletion, 2, "Correct number of logins after deleting one");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-3", "username is set");
+ is(form.pword.value, "pass-3", "password is set");
+ });
+
+ add_named_task("delete last", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "user-3", "pass-3", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+
+ /* test 54 */
+ // Delete the last entry (of 3), "user-3"
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ const numLoginsBeforeDeletion = await LoginManager.countLogins(location.origin, "https://autocomplete:8888", null);
+ is(numLoginsBeforeDeletion, 3, "Correct number of logins before deleting one");
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const numLoginsAfterDeletion = await LoginManager.countLogins(location.origin, "https://autocomplete:8888", null);
+ is(numLoginsAfterDeletion, 2, "Correct number of logins after deleting one");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "pass-1", "password is set");
+ });
+
+ // Tests for single-user forms for ignoring autocomplete=off */
+
+ add_named_task("default", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete2">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("password autocomplete off", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete2">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="off">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+
+ restoreForm(form);
+ await popupByArrowDown();
+
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ // value should not update just on selection
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("username autocomplete off", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete2">
+ <input type="text" name="uname" autocomplete="off">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+
+ restoreForm(form);
+ await popupByArrowDown();
+
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ // value should not update just on selection
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("form autocomplete off", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete2" autocomplete="off">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ restoreForm(form);
+ await popupByArrowDown();
+
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ // value should not update just on selection
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("username and password autocomplete off", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete2">
+ <input type="text" name="uname" autocomplete="off">
+ <input type="password" name="pword" autocomplete="off">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ restoreForm(form);
+ await popupByArrowDown();
+
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("changing username does not touch password", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete2">
+ <input type="text" name="uname" autocomplete="off">
+ <input type="password" name="pword" autocomplete="off">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+
+ // Test that the password field remains filled in after changing
+ // the username.
+ form.uname.focus();
+ synthesizeKey("KEY_ArrowRight");
+ synthesizeKey("X", {shiftKey: true});
+ // Trigger the 'blur' event on uname
+ form.pword.focus();
+ is(form.uname.value, "userX", "username is set");
+ is(form.pword.value, "pass", "password is set");
+ });
+
+ add_named_task("additional username field in between", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete3", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete3", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete3">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+
+ // Insert a new username field into the form. We'll then make sure
+ // that invoking the autocomplete doesn't try to fill the form.
+ const newField = document.createElement("input");
+ newField.setAttribute("type", "text");
+ newField.setAttribute("name", "uname2");
+ form.insertBefore(newField, form.pword);
+ is(newField.value, "", "Verifying empty uname2");
+ });
+
+ add_named_task("additional username field in between and form reset", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete3", null, "user", "pass", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete3">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ // Insert a new username field into the form. We'll then make sure
+ // that invoking the autocomplete doesn't try to fill the form.
+ const newField = document.createElement("input");
+ newField.setAttribute("type", "text");
+ newField.setAttribute("name", "uname2");
+ form.insertBefore(newField, form.pword);
+
+ restoreForm(form);
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems,
+ ["user"],
+ window.location.host,
+ "Check dropdown is showing all logins while field is blank");
+
+
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ // The form changes, so we expect the old username field to get the
+ // selected autocomplete value, but neither the new username field nor
+ // the password field should have any values filled in.
+ await SimpleTest.promiseWaitForCondition(() => form.uname.value == "user",
+ "Wait for username to get filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "", "password is empty");
+ is(newField.value, "", "Verifying empty uname2");
+ });
+
+ add_named_task("two forms with different actions", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete", null, "user", "pass", "uname", "pword"]
+ );
+ const div = setContentForTask(`<div>
+ <form id="form1" action="https://autocomplete-other">
+ <input type="email" name="uname">
+ <input type="password" name="pword">
+ </form>
+ <form id="form2" action="https://autocomplete">
+ <input type="email" name="uname">
+ <input type="password" name="pword">
+ </form>
+ </div>`);
+ const form1 = div.querySelector("#form1");
+ const form2 = div.querySelector("#form2");
+ const autofillResult = await formAutofillResult(form1.id);
+ is(autofillResult, "no_saved_logins", "form has not been filled due to no saved logins");
+ const autofillResult2 = await formAutofillResult(form2.id);
+ is(autofillResult2, "filled", "form has been filled");
+
+ is(form2.uname.value, "user", "username is set");
+ is(form2.pword.value, "pass", "password is set");
+
+ restoreForm(form2);
+ is(form2.uname.value, "", "username is empty");
+ is(form2.pword.value, "", "password is empty");
+
+ form1.uname.focus();
+ is(form2.uname.value, "", "username is empty");
+ is(form2.pword.value, "", "password is empty");
+ });
+
+ add_named_task("filtering", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete", null, "form9userAB", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete", null, "form9userAAB", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+
+ const results = await popupBy(() => form.uname.focus());
+ checkAutoCompleteResults(results,
+ ["form9userAAB", "form9userAB"],
+ window.location.host,
+ "Check dropdown is showing all logins while field is blank");
+ synthesizeKey("KEY_Escape"); // Need to close the popup so we can get another popupshown after sending the string below.
+
+ const results2 = await popupBy(() => sendString("form9userAB"));
+ checkAutoCompleteResults(results2,
+ ["form9userAB"],
+ window.location.host,
+ "Check dropdown is showing login with only one 'A'");
+
+ is(form.uname.value, "form9userAB", "username is set");
+ is(form.pword.value, "", "password is empty");
+ form.uname.focus();
+ synthesizeKey("KEY_ArrowLeft");
+ const results3 = await popupBy(() => synthesizeKey("A", {shiftKey: true}));
+
+ is(form.uname.value, "form9userAAB", "username is set");
+ is(form.pword.value, "", "password is empty");
+ checkAutoCompleteResults(results3, ["form9userAAB"],
+ window.location.host, "Check dropdown is updated after inserting 'A'");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "form9userAAB", "username is set");
+ is(form.pword.value, "pass-2", "password set");
+ });
+
+ add_named_task("autocomplete cache", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "form9userAB", "pass-1", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "form9userAAB", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+
+ await popupBy(() => form.uname.focus());
+
+ await addLoginsInParent(
+ [location.origin, "https://autocomplete", null, "form9userAABzz", "pass-3", "uname", "pword"]
+ );
+
+ const promise1 = notifyMenuChanged(1);
+ sendString("z");
+ const results1 = await promise1;
+ checkAutoCompleteResults(results1, [], window.location.host,
+ "Check popup does not have any login items");
+
+ // check that empty results are cached - bug 496466
+ const promise2 = notifyMenuChanged(1);
+ sendString("z");
+ const results2 = await promise2;
+ checkAutoCompleteResults(results2, [], window.location.host,
+ "Check popup only has the footer when it opens");
+ });
+
+ add_named_task("formless", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, location.origin, null, "user", "pass", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ // Test form-less autocomplete
+ // Test form-less autocomplete
+ // TODO: wait - whats formless about this form?
+ restoreForm(form);
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("open on trusted focus", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user", "pass", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ form.uname.value = "";
+ form.pword.value = "";
+
+ // Move focus to the password field so we can test the first click on the
+ // username field.
+ form.pword.focus();
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const firePrivEventPromise = new Promise((resolve) => {
+ form.uname.addEventListener("click", (e) => {
+ ok(e.isTrusted, "Ensure event is trusted");
+ resolve();
+ });
+ });
+ await popupBy(async () => {
+ synthesizeMouseAtCenter(form.uname, {});
+ await firePrivEventPromise;
+ });
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("recipes", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "user", "pass", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete:8888">
+ <input type="text" name="uname">
+ <input type="text" name="pword">
+ </form>`);
+
+ await loadRecipes({
+ siteRecipes: [{
+ "hosts": [window.location.host],
+ "usernameSelector": "input[name='1']",
+ "passwordSelector": "input[name='2']",
+ }],
+ });
+
+ // Switch the password field to type=password so _fillForm marks the username
+ // field for autocomplete.
+ form.pword.type = "password";
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ restoreForm(form);
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+
+ // Now test recipes with blur on the username field.
+ restoreForm(form);
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ form.uname.value = "user";
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Tab");
+ const autofillResult3 = await formAutofillResult(form.id);
+ is(autofillResult3, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ await resetRecipes();
+ });
+
+ add_named_task("form stays open upon empty search", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete:8888", null, "", "pass", "", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete:8888">
+ <input type="text" name="uname" value="prefilled">
+ <input type="password" name="pword" value="prefilled">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "existing_password", "form has not been filled due to existing password");
+
+ is(form.uname.value, "prefilled", "username is not changed");
+ is(form.pword.value, "prefilled", "password is not changed");
+
+ form.uname.scrollIntoView();
+ await popupBy(() => synthesizeMouseAtCenter(form.uname, {}));
+ form.uname.select();
+ synthesizeKey("KEY_Delete");
+
+ const popupState = await getPopupState();
+ is(popupState.open, true, "Check popup is still open");
+ is(form.uname.value, "", "username is emoty");
+ is(form.pword.value, "prefilled", "password is not changed");
+
+ info("testing password field");
+ synthesizeMouseAtCenter(form.pword, {});
+ form.pword.select();
+ const popupState2 = await getPopupState();
+ is(popupState2.open, false, "Check popup closed since password field isn't empty");
+ await popupBy(() => synthesizeKey("KEY_Delete"));
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("username only", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "https://autocomplete", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete" autocomplete="off">
+ <input type="email" name="uname" value="prefilled" autocomplete="username">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "existing_username", "form has not been filled due to existing username");
+
+ is(form.uname.value, "prefilled", "username is not changed");
+ is(form.pword.value, "", "password is empty");
+ restoreForm(form);
+ await popupByArrowDown();
+
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is set");
+ is(form.pword.value, "pass", "password is set");
+ });
+
+ add_named_task("webauthn", async () => {
+ await setStoredLoginsDuringTask();
+ const form = setContentForTask(`<form id="form1" action="https://autocomplete">
+ <input type="text" name="uname" autocomplete="webauthn">
+ <input type="password" name="pword" autocomplete="current-password webauthn">
+ </form>`);
+
+ // Set up a virtual authenticator with a credential for this site.
+ let authenticatorId = await addVirtualAuthenticator();
+ addCredential(authenticatorId, document.domain);
+
+ // Start a conditionally mediated WebAuthn request.
+ let challenge = crypto.getRandomValues(new Uint8Array(16));
+ let publicKey = { challenge, rpId: document.domain };
+ let webauthnPromise = navigator.credentials.get( { publicKey, mediation: "conditional" });
+
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "no_saved_logins", "form has not been filled due to no saved logins");
+
+ // Ensure that focusing on either input shows the popup
+ form.uname.focus();
+ await popupByArrowDown();
+
+ form.pword.focus();
+ await popupByArrowDown();
+
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+
+ let credential = await webauthnPromise;
+ is(credential.type, "public-key", "received a webauthn credential");
+ });
+</script>
+</pre>
+</body>
+</html>
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..0365d5369c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_formActionOrigin.html
@@ -0,0 +1,79 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test that logins with non-matching formActionOrigin appear in autocomplete dropdown</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Login Manager test: logins with non-matching formActionOrigin appear in autocomplete dropdown
+
+<template id="form1-template">
+ <form id="form1">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const formTemplate = document.getElementById("form1-template");
+
+ add_setup(async () => {
+ await setStoredLoginsDuringTest(
+ [window.location.origin, "https://differentFormSubmitURL", null, "dfsu1", "dfsp1", "uname", "pword"]
+ );
+ listenForUnexpectedPopupShown();
+ });
+
+ add_named_task("form initially empty", async () => {
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "no_saved_logins", "form has not been filled due to no saved logins");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+ });
+
+ /* For this testcase, the only login that exists for this origin
+ * is one with a different formActionOrigin, so the login will appear
+ * in the autocomplete popup.
+ */
+ add_named_task("menu shows logins for different form action origin", async () => {
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "no_saved_logins", "form has not been filled due to no saved logins");
+
+ // Trigger autocomplete popup
+ form.uname.focus();
+
+ const autocompleteItems = await popupByArrowDown();
+
+ const popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+ const expectedMenuItems = ["dfsu1"];
+ checkAutoCompleteResults(autocompleteItems, expectedMenuItems, window.location.host, "Check all menuitems are displayed correctly.");
+
+ synthesizeKey("KEY_ArrowDown"); // first item
+
+ // value shouldn't update just by selecting
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "dfsu1", "username is set");
+ is(form.pword.value, "dfsp1", "password is set");
+ });
+</script>
+</pre>
+</body>
+</html>
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..156a89f77a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_insecure.html
@@ -0,0 +1,932 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test insecure form field autocomplete</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Test for Login Manager: login autocomplete, with insecure connection. This tests autocomplete menu items, its navigation, the selection and deletion of entries as well as sending untrusted events.
+
+This tests the login manager in a insecure setting using http. A similar test file exists for using a secure connection: test_autocomplete_basic_form.html.
+
+<template id="form1-template">
+ <form id="form1" action="http://autocomplete:8888/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const formTemplate = document.getElementById("form1-template");
+
+ // Restore the form to the default state.
+ function restoreForm(form) {
+ form.uname.value = "";
+ form.pword.value = "";
+ form.uname.focus();
+ }
+
+ add_setup(async () => {
+ listenForUnexpectedPopupShown();
+ });
+
+ add_named_task("form is initially empty and popup closed", async () => {
+ const form = setContentForTask(formTemplate);
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is closed");
+ });
+
+ add_named_task("menuitems, telemetry events, selection and escape", async () => {
+ await setStoredLoginsDuringTask(
+ // login 0 has no username, so should be filtered out from the autocomplete list.
+ [location.origin, "http://autocomplete:8888", null, "", "pass0", "", "pword"],
+
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-3", "pass-3", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ form.uname.focus();
+ const autocompleteItems = await popupByArrowDown();
+
+ const popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+ const expectedMenuItems = [
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ "user-1",
+ "user-2",
+ "user-3"
+ ];
+ checkAutoCompleteResults(autocompleteItems, expectedMenuItems,
+ window.location.host, "Check all menuitems are displayed correctly.");
+
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Escape");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("select first entry", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ const popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown"); // first
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "pass-1", "password is set");
+ });
+
+ add_named_task("select second entry", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("wrap around first entry", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_ArrowDown"); // footer
+ synthesizeKey("KEY_ArrowDown"); // deselects
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "pass-1", "password is set");
+ });
+
+ add_named_task("wrap around up last entry", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_ArrowUp"); // last (fourth)
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("wrap around up down up up", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown"); // select first entry
+ synthesizeKey("KEY_ArrowUp"); // selects nothing!
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_ArrowUp"); // select last entry
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("wrap around up down up last", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowUp"); // deselects
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_ArrowUp"); // last entry
+ synthesizeKey("KEY_ArrowUp"); // skip insecure warning
+ synthesizeKey("KEY_ArrowUp"); // first entry
+ synthesizeKey("KEY_ArrowUp"); // deselects
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_ArrowUp"); // last entry
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("fill username without autofill right", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ // Set first entry w/o triggering autocomplete
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowRight");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("fill username without autofill left", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ // Set first entry w/o triggering autocomplete
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowLeft");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("page up first", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ // Check first entry (page up)
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_PageUp"); // first
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "pass-1", "password is set");
+ });
+
+ add_named_task("page down last", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ // Check last entry (page down)
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_PageDown"); // footer
+ synthesizeKey("KEY_ArrowUp"); // last
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("untrusted event", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("delete", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-3", "pass-3", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+
+ // XXX tried sending character "t" before/during dropdown to test
+ // filtering, but had no luck. Seemed like the character was getting lost.
+ // Setting uname.value didn't seem to work either. This works with a human
+ // driver, so I'm not sure what's up.
+
+ // Delete the first entry (of 3), "user-1"
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown");
+ const numLoginsBeforeDeletion = await LoginManager.countLogins(location.origin, "http://autocomplete:8888", null);
+ is(numLoginsBeforeDeletion, 3, "Correct number of logins before deleting one");
+
+ const countChangedPromise = notifyMenuChanged(4);
+ const deletionPromise = promiseStorageChanged(["removeLogin"]);
+ // On OS X, shift-backspace and shift-delete work, just delete does not.
+ // On Win/Linux, shift-backspace does not work, delete and shift-delete do.
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ await deletionPromise;
+
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const numLoginsAfterDeletion = await LoginManager.countLogins(location.origin, "http://autocomplete:8888", null);
+ is(numLoginsAfterDeletion, 2, "Correct number of logins after deleting one");
+ await countChangedPromise;
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-2", "username is set");
+ is(form.pword.value, "pass-2", "password is set");
+ });
+
+ add_named_task("delete second", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-3", "pass-3", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ // Delete the second entry (of 3), "user-2"
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const numLoginsAfterDeletion = await LoginManager.countLogins(location.origin, "http://autocomplete:8888", null);
+ is(numLoginsAfterDeletion, 2, "Correct number of logins after deleting one");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-3", "username is set");
+ is(form.pword.value, "pass-3", "password is set");
+ });
+
+ add_named_task("delete last", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-2", "pass-2", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "user-3", "pass-3", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ form.uname.focus();
+ await popupByArrowDown();
+
+ /* test 54 */
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ // Delete the last entry (of 3), "user-3"
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ const numLoginsBeforeDeletion = await LoginManager.countLogins(location.origin, "http://autocomplete:8888", null);
+ is(numLoginsBeforeDeletion, 3, "Correct number of logins before deleting one");
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const numLoginsAfterDeletion = await LoginManager.countLogins(location.origin, "http://autocomplete:8888", null);
+ is(numLoginsAfterDeletion, 2, "Correct number of logins after deleting one");
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user-1", "username is set");
+ is(form.pword.value, "pass-1", "password is set");
+ });
+
+
+ // Tests for single-user forms for ignoring autocomplete=off
+
+ add_named_task("default", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete2">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("password autocomplete off", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete2">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="off">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+
+ restoreForm(form);
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ // fields should not update just on selection
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("username autocomplete off", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete2">
+ <input type="text" name="uname" autocomplete="off">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+
+ restoreForm(form);
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ // value should not update just on selection
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("form autocomplete off", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete2" autocomplete="off">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ restoreForm(form);
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ // value should not update just on selection
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("username and password autocomplete off", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete2">
+ <input type="text" name="uname" autocomplete="off">
+ <input type="password" name="pword" autocomplete="off">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ restoreForm(form);
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ // value should not update just on selection
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("changing username does not touch password", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete2", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete2">
+ <input type="text" name="uname" autocomplete="off">
+ <input type="password" name="pword" autocomplete="off">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+
+ // Test that the password field remains filled in after changing
+ // the username.
+ form.uname.focus();
+ synthesizeKey("KEY_ArrowRight");
+ synthesizeKey("X", {shiftKey: true});
+ // Trigger the 'blur' event on uname
+ form.pword.focus();
+ is(form.uname.value, "userX", "username is set");
+ is(form.pword.value, "pass", "password is set");
+ });
+
+ add_named_task("additional username field in between", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete3", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete3", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete3">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+
+ // Insert a new username field into the form. We'll then make sure
+ // that invoking the autocomplete doesn't try to fill the form.
+ const newField = document.createElement("input");
+ newField.setAttribute("type", "text");
+ newField.setAttribute("name", "uname2");
+ form.insertBefore(newField, form.pword);
+ is(newField.value, "", "Verifying empty uname2");
+ });
+
+ add_named_task("additional username field in between and form reset", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete3", null, "user", "pass", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete3">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ // Insert a new username field into the form. We'll then make sure
+ // that invoking the autocomplete doesn't try to fill the form.
+ const newField = document.createElement("input");
+ newField.setAttribute("type", "text");
+ newField.setAttribute("name", "uname2");
+ form.insertBefore(newField, form.pword);
+
+ restoreForm(form);
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems,
+ [
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ "user"
+ ],
+ window.location.host,
+ "Check dropdown is showing all logins while field is blank");
+
+
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ // The form changes, so we expect the old username field to get the
+ // selected autocomplete value, but neither the new username field nor
+ // the password field should have any values filled in.
+ await SimpleTest.promiseWaitForCondition(() => form.uname.value == "user",
+ "Wait for username to get filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "", "password is empty");
+ is(newField.value, "", "Verifying empty uname2");
+ });
+
+ add_named_task("two forms with different actions", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete", null, "user", "pass", "uname", "pword"]
+ );
+ const div = setContentForTask(`<div>
+ <form id="form1" action="http://autocomplete-other">
+ <input type="email" name="uname">
+ <input type="password" name="pword">
+ </form>
+ <form id="form2" action="http://autocomplete">
+ <input type="email" name="uname">
+ <input type="password" name="pword">
+ </form>
+ </div>`);
+ const form1 = div.querySelector("#form1");
+ const form2 = div.querySelector("#form2");
+ const autofillResult = await formAutofillResult(form1.id);
+ is(autofillResult, "no_saved_logins", "form has not been filled due to no saved logins");
+ const autofillResult2 = await formAutofillResult(form2.id);
+ is(autofillResult2, "filled", "form has been filled");
+
+ is(form2.uname.value, "user", "username is set");
+ is(form2.pword.value, "pass", "password is set");
+
+ restoreForm(form2);
+ is(form2.uname.value, "", "username is empty");
+ is(form2.pword.value, "", "password is empty");
+
+ form1.uname.focus();
+ is(form2.uname.value, "", "username is empty");
+ is(form2.pword.value, "", "password is empty");
+ });
+
+ add_named_task("filtering", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete", null, "form9userAB", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete", null, "form9userAAB", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+
+ const results = await popupBy(() => form.uname.focus());
+ checkAutoCompleteResults(results,
+ [
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ "form9userAAB",
+ "form9userAB"
+ ],
+ window.location.host,
+ "Check dropdown is showing all logins while field is blank");
+ synthesizeKey("KEY_Escape"); // Need to close the popup so we can get another popupshown after sending the string below.
+
+ const results2 = await popupBy(() => sendString("form9userAB"));
+ checkAutoCompleteResults(results2,
+ [
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ "form9userAB"
+ ],
+ window.location.host,
+ "Check dropdown is showing login with only one 'A'");
+
+ is(form.uname.value, "form9userAB", "username is set");
+ is(form.pword.value, "", "password is empty");
+ form.uname.focus();
+ synthesizeKey("KEY_ArrowLeft");
+ const results3 = await popupBy(() => synthesizeKey("A", {shiftKey: true}));
+
+ is(form.uname.value, "form9userAAB", "username is set");
+ is(form.pword.value, "", "password is empty");
+ checkAutoCompleteResults(results3,
+ [
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ "form9userAAB"
+ ],
+ window.location.host, "Check dropdown is updated after inserting 'A'");
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "form9userAAB", "username is set");
+ is(form.pword.value, "pass-2", "password set");
+ });
+
+ add_named_task("autocomplete cache", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "form9userAB", "pass-1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "form9userAAB", "pass-2", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "multiple_logins", "form has not been filled due to multiple logins");
+
+ await popupBy(() => form.uname.focus());
+
+ await addLoginsInParent(
+ [location.origin, "https://autocomplete", null, "form9userAABzz", "pass-3", "uname", "pword"]
+ );
+
+ const promise1 = notifyMenuChanged(2);
+ sendString("z");
+ const results1 = await promise1;
+ checkAutoCompleteResults(results1, [
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ ], window.location.host,
+ "Check popup does not have any login items");
+
+ // check that empty results are cached - bug 496466
+ const promise2 = notifyMenuChanged(2);
+ sendString("z");
+ const results2 = await promise2;
+ checkAutoCompleteResults(results2, [
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ ], window.location.host,
+ "Check popup only has the footer when it opens");
+ });
+
+ add_named_task("formless", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user", "pass", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ // Test form-less autocomplete
+ // TODO: wait - whats formless about this form?
+ restoreForm(form);
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("open on trusted focus", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user", "pass", "uname", "pword"]
+ );
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ form.uname.value = "";
+ form.pword.value = "";
+
+ // Move focus to the password field so we can test the first click on the
+ // username field.
+ form.pword.focus();
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const firePrivEventPromise = new Promise((resolve) => {
+ form.uname.addEventListener("click", (e) => {
+ ok(e.isTrusted, "Ensure event is trusted");
+ resolve();
+ });
+ });
+ await popupBy(async () => {
+ synthesizeMouseAtCenter(form.uname, {});
+ await firePrivEventPromise;
+ });
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ });
+
+ add_named_task("recipes", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "user", "pass", "uname", "pword"]
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete:8888">
+ <input type="text" name="uname">
+ <input type="text" name="pword">
+ </form>`);
+
+ await loadRecipes({
+ siteRecipes: [{
+ "hosts": [window.location.host],
+ "usernameSelector": "input[name='1']",
+ "passwordSelector": "input[name='2']",
+ }],
+ });
+
+ // Switch the password field to type=password so _fillForm marks the username
+ // field for autocomplete.
+ form.pword.type = "password";
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+ restoreForm(form);
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+
+ // Now test recipes with blur on the username field.
+ restoreForm(form);
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ form.uname.value = "user";
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Tab");
+ const autofillResult3 = await formAutofillResult(form.id);
+ is(autofillResult3, "filled", "form has been filled");
+ is(form.uname.value, "user", "username is filled");
+ is(form.pword.value, "pass", "password is filled");
+ await resetRecipes();
+ });
+
+ add_named_task("form stays open upon empty search", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete:8888", null, "", "pass", "", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete:8888">
+ <input type="text" name="uname" value="prefilled">
+ <input type="password" name="pword" value="prefilled">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "existing_password", "form has not been filled due to existing password");
+
+ is(form.uname.value, "prefilled", "username is not changed");
+ is(form.pword.value, "prefilled", "password is not changed");
+
+ form.uname.scrollIntoView();
+ await popupBy(() => synthesizeMouseAtCenter(form.uname, {}));
+ form.uname.select();
+ synthesizeKey("KEY_Delete");
+
+ const popupState = await getPopupState();
+ is(popupState.open, true, "Check popup is still open");
+ is(form.uname.value, "", "username is emoty");
+ is(form.pword.value, "prefilled", "password is not changed");
+
+ info("testing password field");
+ synthesizeMouseAtCenter(form.pword, {});
+ form.pword.select();
+ const popupState2 = await getPopupState();
+ is(popupState2.open, false, "Check popup closed since password field isn't empty");
+ await popupBy(() => synthesizeKey("KEY_Delete"));
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ });
+
+ add_named_task("username only", async () => {
+ await setStoredLoginsDuringTask(
+ [location.origin, "http://autocomplete", null, "user", "pass", "uname", "pword"],
+ );
+ const form = setContentForTask(`<form id="form1" action="http://autocomplete" autocomplete="off">
+ <input type="email" name="uname" value="prefilled" autocomplete="username">
+ <input type="password" name="pword">
+ </form>`);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "existing_username", "form has not been filled due to existing username");
+
+ is(form.uname.value, "prefilled", "username is not changed");
+ is(form.pword.value, "", "password is empty");
+ restoreForm(form);
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // skip insecure warning
+ // Check first entry
+ synthesizeKey("KEY_ArrowDown");
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ synthesizeKey("KEY_Enter");
+ await untilAutocompletePopupClosed();
+ is(form.uname.value, "user", "username is set");
+ is(form.pword.value, "pass", "password is set");
+ });
+</script>
+</pre>
+</body>
+</html>
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..1dcd8b116e
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_basic_form_related_realms.html
@@ -0,0 +1,112 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test login autocomplete with related realms</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Login Manager test: related realms autocomplete
+
+<template id="form1-template">
+ <form id="form1" action="https://autocomplete:8888/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const formTemplate = document.getElementById("form1-template");
+
+ add_setup(async () => {
+ await setStoredLoginsDuringTest(
+ // Simple related domain relationship where example.com and other-example.com are in the related domains list
+ ["https://other-example.com", "https://other-example.com", null, "relatedUser1", "relatedPass1", "uname", "pword"],
+
+ // Example.com and example.co.uk are related, so sub.example.co.uk should appear on example.com's autocomplete dropdown
+ // The intent is to cover the ebay.com/ebay.co.uk and all other country TLD cases
+ // where the sign in page is actually signin.ebay.com/signin.ebay.co.uk but credentials could have manually been entered
+ // for ebay.com/ebay.co.uk or automatically stored as signin.ebay.com/sigin.ebay.co.uk
+ ["https://sub.example.co.uk", "https://sub.example.co.uk", null, "subUser1", "subPass1", "uname", "pword"],
+ );
+ listenForUnexpectedPopupShown();
+ });
+
+ add_named_task("form is initially empty", async () => {
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "no_saved_logins", "form has not been filled due to no saved logins");
+
+ // Make sure initial form is empty.
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+ const popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+ });
+
+ add_named_task("form related domain menuitems", async () => {
+ const form = setContentForTask(formTemplate);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "no_saved_logins", "form has not been filled due to no saved logins");
+
+ form.uname.focus();
+
+ const autocompleteItems = await popupByArrowDown();
+ const popupState = await getPopupState();
+
+ is(popupState.selectedIndex, -1, "Check no entires are selected upon opening");
+
+ const expectedMenuItems = ["relatedUser1", "subUser1"];
+ checkAutoCompleteResults(autocompleteItems, expectedMenuItems, window.location.host, "Check all menuitems are displayed correctly");
+
+ const acEvents = await getTelemetryEvents({ process: "parent", filterProps: TelemetryFilterPropsAC, clear: true });
+ is(acEvents.length, 1, "One autocomplete event");
+ checkACTelemetryEvent(acEvents[0], form.uname, {
+ "hadPrevious": "0",
+ "login": expectedMenuItems.length + "",
+ "loginsFooter": "1"
+ });
+ checkLoginForm(form.uname, "", form.pword, ""); // value shouldn't update just by opening
+
+ synthesizeKey("KEY_ArrowDown"); // first item
+ // value shouldn't update just by selecting
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+
+ synthesizeKey("KEY_Enter");
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ is(form.pword.value, "relatedPass1", "password should match the login that was selected");
+ checkLoginForm(form.uname, "relatedUser1", form.pword, "relatedPass1");
+ is(form.uname.value, "relatedUser1", "username is set");
+ is(form.pword.value, "relatedPass1", "password is set");
+
+ form.uname.value = "";
+ form.pword.value = "";
+ form.uname.focus();
+
+ await popupByArrowDown();
+
+ synthesizeKey("KEY_ArrowDown"); // first item
+ synthesizeKey("KEY_ArrowDown"); // second item
+ // value shouldn't update just by selecting
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+
+ synthesizeKey("KEY_Enter");
+ const autofillResult3 = await formAutofillResult(form.id);
+ is(autofillResult3, "filled", "form has been filled");
+ is(form.uname.value, "subUser1", "username is set");
+ is(form.pword.value, "subPass1", "password is set");
+ });
+</script>
+</pre>
+</body>
+</html>
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..0eb2beadc7
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_hasBeenTypePassword.html
@@ -0,0 +1,93 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test that passwords are autocompleted into fields that were previously type=password</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Login Manager test: Test that passwords are autocompleted into fields that were
+previously type=password
+
+Usually the autocomplete login form only operates on password fields which are
+of type `password`. But when a password fields type has been changed to `text`
+via JavaScript, the password manager should still fill this form, because this
+is often used in real world forms to handle masking/unmasking of password
+fields.
+
+<template id="form1-template">
+ <form id="form1" action="https://www.example.com/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const formTemplate = document.getElementById("form1-template");
+
+ add_setup(async () => {
+ const origin = window.location.origin;
+ await setStoredLoginsDuringTest(
+ [origin, origin, null, "user1", "pass1"],
+ [origin, origin, null, "user2", "pass2"]
+ );
+ listenForUnexpectedPopupShown();
+ });
+
+ add_named_task("autofill operates on a password field made text", async () => {
+ const form = setContentForTask(formTemplate);
+
+ // initial consume autofill event
+ await formAutofillResult(form.id);
+
+ info("Setting the password field type to text");
+ // This is similar to a site implementing their own password visibility/unmasking toggle
+ form.pword.type = "text";
+
+ // Trigger autocomplete popup
+ form.uname.focus();
+ const autocompleteItems = await popupByArrowDown();
+
+ const popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+ checkAutoCompleteResults(autocompleteItems, ["user1", "user2"], window.location.host,
+ "Check all menuitems are displayed correctly.");
+
+ synthesizeKey("KEY_ArrowDown"); // first item
+ // value shouldn't update just by selecting
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+
+ synthesizeKey("KEY_Enter");
+
+ const autofillResult1 = await formAutofillResult(form.id);
+ is(autofillResult1, "filled", "form has been filled");
+
+ is(form.uname.value, "user1", "username should match the login");
+ is(form.pword.value, "pass1", "password should match the login");
+
+ form.reset();
+
+ form.pword.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown"); // first item
+ // value shouldn't update just by selecting
+ is(form.uname.value, "", "username is empty");
+ is(form.pword.value, "", "password is empty");
+
+ synthesizeKey("KEY_Enter");
+ is(form.uname.value, "", "username should stay empty");
+ is(form.pword.value, "pass1", "Password should match the login that was selected");
+ });
+</script>
+</pre>
+</body>
+</html>
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..49cedde0bc
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight.html
@@ -0,0 +1,86 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form field autofill highlight</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+<script>
+const { ContentTaskUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/ContentTaskUtils.sys.mjs"
+);
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [location.origin, "https://autocomplete", null, "user1", "pass1", "", ""],
+ [location.origin, "https://autocomplete", null, "user2", "pass2", "", ""]
+ );
+});
+
+add_task(async function test_field_highlight_on_autocomplete() {
+ const form = createLoginForm({
+ action: "https://autocomplete"
+ });
+
+ await openPopupOn(form.uname);
+ synthesizeKey("KEY_ArrowDown");
+ await synthesizeKey("KEY_Enter");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return form.uname.matches(":autofill")
+ }, "Highlight was successfully applied to the username field on username autocomplete");
+
+ ok(form.pword.matches(":autofill"),
+ "Highlight was successfully applied to the password field on username autocomplete");
+
+ // Clear existing highlight on login fields. We check by pressing the tab key after backspace
+ // (by shifting focus to the next element) because the tab key was known to cause a bug where the
+ // highlight is applied once again. See Bug 1526522.
+ form.uname.focus();
+ synthesizeKey("KEY_Backspace");
+ synthesizeKey("KEY_Tab");
+ ok(!form.uname.matches(":autofill"),
+ "Highlight was successfully removed on the username field");
+
+ synthesizeKey("KEY_Backspace");
+ synthesizeKey("KEY_Tab");
+ ok(!form.pword.matches(":autofill"),
+ "Highlight was successfully removed on the password field");
+
+ // Clear login fields.
+ form.uname.value = "";
+ form.pword.value = "";
+
+ // Test password field autocomplete.
+ await openPopupOn(form.pword);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return form.pword.matches(":autofill");
+ }, "Highlight was successfully applied to the password field on password autocomplete");
+
+ // Clear existing highlight on the password field. We check by pressing the tab key after backspace
+ // (by shifting focus to the next element) because the tab key was known to cause a bug where the
+ // highlight is applied once again. See Bug 1526522.
+ synthesizeKey("KEY_Backspace");
+ synthesizeKey("KEY_Tab");
+
+ ok(!form.pword.matches(":autofill"),
+ "Highlight was successfully removed on the password field");
+
+ // Clear login fields.
+ form.uname.value = "";
+ form.pword.value = "";
+});
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form field autofill highlight</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+<script>
+function closeCurrentTab() {
+ runInParent(function cleanUpWindow() {
+ let window = Services.wm.getMostRecentWindow("navigator:browser");
+ window.gBrowser.removeTab(window.gBrowser.selectedTab);
+ });
+}
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [location.origin, "http://autocomplete", null, "user1", "pass1", "", ""],
+ [location.origin, "http://autocomplete", null, "user2", "pass2", "", ""]
+ );
+});
+
+add_task(async function test_field_highlight_on_pw_field_autocomplete_insecureWarning() {
+ const form = createLoginForm({
+ action: "http://autocomplete"
+ });
+ await promiseFormsProcessedInSameProcess();
+
+ // Press enter on insecure warning and check.
+ form.pword.focus();
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown"); // insecure warning
+ synthesizeKey("KEY_Enter");
+
+ is(document.defaultView.getComputedStyle(form.pword).getPropertyValue("filter"), "none",
+ "Highlight is not applied to the password field if enter key is pressed on the insecure warning item");
+ is(document.defaultView.getComputedStyle(form.uname).getPropertyValue("filter"), "none",
+ "Highlight is not applied to the username field if enter key is pressed on the insecure warning item");
+
+ // Press tab on insecure warning and check.
+ await openPopupOn(form.pword);
+ synthesizeKey("KEY_ArrowDown"); // insecure warning
+ synthesizeKey("KEY_Tab");
+
+ is(document.defaultView.getComputedStyle(form.pword).getPropertyValue("filter"), "none",
+ "Highlight is not applied to the password field if tab key is pressed on the insecure warning item");
+ is(document.defaultView.getComputedStyle(form.uname).getPropertyValue("filter"), "none",
+ "Highlight is not applied to the username field if tab key is pressed on the insecure warning item");
+});
+
+add_task(async function test_field_highlight_on_pw_field_autocomplete_footer() {
+ const form = createLoginForm({
+ action: "http://autocomplete"
+ });
+ await promiseFormsProcessedInSameProcess();
+
+ // Press enter on the footer and check.
+ await openPopupOn(form.pword);
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_Enter");
+
+ is(document.defaultView.getComputedStyle(form.pword).getPropertyValue("filter"), "none",
+ "Highlight is not applied to the password field if enter key is pressed on the footer item");
+ is(document.defaultView.getComputedStyle(form.uname).getPropertyValue("filter"), "none",
+ "Highlight is not applied to the username field if enter key is pressed on the footer item");
+
+ closeCurrentTab();
+
+ // Press tab on the footer and check.
+ await openPopupOn(form.pword);
+ synthesizeKey("KEY_ArrowUp"); // footer
+ synthesizeKey("KEY_Tab");
+
+ is(document.defaultView.getComputedStyle(form.pword).getPropertyValue("filter"), "none",
+ "Highlight is not applied to the password field if tab key is pressed on the footer item");
+ is(document.defaultView.getComputedStyle(form.uname).getPropertyValue("filter"), "none",
+ "Highlight is not applied to the username field if tab key is pressed on the insecure warning item");
+
+ closeCurrentTab();
+});
+</script>
+</body>
+</html>
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..2451f47308
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_highlight_username_only_form.html
@@ -0,0 +1,56 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form field autofill highlight</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+<script>
+const { ContentTaskUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/ContentTaskUtils.sys.mjs"
+);
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [location.origin, "https://autocomplete", null, "user1", "pass1", "", ""],
+ [location.origin, "https://autocomplete", null, "user2", "pass2", "", ""]
+ );
+});
+
+add_task(async function test_username_field_in_username_only_form_highlight_on_autocomplete() {
+ const form = createLoginForm({
+ action: "https://autocomplete",
+ username: {
+ autocomplete: "username"
+ },
+ password: false
+ });
+
+ await openPopupOn(form.uname);
+ synthesizeKey("KEY_ArrowDown");
+ await synthesizeKey("KEY_Enter");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return form.uname.matches(":autofill");
+ }, "Highlight was successfully applied to the username field on username autocomplete");
+
+ // Clear existing highlight on login fields. We check by pressing the tab key after backspace
+ // (by shifting focus to the next element) because the tab key was known to cause a bug where the
+ // highlight is applied once again. See Bug 1526522.
+ form.uname.focus();
+ synthesizeKey("KEY_Backspace");
+ synthesizeKey("KEY_Tab");
+ ok(!form.uname.matches(":autofill"),
+ "Highlight was successfully removed on the username field");
+});
+</script>
+</body>
+</html>
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..44d830afe5
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_https_downgrade.html
@@ -0,0 +1,105 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autocomplete on an HTTPS page using upgraded HTTP logins</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <iframe></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+const origin = "http://" + window.location.host;
+const secureOrigin = "https://" + window.location.host;
+const iframe = document.getElementsByTagName("iframe")[0];
+let iframeDoc, hostname;
+let uname;
+let pword;
+
+// Restore the form to the default state.
+function restoreForm() {
+ return SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ this.content.document.getElementById("form-basic-password").focus();
+ this.content.document.getElementById("form-basic-username").value = "";
+ this.content.document.getElementById("form-basic-password").value = "";
+ this.content.document.getElementById("form-basic-username").focus();
+ });
+}
+
+const HTTP_FORM_URL = origin + "/tests/toolkit/components/passwordmgr/test/mochitest/form_basic.html";
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ // We have two actual HTTPS to avoid autofill before the schemeUpgrades pref flips to true.
+ [secureOrigin, secureOrigin, null, "name", "pass", "uname", "pword"],
+ [secureOrigin, secureOrigin, null, "name1", "pass1", "uname", "pword"],
+ // Same as above but HTTP instead of HTTPS (to test de-duping)
+ [origin, origin, null, "name1", "pass1", "uname", "pword"],
+ // Different HTTP login to upgrade with secure formActionOrigin
+ [origin, secureOrigin, null, "name2", "passHTTPtoHTTPS", "uname", "pword"]
+ );
+});
+
+async function setup(formUrl) {
+ await SpecialPowers.pushPrefEnv({"set": [["signon.schemeUpgrades", true]]});
+
+ let processedPromise = promiseFormsProcessed();
+ iframe.src = formUrl;
+ await new Promise(resolve => {
+ iframe.addEventListener("load", function() {
+ resolve();
+ }, {once: true});
+ });
+
+ await processedPromise;
+
+ hostname = await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ return this.content.document.documentURIObject.host;
+ });
+}
+
+add_task(async function test_autocomplete_https_downgrade() {
+ info("test_autocomplete_http, setup with " + HTTP_FORM_URL);
+ await setup(HTTP_FORM_URL);
+
+ let logins = await LoginManager.getAllLogins();
+ info("got logins: " + logins.map(l => l.origin));
+
+ // from a HTTP page, look for matching logins, we should never offer a login with an HTTPS scheme
+ // we're expecting just login2 as a match
+ let isCrossOrigin = false;
+ try {
+ // If this is a cross-origin test, the parent will be inaccessible. The fields
+ // should not be filled in.
+ window.parent.windowGlobalChild;
+ } catch(ex) {
+ isCrossOrigin = true;
+ }
+
+ await checkLoginFormInFrame(iframe, "form-basic-username", isCrossOrigin ? "" : "name1",
+ "form-basic-password", isCrossOrigin ? "" : "pass1");
+
+ // Trigger autocomplete popup
+ await restoreForm();
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+ const autocompleteItems = await popupByArrowDown();
+ info("got results: " + autocompleteItems.join(", "));
+ popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected");
+ checkAutoCompleteResults(autocompleteItems, ["This connection is not secure. Logins entered here could be compromised. Learn More", "name1", "name2"], hostname, "initial");
+});
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autocomplete on an HTTPS page using upgraded HTTP logins</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <iframe></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+const originSecure = "https://" + window.location.host;
+const originNonSecure = "http://" + window.location.host;
+const iframe = document.getElementsByTagName("iframe")[0];
+let iframeDoc, hostname;
+
+// Restore the form to the default state.
+function restoreForm() {
+ return SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ this.content.document.getElementById("form-basic-password").focus();
+ this.content.document.getElementById("form-basic-username").value = "";
+ this.content.document.getElementById("form-basic-password").value = "";
+ this.content.document.getElementById("form-basic-username").focus();
+ });
+}
+
+const HTTPS_FORM_URL = originSecure + "/tests/toolkit/components/passwordmgr/test/mochitest/form_basic.html";
+
+async function setup(formUrl = HTTPS_FORM_URL) {
+ await SpecialPowers.pushPrefEnv({"set": [["signon.schemeUpgrades", true]]});
+
+ let processedPromise = promiseFormsProcessed();
+ iframe.src = formUrl;
+ await new Promise(resolve => {
+ iframe.addEventListener("load", function() {
+ resolve();
+ }, {once: true});
+ });
+
+ await processedPromise;
+
+ hostname = await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ return this.content.document.documentURIObject.host;
+ });
+}
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ // We have two actual HTTPS to avoid autofill before the schemeUpgrades pref flips to true.
+ [originSecure, originSecure, null, "name", "pass", "uname", "pword"],
+ [originSecure, originSecure, null, "name1", "pass1", "uname", "pword"],
+
+ // Same as above but HTTP instead of HTTPS (to test de-duping)
+ [originNonSecure, originNonSecure, null, "name1", "pass1", "uname", "pword"],
+
+ // Different HTTP login to upgrade with secure formActionOrigin
+ [originNonSecure, originSecure, null, "name2", "passHTTPtoHTTPS", "uname", "pword"]
+ );
+});
+
+add_task(async function setup_https_frame() {
+ await setup(HTTPS_FORM_URL);
+});
+
+add_task(async function test_empty_first_entry() {
+ // Make sure initial form is empty.
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0), "form-basic-username", "", "form-basic-password", "");
+ // Trigger autocomplete popup
+ await restoreForm();
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+ let autocompleteItems = await popupBy();
+ popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected");
+ checkAutoCompleteResults(autocompleteItems, ["name", "name1", "name2"], hostname, "initial");
+
+ // Check first entry
+ let index0Promise = notifySelectedIndex(0);
+ synthesizeKey("KEY_ArrowDown");
+ await index0Promise;
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0), "form-basic-username", "", "form-basic-password", ""); // value shouldn't update
+ synthesizeKey("KEY_Enter");
+ await promiseFormsProcessedInSameProcess();
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0), "form-basic-username", "name", "form-basic-password", "pass");
+});
+
+add_task(async function test_empty_second_entry() {
+ await restoreForm();
+ await popupBy();
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_Enter");
+ await promiseFormsProcessedInSameProcess();
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0), "form-basic-username", "name1", "form-basic-password", "pass1");
+});
+
+add_task(async function test_search() {
+ await restoreForm();
+ let results = await popupBy(async () => {
+ // We need to blur for the autocomplete controller to notice the forced value below.
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ let uname = this.content.document.getElementById("form-basic-username");
+ uname.blur();
+ uname.value = "name";
+ uname.focus();
+ });
+
+ sendChar("1");
+ synthesizeKey("KEY_ArrowDown"); // open
+ });
+ checkAutoCompleteResults(results, ["name1"], hostname, "check result deduping for 'name1'");
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_Enter");
+ await promiseFormsProcessedInSameProcess();
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0), "form-basic-username", "name1", "form-basic-password", "pass1");
+
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is now closed");
+});
+
+add_task(async function test_delete_first_entry() {
+ await restoreForm();
+ await popupBy();
+
+ let index0Promise = notifySelectedIndex(0);
+ synthesizeKey("KEY_ArrowDown");
+ await index0Promise;
+
+ let deletionPromise = promiseStorageChanged(["removeLogin"]);
+ // On OS X, shift-backspace and shift-delete work, just delete does not.
+ // On Win/Linux, shift-backspace does not work, delete and shift-delete do.
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ await deletionPromise;
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0), "form-basic-username", "", "form-basic-password", "");
+
+ let results = await notifyMenuChanged(3, "name1");
+
+ checkAutoCompleteResults(results, ["name1", "name2"], hostname, "two logins should remain after deleting the first");
+ let popupState = await getPopupState();
+ is(popupState.open, true, "Check popup stays open after deleting");
+ synthesizeKey("KEY_Escape");
+ popupState = await getPopupState();
+ is(popupState.open, false, "Check popup closed upon ESC");
+});
+
+add_task(async function test_delete_duplicate_entry() {
+ await restoreForm();
+ await popupBy();
+
+ let index0Promise = notifySelectedIndex(0);
+ synthesizeKey("KEY_ArrowDown");
+ await index0Promise;
+
+ let deletionPromise = promiseStorageChanged(["removeLogin"]);
+ // On OS X, shift-backspace and shift-delete work, just delete does not.
+ // On Win/Linux, shift-backspace does not work, delete and shift-delete do.
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ await deletionPromise;
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0), "form-basic-username", "", "form-basic-password", "");
+
+ is(await LoginManager.countLogins(originNonSecure, originNonSecure, null), 1,
+ "Check that the HTTP login remains");
+ is(await LoginManager.countLogins(originSecure, originSecure, null), 0,
+ "Check that the HTTPS login was deleted");
+
+ // Two menu items should remain as the HTTPS login should have been deleted but
+ // the HTTP would remain.
+ let results = await notifyMenuChanged(2, "name2");
+
+ checkAutoCompleteResults(results, ["name2"], hostname, "one login should remain after deleting the HTTPS name1");
+ let popupState = await getPopupState();
+ is(popupState.open, true, "Check popup stays open after deleting");
+ synthesizeKey("KEY_Escape");
+ popupState = await getPopupState();
+ is(popupState.open, false, "Check popup closed upon ESC");
+});
+
+</script>
+</pre>
+</body>
+</html>
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..3fd8b1e90b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation.html
@@ -0,0 +1,596 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill and autocomplete on autocomplete=new-password fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <script src="../../../satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Login Manager test: autofill with password generation on `autocomplete=new-password` fields.
+
+<template id="form1-template">
+ <form id="form1" action="https://autofill">
+ <input type="text" name="uname">
+ <input type="password" name="p">
+ <button type="submit" name="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form2-template">
+ <form id="form2" action="https://autofill">
+ <input type="text" name="uname">
+ <input type="password" name="password" autocomplete="new-password">
+ <button type="submit" name="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form3-template">
+ <form id="form3" action="https://autofill">
+ <input type="text" name="username">
+ <label>New password<input type="password" name="password"></label>
+ <button type="submit" name="submit">Submit</button>
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const { TestUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+
+ const formTemplates = {
+ form1: document.getElementById("form1-template"),
+ form2: document.getElementById("form2-template"),
+ form3: document.getElementById("form3-template"),
+ };
+
+ const dateAndTimeFormatter = new SpecialPowers.Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ });
+
+ async function showACPopup(formNumber, expectedACLabels) {
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems, expectedACLabels,
+ window.location.host, "Check all rows are correct");
+ }
+
+ function clearGeneratedPasswords() {
+ const { LoginManagerParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerParent.sys.mjs"
+ );
+ if (LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin()) {
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ }
+ }
+
+ add_named_task("autofill autocomplete username no generation", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", false],
+ ["signon.generation.enabled", false],
+ );
+ await setStoredLoginsDuringTask([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form1 = setContentForTask(formTemplates.form1);
+
+ const autofillResult1 = await formAutofillResult(form1.id);
+ is(autofillResult1, "filled", "form has not been filled due to multiple logins");
+
+ // reference form was filled as expected?
+ is(form1.uname.value, "user1", "username is filled");
+ is(form1.p.value, "pass1", "password is filled");
+
+ const form2 = setContentForTask(formTemplates.form2);
+ const autofillResult2 = await formAutofillResult(form2.id);
+ is(autofillResult2, "password_autocomplete_new_password", "form has not been filled due to password_autocomplete_new_password");
+
+ // 2nd form should not be filled
+ is(form2.uname.value, "", "username is empty");
+ is(form2.password.value, "", "password is empty");
+
+ form2.uname.focus();
+ await showACPopup(2, ["user1"]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+
+ const autofillResult3 = await formAutofillResult(form2.id);
+ is(autofillResult3, "filled", "form has been filled");
+ is(form2.uname.value, "user1", "username is filled");
+ is(form2.password.value, "pass1", "password is filled");
+ });
+
+ add_named_task("autofill autocomplete password no generation", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", false],
+ ["signon.generation.enabled", false],
+ );
+ await setStoredLoginsDuringTask([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form = setContentForTask(formTemplates.form2);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "password_autocomplete_new_password", "form has not been filled due to password_autocomplete_new_password");
+
+ // form should not be filled
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, "", "password is empty");
+
+ form.password.focus();
+ // No generation option on username fields.
+ await showACPopup(2, ["user1"]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+
+ await SimpleTest.promiseWaitForCondition(() => form.password.value == "pass1", "Check pw filled");
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, "pass1", "password is filled");
+
+ // No autocomplete results should appear for non-empty pw fields.
+ await noPopupByArrowDown();
+ });
+
+ add_named_task("autofill autocomplete username no generation #2", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", false],
+ ["signon.generation.enabled", false],
+ );
+ await setStoredLoginsDuringTask([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form = setContentForTask(formTemplates.form2);
+ const autofillResult1 = await formAutofillResult(form.id);
+ is(autofillResult1, "password_autocomplete_new_password", "form has not been filled due to password_autocomplete_new_password");
+
+ // form should not be filled
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, "", "password is empty");
+
+ form.uname.focus();
+ // No generation option on username fields.
+ await showACPopup(2, ["user1"]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+
+ const autofillResult2 = await formAutofillResult(form.id);
+ is(autofillResult2, "filled", "form has been filled");
+ is(form.uname.value, "user1", "username is filled");
+ is(form.password.value, "pass1", "password is filled");
+ });
+
+ add_named_task("autofill autocomplete passsword with generation", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", true],
+ ["signon.generation.enabled", true],
+ );
+
+ runInParent(clearGeneratedPasswords);
+ await setStoredLoginsDuringTask([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form = setContentForTask(formTemplates.form2);
+ const formNumber = 2;
+
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "password_autocomplete_new_password", "form has not been filled due to password_autocomplete_new_password");
+
+ form.reset();
+
+ // form should not be filled
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, "", "password is empty");
+
+ form.password.focus();
+
+ await showACPopup(formNumber, [
+ "user1",
+ "Use a Securely Generated Password",
+ ]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => form.password.value == "pass1", "Check pw filled");
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, "pass1", "password is filled");
+
+ // No autocomplete results should appear for non-empty pw fields.
+ await noPopupByArrowDown();
+
+ info("Removing all logins to test auto-saving of generated passwords");
+ await LoginManager.removeAllUserFacingLogins();
+
+ while (form.password.value) {
+ synthesizeKey("KEY_Backspace");
+ }
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blanked field");
+
+ info("This time select the generated password");
+ await showACPopup(formNumber, [
+ "Use a Securely Generated Password",
+ ]);
+
+ synthesizeKey("KEY_ArrowDown");
+ const storageAddPromise = promiseStorageChanged(["addLogin"]);
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Before first fill of generated pw");
+ synthesizeKey("KEY_Enter");
+
+ info("waiting for the password field to be filled with the generated password");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check generated pw filled");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, "After first fill of generated pw");
+ info("Wait for generated password to be added to storage");
+ await storageAddPromise;
+
+ let logins = await LoginManager.getAllLogins();
+ const timePasswordChanged = logins[logins.length - 1].timePasswordChanged;
+ const time = dateAndTimeFormatter.format(new Date(timePasswordChanged));
+ const LABEL_NO_USERNAME = "No username (" + time + ")";
+
+ const generatedPW = form.password.value;
+ is(generatedPW.length, GENERATED_PASSWORD_LENGTH, "Check generated password length");
+ ok(generatedPW.match(GENERATED_PASSWORD_REGEX), "Check generated password format");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, "After fill");
+
+ info("Check field is masked upon blurring");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "After blur");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, "After shift-tab to focus again");
+ // Remove selection for OS where the whole value is selected upon focus.
+ synthesizeKey("KEY_ArrowRight");
+
+ while (form.password.value) {
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, form.password.value);
+ synthesizeKey("KEY_Backspace");
+ }
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blanked field");
+
+ info("Blur the empty field to trigger a 'change' event");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blur after blanking");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Focus again after blanking");
+
+ info("Type a single character after blanking");
+ synthesizeKey("@");
+
+ info("Blur the single-character field to trigger a 'change' event");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blur after backspacing");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Focus again after backspacing");
+ synthesizeKey("KEY_Backspace"); // Blank the field again
+
+ await showACPopup(formNumber, [
+ LABEL_NO_USERNAME,
+ "Use a Securely Generated Password",
+ ]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check generated pw filled");
+ // Same generated password should be used, even despite the 'change' to @ earlier.
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, generatedPW, "password is filled with generated password");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, "Second fill of the generated pw");
+
+ logins = await LoginManager.getAllLogins();
+ is(logins.length, 1, "Still 1 login after filling the generated password a 2nd time");
+ is(logins[0].timePasswordChanged, timePasswordChanged, "Saved login wasn't changed");
+ is(logins[0].password, generatedPW, "Password is the same");
+
+ info("filling the saved login to ensure the field is masked again");
+
+ while (form.password.value) {
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, form.password.value);
+ synthesizeKey("KEY_Backspace");
+ }
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blanked field again");
+
+ info("Blur the field to trigger a 'change' event again");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blur after blanking again");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Focus again after blanking again");
+ // Remove selection for OS where the whole value is selected upon focus.
+ synthesizeKey("KEY_ArrowRight");
+
+ await showACPopup(formNumber, [
+ LABEL_NO_USERNAME,
+ "Use a Securely Generated Password",
+ ]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check saved generated pw filled");
+ // Same generated password should be used but from storage
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, generatedPW, "password is filled with generated password");
+ // Passwords from storage should always be masked.
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "after fill from storage");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "after blur");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "after shift-tab to focus again");
+ });
+
+ add_named_task("autofill autocomplete passsword with generation #2", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", true],
+ ["signon.generation.enabled", true],
+ );
+
+ runInParent(clearGeneratedPasswords);
+ await setStoredLoginsDuringTask([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form = setContentForTask(formTemplates.form3);
+ const formNumber = 3;
+
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "filled", "form has been filled");
+
+ form.reset();
+
+ is(form.username.value, "", "username is empty");
+ is(form.password.value, "", "password is empty");
+
+ form.password.focus();
+
+ await showACPopup(formNumber, [
+ "user1",
+ "Use a Securely Generated Password",
+ ]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => form.password.value == "pass1", "Check pw filled");
+ is(form.username.value, "", "username is empty");
+ is(form.password.value, "pass1", "password is filled");
+
+ // No autocomplete results should appear for non-empty pw fields.
+ await noPopupByArrowDown();
+
+ info("Removing all logins to test auto-saving of generated passwords");
+ await LoginManager.removeAllUserFacingLogins();
+
+ while (form.password.value) {
+ synthesizeKey("KEY_Backspace");
+ }
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blanked field");
+
+ info("This time select the generated password");
+ await showACPopup(formNumber, [
+ "Use a Securely Generated Password",
+ ]);
+
+ synthesizeKey("KEY_ArrowDown");
+ const storageAddPromise = promiseStorageChanged(["addLogin"]);
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Before first fill of generated pw");
+ synthesizeKey("KEY_Enter");
+
+ info("waiting for the password field to be filled with the generated password");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check generated pw filled");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, "After first fill of generated pw");
+ info("Wait for generated password to be added to storage");
+ await storageAddPromise;
+
+ let logins = await LoginManager.getAllLogins();
+ const timePasswordChanged = logins[logins.length - 1].timePasswordChanged;
+ const time = dateAndTimeFormatter.format(new Date(timePasswordChanged));
+ const LABEL_NO_USERNAME = "No username (" + time + ")";
+
+ const generatedPW = form.password.value;
+ is(generatedPW.length, GENERATED_PASSWORD_LENGTH, "Check generated password length");
+ ok(generatedPW.match(GENERATED_PASSWORD_REGEX), "Check generated password format");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, "After fill");
+
+ info("Check field is masked upon blurring");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "After blur");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, "After shift-tab to focus again");
+ // Remove selection for OS where the whole value is selected upon focus.
+ synthesizeKey("KEY_ArrowRight");
+
+ while (form.password.value) {
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, form.password.value);
+ synthesizeKey("KEY_Backspace");
+ }
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blanked field");
+
+ info("Blur the empty field to trigger a 'change' event");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blur after blanking");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Focus again after blanking");
+
+ info("Type a single character after blanking");
+ synthesizeKey("@");
+
+ info("Blur the single-character field to trigger a 'change' event");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blur after backspacing");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Focus again after backspacing");
+ synthesizeKey("KEY_Backspace"); // Blank the field again
+
+ await showACPopup(formNumber, [
+ LABEL_NO_USERNAME,
+ "Use a Securely Generated Password",
+ ]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check generated pw filled");
+ // Same generated password should be used, even despite the 'change' to @ earlier.
+ is(form.username.value, "", "username is empty");
+ is(form.password.value, generatedPW, "password is filled with generated password");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, "Second fill of the generated pw");
+
+ logins = await LoginManager.getAllLogins();
+ is(logins.length, 1, "Still 1 login after filling the generated password a 2nd time");
+ is(logins[0].timePasswordChanged, timePasswordChanged, "Saved login wasn't changed");
+ is(logins[0].password, generatedPW, "Password is the same");
+
+ info("filling the saved login to ensure the field is masked again");
+
+ while (form.password.value) {
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, false, form.password.value);
+ synthesizeKey("KEY_Backspace");
+ }
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blanked field again");
+
+ info("Blur the field to trigger a 'change' event again");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Blur after blanking again");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "Focus again after blanking again");
+ // Remove selection for OS where the whole value is selected upon focus.
+ synthesizeKey("KEY_ArrowRight");
+
+ await showACPopup(formNumber, [
+ LABEL_NO_USERNAME,
+ "Use a Securely Generated Password",
+ ]);
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check saved generated pw filled");
+ // Same generated password should be used but from storage
+ is(form.username.value, "", "username is empty");
+ is(form.password.value, generatedPW, "password is filled with generated password");
+ // Passwords from storage should always be masked.
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "after fill from storage");
+ synthesizeKey("KEY_Tab"); // blur
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "after blur");
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.password, true, "after shift-tab to focus again");
+ });
+
+ add_named_task("autofill autocomplete password save login disabled", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", true],
+ ["signon.generation.enabled", true],
+ );
+ await setStoredLoginsDuringTask([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form = setContentForTask(formTemplates.form2);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "password_autocomplete_new_password", "form has not been filled due to password_autocomplete_new_password");
+
+ // form should not be filled
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, "", "password is empty");
+
+ const formOrigin = new URL(document.documentURI).origin;
+ is(formOrigin, location.origin, "Expected form origin");
+
+ await LoginManager.setLoginSavingEnabled(location.origin, false);
+
+ form.password.focus();
+ // when login-saving is disabled for an origin, we expect no generated password row here
+ await showACPopup(2, ["user1"]);
+
+ // close any open menu
+ synthesizeKey("KEY_Escape");
+ await untilAutocompletePopupClosed();
+
+ await LoginManager.setLoginSavingEnabled(location.origin, true);
+ });
+
+ add_named_task("delete and reselect generated password", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", true],
+ ["signon.generation.enabled", true],
+ );
+ await setStoredLoginsDuringTask([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form = setContentForTask(formTemplates.form2);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "password_autocomplete_new_password", "form has not been filled due to password_autocomplete_new_password");
+
+ info("Removing all logins to test auto-saving of generated passwords");
+ await LoginManager.removeAllUserFacingLogins();
+
+ // form should not be filled
+ is(form.uname.value, "", "username is empty");
+ is(form.password.value, "", "password is empty");
+
+ async function showAndSelectACPopupItem(index) {
+ form.password.focus();
+ if (form.password.value) {
+ form.password.select();
+ synthesizeKey("KEY_Backspace");
+ }
+ const autocompleteItems = await popupByArrowDown();
+ if (index < 0) {
+ index = autocompleteItems.length + index;
+ }
+ for (let i = 0; i <= index; i++) {
+ synthesizeKey("KEY_ArrowDown");
+ }
+ await TestUtils.waitForTick();
+ return autocompleteItems[index];
+ }
+
+ let menuLabel, itemIndex, savedLogins;
+
+ // fill the password field with the generated password via auto-complete menu
+ const addLoginPromise = promiseStorageChanged(["addLogin"]);
+ // select last-but-2 item - the one before the footer
+ menuLabel = await showAndSelectACPopupItem(-2);
+ is(menuLabel, "Use a Securely Generated Password", "Check item label");
+ synthesizeKey("KEY_Enter");
+ info("waiting for the password field to be filled with the generated password");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check generated pw filled");
+ info("Wait for generated password to be added to storage");
+ await addLoginPromise;
+
+ savedLogins = await LoginManager.getAllLogins();
+ is(savedLogins.length, 1, "Check saved logins count");
+
+ form.uname.focus();
+ await TestUtils.waitForTick();
+
+ is(form.password.value.length, LoginTestUtils.generation.LENGTH, "Check password looks generated");
+ const GENERATED_PASSWORD = form.password.value;
+
+ info("clear the password field and delete the saved login using the AC menu")
+ const removeLoginPromise = promiseStorageChanged(["removeLogin"]);
+
+ itemIndex = 0;
+ menuLabel = await showAndSelectACPopupItem(itemIndex);
+ ok(menuLabel.includes("No username"), "Check first item is the auto-saved login");
+ // Send delete to remove the auto-saved login from storage
+ // On OS X, shift-backspace and shift-delete work, just delete does not.
+ // On Win/Linux, shift-backspace does not work, delete and shift-delete do.
+ synthesizeKey("KEY_Delete", {shiftKey: true});
+ await removeLoginPromise;
+
+ form.uname.focus();
+ await TestUtils.waitForTick();
+
+ savedLogins = await LoginManager.getAllLogins();
+ is(savedLogins.length, 0, "Check saved logins count");
+
+ info("Re-fill with the generated password");
+ // select last-but-2 item - the one before the footer
+ menuLabel = await showAndSelectACPopupItem(-2);
+ is(menuLabel, "Use a Securely Generated Password", "Check item label");
+ synthesizeKey("KEY_Enter");
+ info("waiting for the password field to be filled with the generated password");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check generated pw filled");
+
+ form.uname.focus();
+ await TestUtils.waitForTick();
+ is(form.password.value, GENERATED_PASSWORD, "Generated password has not changed");
+ });
+</script>
+</pre>
+</body>
+</html>
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..54cb450815
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_confirm.html
@@ -0,0 +1,380 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Test filling generated passwords into confirm password fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <script src="../../../satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Login Manager test: filling generated passwords into confirm password fields
+
+<template id="form1-template">
+ <form id="form1" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="pwordNext">
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form2-template">
+ <form id="form2" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="pwordNext" value="initial value">
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form3-template">
+ <form id="form3" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="pwordNext" readonly>
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form4-template">
+ <form id="form4" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="pwordNext" disabled>
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form5-template">
+ <form id="form5" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="pwordBetween">
+ <input type="password" name="pwordNext" autocomplete="new-password">
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form6-template">
+ <form id="form6" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="pwordNext">
+ <input type="password" name="pwordAfter" autocomplete="new-password" disabled>
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form7-template">
+ <form id="form7" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="junk0">
+ <input type="password" name="junk1">
+ <input type="password" name="junk2">
+ <input type="password" name="junk3">
+ <input type="password" name="junk4">
+ <input type="password" name="pwordNext" autocomplete="new-password">
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form8-template">
+ <form id="form8" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="junk0" disabled>
+ <input type="password" name="junk1" disabled>
+ <input type="password" name="junk2" disabled>
+ <input type="password" name="junk3" disabled>
+ <input type="password" name="junk4" disabled>
+ <input type="password" name="pwordNext" autocomplete="new-password">
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form9-template">
+ <form id="form9" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="hidden" name="junk0">
+ <input type="hidden" name="junk1">
+ <input type="hidden" name="junk2">
+ <input type="hidden" name="junk3">
+ <input type="hidden" name="junk4">
+ <input type="password" name="pwordNext" autocomplete="new-password">
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form10-template">
+ <form id="form10" action="https://example.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword" autocomplete="new-password">
+ <input type="password" name="pwordNext" autocomplete="new-password">
+ <input type="password" name="pwordExtra" autocomplete="new-password">
+ <button type="submit">Submit</button>
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const formTemplates = {
+ form1: document.getElementById("form1-template"),
+ form2: document.getElementById("form2-template"),
+ form3: document.getElementById("form3-template"),
+ form4: document.getElementById("form4-template"),
+ form5: document.getElementById("form5-template"),
+ form6: document.getElementById("form6-template"),
+ form7: document.getElementById("form7-template"),
+ form8: document.getElementById("form8-template"),
+ form9: document.getElementById("form9-template"),
+ form10: document.getElementById("form10-template"),
+ };
+
+ const setupScript = runInParent(function parentTestSetup() {
+ const { LoginTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/LoginTestUtils.sys.mjs"
+ );
+
+ addMessageListener(
+ "resetLoginsAndGeneratedPasswords", () => {
+ LoginTestUtils.clearData();
+ LoginTestUtils.resetGeneratedPasswordsCache();
+ }
+ );
+ });
+
+ function resetLoginsAndGeneratedPasswords() {
+ return setupScript.sendAsyncMessage("resetLoginsAndGeneratedPasswords");
+ }
+
+ async function triggerPasswordGeneration(form) {
+ await openPopupOn(form.pword);
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => !!form.pword.value, "Wait for generated password to get filled");
+ }
+
+ add_named_task("autocomplete menu contains option to generate password", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+
+ const { items } = await openPopupOn(form.pword);
+
+ checkAutoCompleteResults(items, [
+ "Use a Securely Generated Password"
+ ], location.host, "Check all rows are correct");
+ });
+
+ add_named_task("username field highlight", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ ok(!form.uname.matches(":autofill"), "Highlight was not applied to the username field");
+ });
+
+ add_named_task("password field highlight", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ ok(form.pword.matches(":autofill"), "Highlight was applied to the password field");
+ });
+
+ add_named_task("username field is left untouched", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ is(form.uname.value, "", "Value is still empty")
+ });
+
+ add_named_task("generated password looks like a generated password", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ const generatedPassword = form.pword.value;
+ is(generatedPassword.length, GENERATED_PASSWORD_LENGTH, "Generated password length matches");
+ ok(generatedPassword.match(GENERATED_PASSWORD_REGEX), "Generated password format matches");
+ });
+
+ add_named_task("password confirmation also gets filled with the generated password", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
+ });
+
+ add_named_task("password field is not masked initially after password generation", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, false, "password field is not masked after password generation");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pwordNext, true, "password confirmation field is masked after user input");
+ });
+
+ add_named_task("password field is masked after user input", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ form.pwordNext.focus();
+ form.pwordNext.select();
+ synthesizeKey("KEY_Backspace");
+ synthesizeKey("a");
+ form.pwordNext.blur();
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pword, true, "password field is masked after user input");
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pwordNext, true, "password confirmation field is also masked after user input");
+ });
+
+ add_named_task("password field highlight is cleared after user input", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ form.pwordNext.focus();
+ form.pwordNext.select();
+ synthesizeKey("KEY_Backspace");
+ synthesizeKey("a");
+ form.pwordNext.blur();
+ await SimpleTest.promiseWaitForCondition(() => !form.pwordNext.matches(":autofill"), "Highlight was cleared");
+ });
+
+ add_named_task("generated password can be changed", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ const generatedPassword = form.pword.value;
+ form.pword.focus();
+ synthesizeKey("KEY_End");
+ synthesizeKey("@");
+ is(form.pword.value, `${generatedPassword}@`, "Value of the password field changed");
+ });
+
+ add_named_task("password confirmation field does not receive changes from password field", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+ const generatedPassword = form.pword.value;
+
+ // changing the password field value should result in a message sent to the parent process
+ const messageSentPromise = getPasswordEditedMessage();
+
+ form.pword.focus();
+ synthesizeKey("KEY_End");
+ synthesizeKey("@");
+
+ // bluring results in a "change" event
+ form.pword.blur();
+ await messageSentPromise;
+
+ is(form.pwordNext.value, generatedPassword, "Value of the confirm field still holds the original generated password");
+ ok(form.pwordNext.matches(":autofill"), "Highlight is still applied to password confirmation field");
+ });
+
+ add_named_task("password confirmation field behaves like a normal password field once changed", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ await triggerPasswordGeneration(form);
+
+ form.pwordNext.focus();
+ form.pwordNext.select();
+ synthesizeKey("KEY_Backspace");
+
+ // verify the focused confirm field now masks its input like a normal,
+ // non-generated password field after being emptied
+ form.pwordNext.focus();
+ synthesizeKey("a");
+ form.pwordNext.blur();
+
+ LOGIN_FIELD_UTILS.checkPasswordMasked(form.pwordNext, true, "password confirmation field is masked");
+ await SimpleTest.promiseWaitForCondition(() => !form.pwordNext.matches(":autofill"), "highlight was cleared");
+ });
+
+ add_named_task("password confirmation also gets filled with the generated password, even if it has been changed to be of type text", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ form.pwordNext.type = "text";
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
+ });
+
+ add_named_task("password confirmation does not get filled with the generated password if it is not empty", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form2);
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, "initial value", "Value of the confirm field has been filled with generated password");
+ });
+
+ add_named_task("password confirmation does not get filled with the generated password if it has been edited", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form1);
+ form.pwordNext.focus()
+ sendString("edited value");
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, "edited value", "Value of the confirm field has been filled with generated password");
+ });
+
+ add_named_task("password confirmation does not get filled with the generated password if its readonly", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form3);
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, "", "Value of the confirm field has been filled with generated password");
+ });
+
+ add_named_task("password confirmation does not get filled with the generated password if its disabled", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form4);
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, "", "Value of the confirm field has been filled with generated password");
+ });
+
+ add_named_task("password confirmation matching autocomplete info gets filled with the generated password", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form5);
+ await triggerPasswordGeneration(form);
+ is(form.pwordBetween.value, "", "Value of the between field has not been filled");
+ is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
+ });
+
+ add_named_task("password confirmation matching autocomplete info gets ignored if its disabled, even if has autocomplete info", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form6);
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
+ is(form.pwordAfter.value, "", "Value of the disabled confirmation field has not been filled");
+ });
+
+ add_named_task("password confirmation matching autocomplete info gets ignored there are too many fields in between, even if has autocomplete info", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form7);
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, "", "Value of the confirm field has not been filled");
+ });
+
+ add_named_task("password confirmation matching autocomplete info gets ignored there are too many disabled fields in between, even if has autocomplete info", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form8);
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, "", "Value of the confirm field has not been filled");
+ });
+
+ add_named_task("password confirmation matching autocomplete info gets filled even if there are many hidden fields in between", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form9);
+ await triggerPasswordGeneration(form);
+ is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
+ });
+
+ add_named_task("do not fill third password field after the confirm-password field", async () => {
+ await resetLoginsAndGeneratedPasswords();
+ const form = setContentForTask(formTemplates.form10);
+ await triggerPasswordGeneration(form);
+ is(form.pwordExtra.value, "", "Value of the additional confirm field has not been filled");
+ });
+</script>
+</pre>
+</body>
+</html>
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_telemetry.html b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_telemetry.html
new file mode 100644
index 0000000000..4bbba09483
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_password_generation_telemetry.html
@@ -0,0 +1,309 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test telemetry on autofill and autocomplete on autocomplete=new-password fields</title>
+ <!-- This test assumes that telemetry events are not cleared after the `setup` task. -->
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <script src="../../../satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Login Manager test: telemetry events during autofill with password generation on `autocomplete=new-password` fields.
+
+<template id="form1-template">
+ <form id="form1" action="https://autofill">
+ <input type="text" name="uname">
+ <input type="password" name="p">
+ <button type="submit" name="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form2-template">
+ <form id="form2" action="https://autofill">
+ <input type="text" name="uname">
+ <input type="password" name="password" autocomplete="new-password">
+ <button type="submit" name="submit">Submit</button>
+ </form>
+</template>
+
+<template id="form3-template">
+ <form id="form3" action="https://autofill">
+ <input type="text" name="username">
+ <label>New password<input type="password" name="password"></label>
+ <button type="submit" name="submit">Submit</button>
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const { TestUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+
+ const formTemplates = {
+ form1: document.getElementById("form1-template"),
+ form2: document.getElementById("form2-template"),
+ form3: document.getElementById("form3-template"),
+ };
+
+ const dateAndTimeFormatter = new SpecialPowers.Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ });
+
+ const TelemetryFilterPropsUsed = Object.freeze({
+ category: "pwmgr",
+ method: "autocomplete_field",
+ object: "generatedpassword",
+ });
+
+ const TelemetryFilterPropsShown = Object.freeze({
+ category: "pwmgr",
+ method: "autocomplete_shown",
+ object: "generatedpassword",
+ });
+
+ async function waitForTelemetryEventsCondition(cond, options = {},
+ errorMsg = "waitForTelemetryEventsCondition timed out", maxTries = 200) {
+ return TestUtils.waitForCondition(async () => {
+ let events = await getTelemetryEvents(options);
+ let result;
+ try {
+ result = cond(events);
+ info("waitForTelemetryEventsCondition, result: " + result);
+ } catch (e) {
+ info("waitForTelemetryEventsCondition caught exception, got events: " + JSON.stringify(events));
+ ok(false, `${e}\n${e.stack}`);
+ }
+ return result ? events : null;
+ }, errorMsg, maxTries);
+ }
+
+ async function showACPopup(formNumber, expectedACLabels) {
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems, expectedACLabels,
+ window.location.host, "Check all rows are correct");
+ }
+
+ async function checkTelemetryEventsPWGenShown(expectedPWGenTelemetryEvents) {
+ info(`showed generated password option, check there are now ${expectedPWGenTelemetryEvents} generatedpassword telemetry events`);
+ await waitForTelemetryEventsCondition(events => {
+ return events.length == expectedPWGenTelemetryEvents;
+ }, { process: "parent", filterProps: TelemetryFilterPropsShown }, `Wait for there to be ${expectedPWGenTelemetryEvents} shown telemetry events`);
+ }
+
+ async function checkTelemetryEventsPWGenUsed(expectedPWGenTelemetryEvents) {
+ info("filled generated password again, ensure we don't record another generatedpassword autocomplete telemetry event");
+ let telemetryEvents;
+ try {
+ telemetryEvents = await waitForTelemetryEventsCondition(events => events.length == expectedPWGenTelemetryEvents + 1,
+ { process: "parent", filterProps: TelemetryFilterPropsUsed },
+ `Wait for there to be ${expectedPWGenTelemetryEvents + 1} used events`, 50);
+ } catch (ex) {}
+ ok(!telemetryEvents, `Expected to timeout waiting for there to be ${expectedPWGenTelemetryEvents + 1} events`);
+ }
+
+ function clearGeneratedPasswords() {
+ const { LoginManagerParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerParent.sys.mjs"
+ );
+ if (LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin()) {
+ LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
+ }
+ }
+
+ add_setup(async () => {
+ let useEvents = await getTelemetryEvents({ process: "parent", filterProps: TelemetryFilterPropsUsed, clear: true });
+ is(useEvents.length, 0, "Expect 0 use events");
+ let showEvents = await getTelemetryEvents({ process: "parent", filterProps: TelemetryFilterPropsShown, clear: true });
+ is(showEvents.length, 0, "Expect 0 show events");
+ let acEvents = await getTelemetryEvents({ process: "parent", filterProps: TelemetryFilterPropsAC, clear: true });
+ is(acEvents.length, 0, "Expect 0 autocomplete events");
+ });
+
+ add_named_task("autofill autocomplete username no generation", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", false],
+ ["signon.generation.enabled", false],
+ );
+ await setStoredLoginsDuringTask([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form = setContentForTask(formTemplates.form2);
+ const autofillResult = await formAutofillResult(form.id);
+ is(autofillResult, "password_autocomplete_new_password", "form has not been filled due to password_autocomplete_new_password");
+
+ form.uname.focus();
+
+ const [acEvent] = await waitForTelemetryEventsCondition(events => {
+ return events.length == 1;
+ }, { process: "parent", filterProps: TelemetryFilterPropsAC, clear: true }, `Wait for there to be 1 autocomplete telemetry event`);
+ checkACTelemetryEvent(acEvent, form.uname, {
+ "hadPrevious": "0",
+ "login": "1",
+ "loginsFooter": "1"
+ });
+ });
+
+ add_named_task("filling out two forms one after the other", async () => {
+ await setPreferencesForTask(
+ ["signon.generation.available", true],
+ ["signon.generation.enabled", true],
+ );
+
+ const formsToTest = [
+ {
+ num: 2,
+ template: formTemplates.form2
+ },
+ {
+ num: 3,
+ template: formTemplates.form3
+ }
+ ];
+
+ // Bug 1616356 and Bug 1548878: Recorded once per origin
+ let expectedPWGenTelemetryEvents = 0;
+ // Bug 1619498: Recorded once every time the autocomplete popup is shown
+ let expectedACShownTelemetryEvents = 0;
+
+ for (const { num: formNumber, template } of formsToTest) {
+ runInParent(clearGeneratedPasswords);
+ await setStoredLoginsAsync([location.origin, "https://autofill", null, "user1", "pass1"]);
+
+ const form = setContentForTask(template);
+ await promiseFormsProcessedInSameProcess();
+ form.reset();
+
+ form.password.focus();
+
+ await showACPopup(formNumber, [
+ "user1",
+ "Use a Securely Generated Password",
+ ]);
+ expectedPWGenTelemetryEvents++;
+ expectedACShownTelemetryEvents++;
+
+ await checkTelemetryEventsPWGenShown(expectedPWGenTelemetryEvents);
+ let acEvents = await waitForTelemetryEventsCondition(events => {
+ return events.length == expectedACShownTelemetryEvents;
+ }, { process: "parent", filterProps: TelemetryFilterPropsAC }, `Wait for there to be ${expectedACShownTelemetryEvents} autocomplete telemetry event(s)`);
+ checkACTelemetryEvent(acEvents[expectedACShownTelemetryEvents - 1], form.password, {
+ "generatedPasswo": "1",
+ "hadPrevious": "0",
+ "login": "1",
+ "loginsFooter": "1"
+ });
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ // Can't use promiseFormsProcessedInSameProcess() when autocomplete fills the field directly.
+ await SimpleTest.promiseWaitForCondition(() => form.password.value == "pass1", "Check pw filled");
+
+ // No autocomplete results should appear for non-empty pw fields.
+ await noPopupByArrowDown();
+
+ info("Removing all logins to test auto-saving of generated passwords");
+ await LoginManager.removeAllUserFacingLogins();
+
+ while (form.password.value) {
+ synthesizeKey("KEY_Backspace");
+ }
+
+ info("This time select the generated password");
+ await showACPopup(formNumber, [
+ "Use a Securely Generated Password",
+ ]);
+ expectedACShownTelemetryEvents++;
+
+ await checkTelemetryEventsPWGenShown(expectedPWGenTelemetryEvents);
+ acEvents = await waitForTelemetryEventsCondition(events => {
+ return events.length == expectedACShownTelemetryEvents;
+ }, { process: "parent", filterProps: TelemetryFilterPropsAC }, `Wait for there to be ${expectedACShownTelemetryEvents} autocomplete telemetry event(s)`);
+ checkACTelemetryEvent(acEvents[expectedACShownTelemetryEvents - 1], form.password, {
+ "generatedPasswo": "1",
+ "hadPrevious": "0",
+ "loginsFooter": "1"
+ });
+
+ synthesizeKey("KEY_ArrowDown");
+ let storageAddPromise = promiseStorageChanged(["addLogin"]);
+ synthesizeKey("KEY_Enter");
+
+ info("waiting for the password field to be filled with the generated password");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check generated pw filled");
+ info("Wait for generated password to be added to storage");
+ await storageAddPromise;
+
+ let logins = await LoginManager.getAllLogins();
+ let timePasswordChanged = logins[logins.length - 1].timePasswordChanged;
+ let time = dateAndTimeFormatter.format(new Date(timePasswordChanged));
+ const LABEL_NO_USERNAME = "No username (" + time + ")";
+
+ info("Check field is masked upon blurring");
+ synthesizeKey("KEY_Tab"); // blur
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ // Remove selection for OS where the whole value is selected upon focus.
+ synthesizeKey("KEY_ArrowRight");
+
+ while (form.password.value) {
+ synthesizeKey("KEY_Backspace");
+ }
+
+ info("Blur the empty field to trigger a 'change' event");
+ synthesizeKey("KEY_Tab"); // blur
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+
+ info("Type a single character after blanking");
+ synthesizeKey("@");
+
+ info("Blur the single-character field to trigger a 'change' event");
+ synthesizeKey("KEY_Tab"); // blur
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ synthesizeKey("KEY_Backspace"); // Blank the field again
+
+ await showACPopup(formNumber, [
+ LABEL_NO_USERNAME,
+ "Use a Securely Generated Password",
+ ]);
+ expectedACShownTelemetryEvents++;
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check generated pw filled");
+
+ await checkTelemetryEventsPWGenUsed(expectedPWGenTelemetryEvents);
+
+ info("filling the saved login to ensure the field is masked again");
+
+ while (form.password.value) {
+ synthesizeKey("KEY_Backspace");
+ }
+
+ info("Blur the field to trigger a 'change' event again");
+ synthesizeKey("KEY_Tab"); // blur
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus again
+ // Remove selection for OS where the whole value is selected upon focus.
+ synthesizeKey("KEY_ArrowRight");
+
+ await showACPopup(formNumber, [
+ LABEL_NO_USERNAME,
+ "Use a Securely Generated Password",
+ ]);
+ expectedACShownTelemetryEvents++;
+
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await SimpleTest.promiseWaitForCondition(() => !!form.password.value, "Check saved generated pw filled");
+ synthesizeKey("KEY_Tab"); // blur
+ synthesizeKey("KEY_Tab", { shiftKey: true }); // focus
+ }
+ });
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test password field autocomplete footer with and without logins</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content"></div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: Test password field autocomplete footer with and without logins **/
+
+add_task(async function test_no_autofill() {
+ await setStoredLoginsAsync(
+ [location.origin, "", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = createLoginForm();
+ await promiseFormsProcessedInSameProcess();
+
+ // Make sure initial form is empty as autofill shouldn't happen in the sandboxed frame.
+ checkLoginForm(form.uname, "", form.pword, "");
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+});
+
+add_task(async function test_two_logins() {
+ await setStoredLoginsAsync(
+ [location.origin, "", null, "user-1", "pass-1", "uname", "pword"],
+ [location.origin, "", null, "user-2", "pass-2", "uname", "pword"]
+ );
+ const form = createLoginForm();
+ await promiseFormsProcessedInSameProcess();
+
+ await popupBy(() => form.uname.focus());
+
+ // popup on the password field should open upon focus
+ let results = await popupBy(() => synthesizeKey("KEY_Tab"));
+
+ let popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+ let expectedMenuItems = [
+ "user-1",
+ "user-2",
+ ];
+ checkAutoCompleteResults(results, expectedMenuItems, window.location.host, "Check all menuitems are displayed correctly.");
+
+ checkLoginForm(form.uname, "", form.pword, "");
+});
+
+add_task(async function test_zero_logins() {
+ // no logins stored
+ await setStoredLoginsAsync();
+ const form = createLoginForm();
+ await promiseFormsProcessedInSameProcess();
+
+ form.uname.focus();
+
+ let shownPromise = popupBy().then(() => ok(false, "Should not have shown"));
+ // Popup on the password field should NOT automatically open upon focus when there are no saved logins.
+ synthesizeKey("KEY_Tab"); // focus the password field
+ SimpleTest.requestFlakyTimeout("Giving a chance for the unexpected popup to show");
+ let autocompleteItems = await Promise.race([
+ shownPromise,
+ new Promise(resolve => setTimeout(resolve, 2000)), // Wait 2s for the popup to appear
+ ]);
+
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is still closed");
+
+ checkLoginForm(form.uname, "", form.pword, "");
+ info("arrow down should still open the popup");
+ autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems, [], window.location.host, "Check only footer is displayed.");
+ checkLoginForm(form.uname, "", form.pword, "");
+});
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form field autocomplete in sandboxed documents (null principal)</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <iframe id="sandboxed"
+ sandbox=""
+ src="form_basic.html"></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: form field autocomplete in sandboxed documents (null principal) **/
+
+let sandboxed = document.getElementById("sandboxed");
+let uname;
+let pword;
+let hostname;
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [window.location.origin, "", null, "tempuser1", "temppass1", "uname", "pword"]
+ );
+ let frameWindow = SpecialPowers.wrap(sandboxed).contentWindow;
+ // Can't use SimpleTest.promiseFocus as it doesn't work with the sandbox.
+ await SimpleTest.promiseWaitForCondition(() => {
+ return frameWindow.document.readyState == "complete" && frameWindow.location.href != "about:blank";
+ }, "Check frame is loaded");
+ let frameDoc = SpecialPowers.wrap(sandboxed).contentDocument;
+ uname = frameDoc.getElementById("form-basic-username");
+ pword = frameDoc.getElementById("form-basic-password");
+ hostname = frameDoc.documentURIObject.host;
+});
+
+add_task(async function test_no_autofill() {
+ // Make sure initial form is empty as autofill shouldn't happen in the sandboxed frame.
+ checkLoginForm(uname, "", pword, "");
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+});
+
+add_task(async function test_autocomplete_warning_no_logins() {
+ const { items } = await openPopupOn(pword);
+
+ let popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+ let expectedMenuItems = [
+ "This connection is not secure. Logins entered here could be compromised. Learn More",
+ ];
+ checkAutoCompleteResults(items, expectedMenuItems, hostname, "Check all menuitems are displayed correctly.");
+
+ checkLoginForm(uname, "", pword, "");
+});
+</script>
+</pre>
+</body>
+</html>
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..4a0aaff0b3
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autocomplete_tab_between_fields.html
@@ -0,0 +1,167 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autocomplete behavior when tabbing between form fields</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content"></div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+const { TestUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const availableLogins = {
+ "exampleUser1": [location.origin, "https://autofill", null, "user1", "pass1", "uname", "pword"],
+ "subdomainUser1": ["https://sub." + location.host, "https://autofill", null, "user1", "pass1", "uname", "pword"],
+ "emptyUsername": [location.origin, "https://autofill", null, "", "pass2", "uname", "pword"],
+}
+
+const tests = [
+ {
+ name: "single_login_exact_origin_no_inputs",
+ logins: ["exampleUser1"],
+ expectedAutofillUsername: "user1",
+ expectedAutofillPassword: "pass1",
+ expectedACLabels: ["user1"],
+ typeUsername: null,
+ expectedTabbedUsername: "",
+ expectedTabbedPassword: "",
+ },
+ {
+ name: "single_login_exact_origin_initial_letter",
+ logins: ["exampleUser1"],
+ expectedAutofillUsername: "user1",
+ expectedAutofillPassword: "pass1",
+ expectedACLabels: ["user1"],
+ typeUsername: "u",
+ expectedTabbedUsername: "u",
+ expectedTabbedPassword: "",
+ },
+ {
+ name: "single_login_exact_origin_type_username",
+ logins: ["exampleUser1"],
+ expectedAutofillUsername: "user1",
+ expectedAutofillPassword: "pass1",
+ expectedACLabels: ["user1"],
+ typeUsername: "user1",
+ expectedTabbedUsername: "user1",
+ expectedTabbedPassword: "pass1",
+ },
+ {
+ name: "single_login_subdomain_no_inputs",
+ logins: ["subdomainUser1"],
+ expectedAutofillUsername: "",
+ expectedAutofillPassword: "",
+ expectedACLabels: ["user1"],
+ typeUsername: null,
+ expectedTabbedUsername: "",
+ expectedTabbedPassword: "",
+ },
+ {
+ name: "single_login_subdomain_type_username",
+ logins: ["subdomainUser1"],
+ expectedAutofillUsername: "",
+ expectedAutofillPassword: "",
+ expectedACLabels: ["user1"],
+ typeUsername: "user1",
+ expectedTabbedUsername: "user1",
+ expectedTabbedPassword: "",
+ },
+ {
+ name: "two_logins_one_with_empty_username",
+ logins: ["exampleUser1", "emptyUsername"],
+ expectedAutofillUsername: "user1",
+ expectedAutofillPassword: "pass1",
+ expectedACLabels: ["user1"],
+ typeUsername: "",
+ expectedTabbedUsername: "",
+ expectedTabbedPassword: "",
+ },
+];
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({"set": [["signon.includeOtherSubdomainsInLookup", true]]});
+});
+
+async function testResultOfTabInteractions(testData) {
+ const logins = testData.logins.map(name => availableLogins[name]);
+ await setStoredLoginsAsync(...logins);
+
+ const form = createLoginForm({
+ action: "https://autofill"
+ });
+ await promiseFormsProcessedInSameProcess();
+
+ await SimpleTest.promiseFocus(window);
+
+ // check autofill results
+ checkForm(1, testData.expectedAutofillUsername, testData.expectedAutofillPassword);
+
+ SpecialPowers.wrap(form.pword).setUserInput("");
+ SpecialPowers.wrap(form.uname).setUserInput("");
+
+ info("Placing focus in the password field");
+ form.pword.focus();
+ await synthesizeKey("KEY_Tab", { shiftKey: true }); // blur pw, focus un
+
+ // moving focus shouldn't change anything
+ await ensureLoginFormStaysFilledWith(form.uname, "", form.pword, "");
+
+ info("waiting for AC results");
+ const results = await popupByArrowDown();
+ info("checking results");
+ checkAutoCompleteResults(results, testData.expectedACLabels,
+ window.location.host, "Check all rows are correct");
+
+ if (testData.typeUsername) {
+ await sendString(testData.typeUsername);
+ }
+
+ // don't select anything from the AC menu
+ await synthesizeKey("KEY_Escape");
+ await TestUtils.waitForCondition(async () => {
+ let popupState = await getPopupState();
+ return !popupState.open;
+ }, "AutoComplete popup should have closed");
+
+ await synthesizeKey("KEY_Tab");
+
+ // wait until username and password are automatically filled in with the
+ // expected values...
+ await TestUtils.waitForCondition(() => {
+ return form.uname.value === testData.expectedTabbedUsername & form.pword.value === testData.expectedTabbedPassword;
+ }, "Username and password field should be filled");
+
+ // ...and if the value is not different from the original value in the form,
+ // make sure that the form keeps its values
+ if (testData.expectedTabbedPassword === "") {
+ await ensureLoginFormStaysFilledWith(form.uname, testData.expectedTabbedUsername, form.pword, testData.expectedTabbedPassword);
+ }
+
+ ok(form.pword.matches("input:focus"), "pword field is focused");
+}
+
+for (const testData of tests) {
+ const tmp = {
+ async [testData.name]() {
+ await testResultOfTabInteractions(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
+
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofilling with autocomplete types (username, off, cc-type, etc.)</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Test autofilling with autocomplete types (username, off, cc-type, etc.)
+
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/*
+ Test for Login Manager: Skip over inappropriate autcomplete types when finding username field
+ */
+
+const win = window.open("about:blank");
+const loadPromise = loadFormIntoWindow(location.origin, `
+ <form id="form0" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form1" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="username">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form2" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="off" name="acfield">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form3" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="on" name="acfield">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form4" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="nosuchtype" name="acfield">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form5" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="email" name="acfield">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form6" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="tel" name="acfield">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form7" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="tel-national" name="acfield">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- Begin forms where the first field is skipped for the username -->
+
+ <form id="form101" action="https://autocomplete">
+ <input type="text" name="uname">
+ <input type="text" autocomplete="cc-number" name="acfield">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>`, win, 8);
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [window.location.origin, "https://autocomplete", null, "testuser@example.com", "testpass1", "", ""]
+ );
+ SimpleTest.registerCleanupFunction(() => win.close());
+});
+
+/* Tests for autofill of single-user forms with various autocomplete types */
+add_task(async function test_autofill_autocomplete_types() {
+ await loadPromise;
+ await checkLoginFormInFrameWithElementValues(win, 0, null, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 1, null, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 2, null, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 3, null, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 4, null, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 5, null, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 6, null, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 7, null, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 101, "testuser@example.com", null, "testpass1");
+});
+
+</script>
+</pre>
+</body>
+</html>
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..1ea43f6a8f
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_different_formActionOrigin.html
@@ -0,0 +1,91 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill on an HTTPS page using upgraded HTTP logins with different formActionOrigin</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+const MISSING_ACTION_PATH = TESTS_DIR + "mochitest/form_basic.html";
+
+const chromeScript = runChecksAfterCommonInit(false);
+
+let nsLoginInfo = SpecialPowers.wrap(SpecialPowers.Components).Constructor("@mozilla.org/login-manager/loginInfo;1",
+ SpecialPowers.Ci.nsILoginInfo,
+ "init");
+</script>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+let origin = window.location.origin;
+let suborigin = "http://sub." + window.location.host;
+
+let win = window.open("about:blank");
+SimpleTest.registerCleanupFunction(() => win.close());
+
+async function prepareLoginsAndProcessForm(url, logins = []) {
+ await LoginManager.removeAllUserFacingLogins();
+
+ let dates = Date.now();
+ for (let login of logins) {
+ SpecialPowers.do_QueryInterface(login, SpecialPowers.Ci.nsILoginMetaInfo);
+ // Force all dates to be the same so they don't affect things like deduping.
+ login.timeCreated = login.timePasswordChanged = login.timeLastUsed = dates;
+ await LoginManager.addLoginAsync(login);
+ }
+
+ let processedPromise = promiseFormsProcessed();
+ win.location = url;
+ await processedPromise;
+}
+
+add_task(async function test_formActionOrigin_wildcard_should_autofill() {
+ await prepareLoginsAndProcessForm(origin + MISSING_ACTION_PATH, [
+ new nsLoginInfo(origin, "", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ await checkLoginFormInFrame(win, "form-basic-username", "name2", "form-basic-password", "pass2");
+});
+
+add_task(async function test_formActionOrigin_different_shouldnt_autofill() {
+ await prepareLoginsAndProcessForm(origin + MISSING_ACTION_PATH, [
+ new nsLoginInfo(origin, "https://example.net", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+
+ await checkLoginFormInFrame(win, "form-basic-username", "", "form-basic-password", "");
+});
+
+add_task(async function test_formActionOrigin_subdomain_should_autofill() {
+ await prepareLoginsAndProcessForm(origin + MISSING_ACTION_PATH, [
+ new nsLoginInfo(origin, suborigin, null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ await checkLoginFormInFrame(win, "form-basic-username", "name2", "form-basic-password", "pass2");
+});
+
+add_task(async function test_origin_subdomain_should_not_autofill() {
+ await prepareLoginsAndProcessForm(origin + MISSING_ACTION_PATH, [
+ new nsLoginInfo(suborigin, origin, null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ await checkLoginFormInFrame(win, "form-basic-username", "", "form-basic-password", "");
+});
+
+</script>
+</pre>
+</body>
+</html>
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..66c6ab3536
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_different_subdomain.html
@@ -0,0 +1,150 @@
+xcod<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill on an HTTPS page using logins with different eTLD+1</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+const MISSING_ACTION_PATH = TESTS_DIR + "mochitest/form_basic.html";
+
+const chromeScript = runChecksAfterCommonInit(false);
+
+let nsLoginInfo = SpecialPowers.wrap(SpecialPowers.Components).Constructor("@mozilla.org/login-manager/loginInfo;1",
+ SpecialPowers.Ci.nsILoginInfo,
+ "init");
+</script>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+ <iframe></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+let iframe = SpecialPowers.wrap(document.getElementsByTagName("iframe")[0]);
+let win = window.open("about:blank");
+SimpleTest.registerCleanupFunction(() => win.close());
+
+let origin = window.location.origin;
+let otherOrigin = "https://foobar." + window.location.host;
+let oldOrigin = "https://old." + window.location.host;
+
+async function checkWindowLoginForm(expectedUsername, expectedPassword) {
+ return SpecialPowers.spawn(win, [expectedUsername, expectedPassword], function(un, pw) {
+ let doc = this.content.document;
+ Assert.equal(doc.querySelector("#form-basic-username").value, un, "Check username value");
+ Assert.equal(doc.querySelector("#form-basic-password").value, pw, "Check password value");
+ });
+}
+
+async function prepareLogins(logins = []) {
+ await LoginManager.removeAllUserFacingLogins();
+
+ let dates = Date.now();
+ for (let login of logins) {
+ SpecialPowers.do_QueryInterface(login, SpecialPowers.Ci.nsILoginMetaInfo);
+ // Force all dates to be the same so they don't affect things like deduping.
+ login.timeCreated = login.timePasswordChanged = login.timeLastUsed = dates;
+ await LoginManager.addLoginAsync(login);
+ }
+}
+
+async function formReadyInFrame(url) {
+ let processedPromise = promiseFormsProcessed();
+ iframe.src = url;
+ return processedPromise;
+}
+
+async function formReadyInWindow(url) {
+ let processedPromise = promiseFormsProcessedInSameProcess();
+ win.location = url;
+ return processedPromise;
+}
+
+add_task(async function test_login_with_different_subdomain_shouldnt_autofill_wildcard_formActionOrigin() {
+ await prepareLogins([
+ new nsLoginInfo(otherOrigin, "", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+ await formReadyInWindow(origin + MISSING_ACTION_PATH);
+
+ await checkWindowLoginForm("", "");
+});
+
+add_task(async function test_login_with_different_subdomain_shouldnt_autofill_same_domain_formActionOrigin() {
+ await prepareLogins([
+ new nsLoginInfo(otherOrigin, origin, null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+ await formReadyInWindow(origin + MISSING_ACTION_PATH);
+
+ await checkWindowLoginForm("", "");
+});
+
+add_task(async function test_matching_logins_with_different_subdomain_and_matching_domain_should_autofill() {
+ await prepareLogins([
+ new nsLoginInfo(origin, origin, null,
+ "name2", "pass2", "uname", "pword"),
+ new nsLoginInfo(oldOrigin, origin, null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+ await formReadyInWindow(origin + MISSING_ACTION_PATH);
+
+ await checkWindowLoginForm("name2", "pass2");
+});
+
+add_task(async function test_login_with_different_subdomain_shouldnt_autofill_different_subdomain_formActionOrigin() {
+ await prepareLogins([
+ new nsLoginInfo(otherOrigin, otherOrigin, null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+ await formReadyInWindow(origin + MISSING_ACTION_PATH);
+
+ await checkWindowLoginForm("", "");
+});
+
+add_task(async function test_login_with_different_subdomain_shouldnt_autofill_different_domain_formActionOrigin() {
+ await prepareLogins([
+ new nsLoginInfo(otherOrigin, "https://example.net", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+ await formReadyInWindow(origin + MISSING_ACTION_PATH);
+
+ await checkWindowLoginForm("", "");
+});
+
+add_task(async function test_login_with_same_origin_shouldnt_autofill_cross_origin_iframe() {
+ await SimpleTest.promiseFocus(window);
+
+ async function checkIframeLoginForm(expectedUsername, expectedPassword) {
+ return SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [expectedUsername, expectedPassword], function(un, pw) {
+ var u = this.content.document.getElementById("form-basic-username");
+ var p = this.content.document.getElementById("form-basic-password");
+ Assert.equal(u.value, un, "Check username value");
+ Assert.equal(p.value, pw, "Check password value");
+ });
+ }
+
+ // We need an origin that is supported by the test framework to be able to load the
+ // cross-origin form into the iframe.
+ let crossOrigin = "https://test1.example.com";
+ info(`Top level frame origin: ${origin}. Iframe and login origin: ${crossOrigin}.`);
+ await prepareLogins([
+ new nsLoginInfo(crossOrigin, crossOrigin, null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+ await formReadyInFrame(crossOrigin + MISSING_ACTION_PATH);
+
+ await checkIframeLoginForm("", "");
+});
+
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofilling documents restored from the back/forward cache (bfcache)</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body onload="test_crossOriginBfcacheRestore();">
+<p id="display"></p>
+
+<div id="content">
+ <a id="next" href="https://example.org/tests/toolkit/components/passwordmgr/test/mochitest/file_history_back.html" target="loginWin">Next</a>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+SimpleTest.waitForExplicitFinish();
+
+/*
+ * The test opens a new window and updates login information. Then
+ * a new page is loaded and it goes immediately back. The initial page
+ * should be coming out from the bfcache and the form control values should be
+ * the ones filled during the initial load.
+ */
+async function test_crossOriginBfcacheRestore() {
+ // If Fission is disabled, the pref is no-op.
+ await SpecialPowers.pushPrefEnv({set: [["fission.bfcacheInParent", true]]});
+
+ var bc = new BroadcastChannel("form_basic_bfcache");
+ window.open("form_basic_bfcache.html", "", "noopener");
+ var pageshowCount = 0;
+ bc.onmessage = function(event) {
+ if (event.data.type == "pageshow") {
+ ++pageshowCount;
+ if (pageshowCount == 1) {
+ is(event.data.persisted, false, "Initial load");
+ bc.postMessage("nextpage");
+ } else if (pageshowCount == 2) {
+ is(event.data.persisted, true, "Should have persisted the page.");
+ bc.postMessage("close");
+ }
+ } else if (event.data.type == "is") {
+ is(event.data.value1, event.data.value2, event.data.message);
+ } else if (event.data.type == "ok") {
+ is(event.data.value, event.data.message);
+ } else if (event.data == "closed") {
+ bc.close();
+ SimpleTest.finish();
+ }
+ }
+
+}
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test no autofill into a password field that is no longer type=password</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+Login Manager test: Test no autofill into a password field that is no longer type=password
+
+<script>
+let DEFAULT_ORIGIN = window.location.origin;
+
+/** Test for Login Manager: Test no autofill into a password field that is no longer type=password **/
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [DEFAULT_ORIGIN, "https://autofill", null, "user1", "pass1"]
+ );
+});
+
+
+// As a control, test that autofill is working on this page.
+add_task(async function test_autofill_control() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <form id="form1" action="https://autofill">
+ <p>This is form 1.</p>
+ <input id="username-1" type="text" name="uname">
+ <input id="password-1" type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ </form>`, win);
+ await checkLoginFormInFrame(win, "username-1", "user1", "password-1", "pass1");
+});
+
+add_task(async function test_no_autofill() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ // Synchronously change the password field type to text before the fill happens.
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <form id="form1" action="https://autofill">
+ <p>This is form 1.</p>
+ <input id="username-1" type="text" name="uname">
+ <input id="password-1" type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ </form>`, win, 1, () => {
+ this.content.document.getElementById("password-1").type = "text";
+ });
+ await checkLoginFormInFrame(win, "username-1", "", "password-1", "");
+});
+</script>
+
+<p id="display"></p>
+
+<div id="content"></div>
+
+<pre id="test"></pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form field autofill highlight</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content">
+<pre id="test">
+<script>
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [window.location.origin, "https://autofill", null, "user1", "pass1", "", ""]
+ );
+});
+
+add_task(async function test_field_highlight_on_autofill() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(window.location.origin, `
+ <form id="form1" action="https://autofill" onsubmit="return false;">
+ <input type="text" id="uname">
+ <input type="password" id="pword">
+ <button type="submit">Submit</button>
+ </form>`, win);
+
+ await SpecialPowers.spawn(win, [], async function() {
+ let EventUtils = ContentTaskUtils.getEventUtils(this.content);
+ let doc = this.content.document;
+ let username = doc.getElementById("uname");
+ let password = doc.getElementById("pword");
+ ok(username.matches(":autofill"),
+ "Highlight was successfully applied to the username field on page load autofill");
+ ok(password.matches(":autofill"),
+ "Highlight was successfully applied to the password field on page load autofill");
+
+ // Test that initiating a change on the input value will remove the highlight. We check by pressing
+ // the tab key after backspace(by shifting focus to the next element) because the tab key is known to
+ // cause a bug where the highlight is applied once again.
+ username.focus();
+ await EventUtils.synthesizeKey("KEY_Backspace", {}, this.content);
+ await EventUtils.synthesizeKey("KEY_Tab", {}, this.content);
+
+ ok(!username.matches(":autofill"), "Highlight was successfully removed on change in value of username input element");
+
+ await EventUtils.synthesizeKey("KEY_Backspace", {}, this.content);
+ await EventUtils.synthesizeKey("KEY_Tab", {}, this.content);
+ ok(!password.matches(":autofill"), "Highlight was successfully removed on change in value of password input element");
+ });
+});
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test that filling an empty username into a form does not highlight the username element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<script>
+</script>
+<body>
+<p id="display"></p>
+<div id="content">
+<pre id="test">
+<script>
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [window.location.origin, "https://autofill", null, "", "pass1", "", ""]
+ );
+});
+
+add_task(async function test_field_highlight_on_autofill() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(window.location.origin, `
+ <form id="form1" action="https://autofill" onsubmit="return false;">
+ <input type="text" id="uname">
+ <input type="password" id="pword">
+ <button type="submit">Submit</button>
+ </form>`, win);
+
+ await SpecialPowers.spawn(win, [], async function() {
+ let EventUtils = ContentTaskUtils.getEventUtils(this.content);
+ let doc = this.content.document;
+ let username = doc.getElementById("uname");
+ let password = doc.getElementById("pword");
+ ok(!username.matches(":autofill"),
+ "Highlight was not applied to the username field on page load autofill");
+ ok(password.matches(":autofill"),
+ "Highlight was successfully applied to the password field on page load autofill");
+
+ // Test that initiating a change on the input value will remove the highlight. We check by pressing
+ // the tab key after backspace(by shifting focus to the next element) because the tab key is known to
+ // cause a bug where the highlight is applied once again.
+ username.focus();
+ await EventUtils.synthesizeKey("U", {}, this.content);
+ await EventUtils.synthesizeKey("KEY_Tab", {}, this.content);
+
+ ok(!username.matches(":autofill"), "Highlight is still not present on username element");
+
+ await EventUtils.synthesizeKey("KEY_Backspace", {}, this.content);
+ await EventUtils.synthesizeKey("KEY_Tab", {}, this.content);
+ ok(!password.matches(":autofill"), "Highlight was successfully removed on change in value of password input element");
+ });
+});
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test that filling a username into a username-only form does highlight the username element</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content">
+<pre id="test">
+<script>
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [window.location.origin, "https://autofill", null, "user1", "pass1", "", ""]
+ );
+});
+
+add_task(async function test_field_highlight_on_autofill() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(window.location.origin, `
+ <form id="form1" action="https://autofill" onsubmit="return false;">
+ <input type="text" id="uname" autocomplete="username">
+ <button type="submit">Submit</button>
+ </form>`, win);
+
+ await SpecialPowers.spawn(win, [], async function() {
+ let EventUtils = ContentTaskUtils.getEventUtils(this.content);
+ let doc = this.content.document;
+ let username = doc.getElementById("uname");
+ ok(username.matches(":autofill"),
+ "Highlight was successfully applied to the username field on page load autofill");
+
+ // Test that initiating a change on the input value will remove the highlight. We check by pressing
+ // the tab key after backspace(by shifting focus to the next element) because the tab key is known to
+ // cause a bug where the highlight is applied once again.
+ username.focus();
+ await EventUtils.synthesizeKey("KEY_Backspace", {}, this.content);
+ await EventUtils.synthesizeKey("KEY_Tab", {}, this.content);
+
+ ok(!username.matches(":autofill"), "Highlight was successfully removed on change in value of username input element");
+ });
+});
+</script>
+</body>
+</html>
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..091f9c8ad6
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_downgrade.html
@@ -0,0 +1,118 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test we don't autofill on an HTTP page using HTTPS logins</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+const MISSING_ACTION_PATH = TESTS_DIR + "mochitest/form_basic.html";
+const SAME_ORIGIN_ACTION_PATH = TESTS_DIR + "mochitest/form_same_origin_action.html";
+
+const chromeScript = runChecksAfterCommonInit(false);
+
+let nsLoginInfo = SpecialPowers.wrap(SpecialPowers.Components).Constructor("@mozilla.org/login-manager/loginInfo;1",
+ SpecialPowers.Ci.nsILoginInfo,
+ "init");
+</script>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+let win = window.open("about:blank");
+SimpleTest.registerCleanupFunction(() => win.close());
+
+async function prepareAndProcessForm(url, login) {
+ let processedPromise = promiseFormsProcessed();
+ win.location = url;
+ info("prepareAndProcessForm, assigned window location: " + url);
+ await processedPromise;
+}
+
+async function checkFormsWithLogin(formUrls, login, expectedUsername, expectedPassword) {
+ await LoginManager.removeAllUserFacingLogins();
+ await LoginManager.addLoginAsync(login);
+
+ for (let url of formUrls) {
+ info("start test_checkNoAutofillOnDowngrade w. url: " + url);
+
+ await prepareAndProcessForm(url);
+ info("form was processed");
+
+ await SpecialPowers.spawn(win, [url, expectedUsername, expectedPassword],
+ function(urlContent, expectedUsernameContent, expectedPasswordContent) {
+ let doc = this.content.document;
+ let uname = doc.getElementById("form-basic-username");
+ let pword = doc.getElementById("form-basic-password");
+ Assert.equal(uname.value, expectedUsernameContent, `username ${expectedUsernameContent ? "filled" : "not filled"} on ${urlContent}`);
+ Assert.equal(pword.value, expectedPasswordContent, `password ${expectedPasswordContent ? "filled" : "not filled"} on ${urlContent}`);
+ });
+ }
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["signon.schemeUpgrades", true],
+ ["dom.security.https_first", false],
+ ]});
+});
+
+add_task(async function test_sanityCheckHTTPS() {
+ let login = new nsLoginInfo("https://example.com", "https://example.com", null,
+ "name1", "pass1", "uname", "pword");
+
+ await checkFormsWithLogin([
+ `https://example.com${MISSING_ACTION_PATH}`,
+ `https://example.com${SAME_ORIGIN_ACTION_PATH}`,
+ ], login, "name1", "pass1");
+});
+
+add_task(async function test_checkNoAutofillOnDowngrade() {
+ let login = new nsLoginInfo("https://example.com", "https://example.com", null,
+ "name1", "pass1", "uname", "pword");
+ await checkFormsWithLogin([
+ `http://example.com${MISSING_ACTION_PATH}`,
+ `http://example.com${SAME_ORIGIN_ACTION_PATH}`,
+ ], login, "", "");
+});
+
+add_task(async function test_checkNoAutofillOnDowngradeSubdomain() {
+ let login = new nsLoginInfo("https://sub.example.com", "https://example.com", null,
+ "name1", "pass1", "uname", "pword");
+ todo(false, "await promiseFormsProcessed timesout when test is run with scheme=https");
+ await checkFormsWithLogin([
+ `http://example.com${MISSING_ACTION_PATH}`,
+ `http://example.com${SAME_ORIGIN_ACTION_PATH}`,
+ ], login, "", "");
+});
+
+
+add_task(async function test_checkNoAutofillOnDowngradeDifferentPort() {
+ let login = new nsLoginInfo("https://example.com:8080", "https://example.com", null,
+ "name1", "pass1", "uname", "pword");
+ await checkFormsWithLogin([
+ `http://example.com${MISSING_ACTION_PATH}`,
+ `http://example.com${SAME_ORIGIN_ACTION_PATH}`,
+ ], login, "", "");
+});
+
+add_task(async function test_checkNoAutofillOnDowngradeSubdomainDifferentPort() {
+ let login = new nsLoginInfo("https://sub.example.com:8080", "https://example.com", null,
+ "name1", "pass1", "uname", "pword");
+ await checkFormsWithLogin([
+ `https://example.com${MISSING_ACTION_PATH}`,
+ `https://example.com${SAME_ORIGIN_ACTION_PATH}`,
+ ], login, "", "");
+});
+</script>
+</pre>
+</body>
+</html>
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..a8691ebe54
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_https_upgrade.html
@@ -0,0 +1,148 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill on an HTTPS page using upgraded HTTP logins</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+const MISSING_ACTION_PATH = TESTS_DIR + "mochitest/form_basic.html";
+const CROSS_ORIGIN_SECURE_PATH = TESTS_DIR + "mochitest/form_cross_origin_secure_action.html";
+
+const chromeScript = runChecksAfterCommonInit(false);
+
+let nsLoginInfo = SpecialPowers.wrap(SpecialPowers.Components).Constructor("@mozilla.org/login-manager/loginInfo;1",
+ SpecialPowers.Ci.nsILoginInfo,
+ "init");
+</script>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+let win = window.open("about:blank");
+SimpleTest.registerCleanupFunction(() => win.close());
+
+async function prepareLoginsAndProcessForm(url, logins = []) {
+ await LoginManager.removeAllUserFacingLogins();
+
+ let dates = Date.now();
+ for (let login of logins) {
+ SpecialPowers.do_QueryInterface(login, SpecialPowers.Ci.nsILoginMetaInfo);
+ // Force all dates to be the same so they don't affect things like deduping.
+ login.timeCreated = login.timePasswordChanged = login.timeLastUsed = dates;
+ await LoginManager.addLoginAsync(login);
+ }
+
+ let processedPromise = promiseFormsProcessed();
+ win.location = url;
+ await processedPromise;
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["signon.schemeUpgrades", true],
+ ["signon.includeOtherSubdomainsInLookup", true],
+ ]});
+});
+
+add_task(async function test_simpleNoDupesNoAction() {
+ await prepareLoginsAndProcessForm("https://example.com" + MISSING_ACTION_PATH, [
+ new nsLoginInfo("http://example.com", "http://example.com", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ await checkLoginFormInFrame(win,
+ "form-basic-username", "name2",
+ "form-basic-password", "pass2");
+});
+
+add_task(async function test_simpleNoDupesUpgradeOriginAndAction() {
+ await prepareLoginsAndProcessForm("https://example.com" + CROSS_ORIGIN_SECURE_PATH, [
+ new nsLoginInfo("http://example.com", "http://example.org", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ await checkLoginFormInFrame(win, "form-basic-username", "name2",
+ "form-basic-password", "pass2");
+});
+
+add_task(async function test_simpleNoDupesUpgradeOriginOnly() {
+ await prepareLoginsAndProcessForm("https://example.com" + CROSS_ORIGIN_SECURE_PATH, [
+ new nsLoginInfo("http://example.com", "https://example.org", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ await checkLoginFormInFrame(win, "form-basic-username", "name2",
+ "form-basic-password", "pass2");
+});
+
+add_task(async function test_simpleNoDupesUpgradeActionOnly() {
+ await prepareLoginsAndProcessForm("https://example.com" + CROSS_ORIGIN_SECURE_PATH, [
+ new nsLoginInfo("https://example.com", "http://example.org", null,
+ "name2", "pass2", "uname", "pword"),
+ ]);
+
+ await checkLoginFormInFrame(win, "form-basic-username", "name2",
+ "form-basic-password", "pass2");
+});
+
+add_task(async function test_dedupe() {
+ await prepareLoginsAndProcessForm("https://example.com" + MISSING_ACTION_PATH, [
+ new nsLoginInfo("https://example.com", "https://example.com", null,
+ "name1", "passHTTPStoHTTPS", "uname", "pword"),
+ new nsLoginInfo("http://example.com", "http://example.com", null,
+ "name1", "passHTTPtoHTTP", "uname", "pword"),
+ new nsLoginInfo("http://example.com", "https://example.com", null,
+ "name1", "passHTTPtoHTTPS", "uname", "pword"),
+ new nsLoginInfo("https://example.com", "http://example.com", null,
+ "name1", "passHTTPStoHTTP", "uname", "pword"),
+ ]);
+
+ await checkLoginFormInFrame(win, "form-basic-username", "name1",
+ "form-basic-password", "passHTTPStoHTTPS");
+});
+
+add_task(async function test_dedupe_subdomain() {
+ // subdomain match (should be autofilled)
+ let loginToFill = new nsLoginInfo("http://test1.example.com", "http://test1.example.com", null,
+ "name1", "pass1");
+ const loginToFillGUID = "subdomain-match"
+ // Assign a GUID to this login so we can ensure this is the login that gets
+ // filled later.
+ loginToFill.QueryInterface(SpecialPowers.Ci.nsILoginMetaInfo).guid = loginToFillGUID;
+
+ await prepareLoginsAndProcessForm("https://test1.example.com" + MISSING_ACTION_PATH, [
+ // All logins have the same username and password:
+ // https: (scheme match)
+ new nsLoginInfo("https://example.com", "https://example.com", null,
+ "name1", "pass1"),
+ loginToFill,
+ // formActionOrigin match
+ new nsLoginInfo("http://example.com", "https://test1.example.com", null,
+ "name1", "pass1"),
+ ]);
+
+ await checkLoginFormInFrame(win, "form-basic-username", "name1",
+ "form-basic-password", "pass1");
+
+ let filledGUID = await SpecialPowers.spawn(win, [], function getFilledGUID() {
+ let LMC = this.content.windowGlobalChild.getActor("LoginManager");
+ let doc = this.content.document;
+ let form = doc.getElementById("form-basic");
+ let { login: filledLogin } = LMC.stateForDocument(doc).fillsByRootElement.get(form);
+ return filledLogin && filledLogin.guid;
+ });
+ is(filledGUID, loginToFillGUID, "Check the correct login was filled");
+});
+</script>
+</pre>
+</body>
+</html>
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..47f53bb65b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_password-only.html
@@ -0,0 +1,133 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test password-only forms should prefer a password-only login when present</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: Bug 444968
+<script>
+runChecksAfterCommonInit(null);
+
+SimpleTest.waitForExplicitFinish();
+
+const chromeScript = runInParent(async function chromeSetup() {
+ const login1A = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ const login1B = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ const login2A = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ const login2B = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ const login2C = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+
+ login1A.init("http://mochi.test:8888", "http://bug444968-1", null, "testuser1A", "testpass1A", "", "");
+ login1B.init("http://mochi.test:8888", "http://bug444968-1", null, "", "testpass1B", "", "");
+
+ login2A.init("http://mochi.test:8888", "http://bug444968-2", null, "testuser2A", "testpass2A", "", "");
+ login2B.init("http://mochi.test:8888", "http://bug444968-2", null, "", "testpass2B", "", "");
+ login2C.init("http://mochi.test:8888", "http://bug444968-2", null, "testuser2C", "testpass2C", "", "");
+
+ await Services.logins.addLogins([
+ login1A,
+ login1B,
+ login2A,
+ login2B,
+ login2C,
+ ]);
+
+ addMessageListener("removeLogins", function removeLogins() {
+ Services.logins.removeLogin(login1A);
+ Services.logins.removeLogin(login1B);
+ Services.logins.removeLogin(login2A);
+ Services.logins.removeLogin(login2B);
+ Services.logins.removeLogin(login2C);
+ });
+});
+
+SimpleTest.registerCleanupFunction(() => chromeScript.sendAsyncMessage("removeLogins"));
+</script>
+
+<p id="display"></p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+const DEFAULT_ORIGIN = window.location.origin;
+
+/* Test for Login Manager: 444968 (password-only forms should prefer a
+ * password-only login when present )
+ */
+async function startTest() {
+ const win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <!-- first 3 forms have matching user+pass and pass-only logins -->
+
+ <!-- user+pass form. -->
+ <form id="form1" action="http://bug444968-1">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- password-only form. -->
+ <form id="form2" action="http://bug444968-1">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- user+pass form, username prefilled -->
+ <form id="form3" action="http://bug444968-1">
+ <input type="text" name="uname" value="testuser1A">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+
+ <!-- next 4 forms have matching user+pass (2x) and pass-only (1x) logins -->
+
+ <!-- user+pass form. -->
+ <form id="form4" action="http://bug444968-2">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- password-only form. -->
+ <form id="form5" action="http://bug444968-2">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- user+pass form, username prefilled -->
+ <form id="form6" action="http://bug444968-2">
+ <input type="text" name="uname" value="testuser2A">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- user+pass form, username prefilled -->
+ <form id="form7" action="http://bug444968-2">
+ <input type="text" name="uname" value="testuser2C">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>`, win, 7);
+
+ await checkLoginFormInFrameWithElementValues(win, 1, "testuser1A", "testpass1A");
+ await checkLoginFormInFrameWithElementValues(win, 2, "testpass1B");
+ await checkLoginFormInFrameWithElementValues(win, 3, "testuser1A", "testpass1A");
+
+ checkUnmodifiedFormInFrame(win, 4); // 2 logins match
+ await checkLoginFormInFrameWithElementValues(win, 5, "testpass2B");
+ await checkLoginFormInFrameWithElementValues(win, 6, "testuser2A", "testpass2A");
+ await checkLoginFormInFrameWithElementValues(win, 7, "testuser2C", "testpass2C");
+
+ SimpleTest.finish();
+}
+
+registerRunTests(0, startTest);
+</script>
+</pre>
+</body>
+</html>
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..ef4dfdb417
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_sandboxed.html
@@ -0,0 +1,100 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form field autofill in sandboxed documents (null principal)</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="sandboxed"
+ sandbox=""></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+const { TestUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+/** Test for Login Manager: form field autofill in sandboxed documents (null principal) **/
+
+const sandboxed = document.getElementById("sandboxed");
+let uname;
+let pword;
+
+add_setup(async () => {
+ await setStoredLoginsAsync(["https://example.com", "", null, "tempuser1", "temppass1", "uname", "pword"]);
+});
+
+add_task(async function test_no_autofill_in_form() {
+ sandboxed.src = "form_basic.html";
+ const frameWindow = SpecialPowers.wrap(sandboxed).contentWindow;
+ const DOMFormHasPasswordPromise = new Promise(resolve => {
+ SpecialPowers.addChromeEventListener("DOMFormHasPassword", function onDFHP() {
+ SpecialPowers.removeChromeEventListener("DOMFormHasPassword", onDFHP);
+ resolve();
+ });
+ });
+ // Can't use SimpleTest.promiseFocus as it doesn't work with the sandbox.
+ await SimpleTest.promiseWaitForCondition(() => {
+ return frameWindow.document.readyState == "complete" &&
+ frameWindow.location.href.endsWith("form_basic.html");
+ }, "Check frame is loaded");
+ info("frame loaded");
+ await DOMFormHasPasswordPromise;
+ const frameDoc = SpecialPowers.wrap(sandboxed).contentDocument;
+
+ uname = frameDoc.getElementById("form-basic-username");
+ pword = frameDoc.getElementById("form-basic-password");
+
+ // Autofill shouldn't happen in the sandboxed frame but would have happened by
+ // now since DOMFormHasPassword was observed above.
+ await ensureLoginFormStaysFilledWith(uname, "", pword, "");
+
+ info("blurring the username field after typing the username");
+ uname.focus();
+ uname.setUserInput("tempuser1");
+ synthesizeKey("VK_TAB", {}, frameWindow);
+
+ await TestUtils.waitForCondition(() => {
+ return uname.value === "tempuser1" & pword.value === "";
+ }, "Username and password field should be filled");
+});
+
+add_task(async function test_no_autofill_outside_form() {
+ sandboxed.src = "formless_basic.html";
+ const frameWindow = SpecialPowers.wrap(sandboxed).contentWindow;
+ const DOMInputPasswordAddedPromise = new Promise(resolve => {
+ SpecialPowers.addChromeEventListener("DOMInputPasswordAdded", function onDIPA() {
+ SpecialPowers.removeChromeEventListener("DOMInputPasswordAdded", onDIPA);
+ resolve();
+ });
+ });
+ // Can't use SimpleTest.promiseFocus as it doesn't work with the sandbox.
+ await SimpleTest.promiseWaitForCondition(() => {
+ return frameWindow.document.readyState == "complete" &&
+ frameWindow.location.href.endsWith("formless_basic.html");
+ }, "Check frame is loaded");
+ info("frame loaded");
+ await DOMInputPasswordAddedPromise;
+ const frameDoc = SpecialPowers.wrap(sandboxed).contentDocument;
+
+ uname = frameDoc.getElementById("form-basic-username");
+ pword = frameDoc.getElementById("form-basic-password");
+
+ // Autofill shouldn't happen in the sandboxed frame but would have happened by
+ // now since DOMInputPasswordAdded was observed above.
+ await ensureLoginFormStaysFilledWith(uname, "", pword, "");
+});
+</script>
+</pre>
+</body>
+</html>
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..c88a6713f4
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_autofill_tab_between_fields.html
@@ -0,0 +1,151 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autocomplete behavior when tabbing between form fields</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+let nsLoginInfo = SpecialPowers.wrap(SpecialPowers.Components).Constructor("@mozilla.org/login-manager/loginInfo;1",
+ SpecialPowers.Ci.nsILoginInfo,
+ "init");
+let readyPromise = registerRunTests();
+</script>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+let DEFAULT_ORIGIN = window.location.origin;
+let win;
+let html = `
+ <form id="form1" action="https://autofill" onsubmit="return false;">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>`;
+
+async function prepareLogins(logins = []) {
+ await LoginManager.removeAllUserFacingLogins();
+
+ for (let login of logins) {
+ await LoginManager.addLoginAsync(login);
+ }
+ let count = (await LoginManager.getAllLogins()).length;
+ is(count, logins.length, "All logins were added");
+}
+
+const availableLogins = {
+ "exampleUser1": new nsLoginInfo(DEFAULT_ORIGIN, "https://autofill", null,
+ "user1", "pass1", "uname", "pword"),
+}
+
+async function recreateTreeInWindow(formNum) {
+ await SpecialPowers.spawn(win, [formNum], (formNumF) => {
+ let form = this.content.document.querySelector(`#form${formNumF}`);
+ // eslint-disable-next-line no-self-assign
+ form.outerHTML = form.outerHTML;
+ });
+}
+
+const tests = [
+ {
+ name: "autofill_disabled_exact_username",
+ autofillEnabled: false,
+ logins: ["exampleUser1"],
+ expectedAutofillUsername: "",
+ expectedAutofillPassword: "",
+ typeUsername: "user1",
+ expectedTabbedUsername: "user1",
+ expectedTabbedPassword: "",
+ },
+ {
+ name: "autofill_enabled_exact_username",
+ autofillEnabled: true,
+ logins: ["exampleUser1"],
+ expectedAutofillUsername: "user1",
+ expectedAutofillPassword: "pass1",
+ typeUsername: "user1",
+ expectedTabbedUsername: "user1",
+ expectedTabbedPassword: "pass1",
+ },
+];
+
+add_setup(async () => {
+ ok(readyPromise, "check promise is available");
+ await readyPromise;
+ win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, html, win);
+});
+
+async function testResultOfTabInteractions(testData) {
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["signon.autofillForms", testData.autofillEnabled],
+ ]});
+
+ await SimpleTest.promiseFocus(win);
+ let logins = testData.logins.map(name => availableLogins[name]);
+ await prepareLogins(logins);
+
+ info("recreating form");
+ let processed = promiseFormsProcessed();
+ await recreateTreeInWindow(1);
+ info("waiting for form processed");
+ await processed;
+ // check autofill results
+ await checkLoginFormInFrameWithElementValues(win, 1, testData.expectedAutofillUsername, testData.expectedAutofillPassword);
+
+ await SpecialPowers.spawn(win, [testData.typeUsername], async (typeUsername) => {
+ let doc = this.content.document;
+ let pword = doc.querySelector("[name='pword']");
+ let uname = doc.querySelector("[name='uname']");
+
+ pword.setUserInput("");
+ uname.setUserInput("");
+
+ info("Placing focus in the username field");
+ uname.focus();
+
+ if (typeUsername) {
+ info("Filling username field");
+ EventUtils.sendString(typeUsername, this.content);
+ }
+
+ EventUtils.synthesizeKey("KEY_Tab", {}, this.content); // blur un, focus pw
+ await new Promise(resolve => SpecialPowers.executeSoon(resolve));
+
+ ok(pword.matches("input:focus"), "pword field is focused");
+ });
+
+ await checkLoginFormInFrameWithElementValues(win, 1, testData.expectedTabbedUsername, testData.expectedTabbedPassword);
+
+ await recreateTreeInWindow(1);
+ await promiseFormsProcessed();
+
+ await SpecialPowers.spawn(win, [], () => {
+ EventUtils.synthesizeKey("KEY_Escape", {}, this.content);
+ });
+}
+
+for (let testData of tests) {
+ let tmp = {
+ async [testData.name]() {
+ await testResultOfTabInteractions(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
+
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill on username-form</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Test autofill on username-form
+
+<script>
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [window.location.origin, "https://autofill", null, "user1", "pass1"]
+ );
+});
+
+add_task(async function test_autofill_username_only_form() {
+ await loadRecipes({
+ siteRecipes: [{
+ hosts: ["mochi.test:8888"],
+ notUsernameSelector: "input[name='shouldnotfill']",
+ }],
+ });
+
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+
+ // 5 out of the 7 forms should be autofilled
+ await loadFormIntoWindow(window.location.origin, `
+ <!-- no password field, 1 username field -->
+ <form id='form1' action='https://autofill'> 1
+ <input type='text' name='uname' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- no password field, 1 username field, with a value set -->
+ <form id='form2' action='https://autofill'> 2
+ <input type='text' name='uname' autocomplete='username' value='someuser'>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- no password field, 2 username fields, should be ignored -->
+ <form id='form3' action='https://autofill'> 3
+ <input type='text' name='uname1' autocomplete='username' value=''>
+ <input type='text' name='uname2' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- no password field, 1 username field, too small for the username login -->
+ <form id='form4' action='https://autofill'> 4
+ <input type='text' name='uname' value='' maxlength="4" autocomplete='username'>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- no password field, 1 username field, too small for the username login -->
+ <form id='form5' action='https://autofill'> 5
+ <input type='text' name='uname' value='' maxlength="0" autocomplete='username'>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- no password field, 1 text input field (not a username-only form), should be ignored -->
+ <form id='form6' action='https://autofill'> 6
+ <input type='text' name='uname' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- no password field, 1 username field that matches notUsernameSelector recipe -->
+ <form id='form7' action='https://autofill'> 7
+ <input type='text' name='shouldnotfill' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>`, win, 5);
+
+ await checkLoginFormInFrameWithElementValues(win, 1, "user1");
+ await checkLoginFormInFrameWithElementValues(win, 2, "someuser");
+ await checkUnmodifiedFormInFrame(win, 3);
+ await checkUnmodifiedFormInFrame(win, 4);
+ await checkUnmodifiedFormInFrame(win, 5);
+ await checkUnmodifiedFormInFrame(win, 6);
+ await checkUnmodifiedFormInFrame(win, 7);
+
+ await resetRecipes();
+});
+</script>
+
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test"></pre>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill on username-form when the number of form exceeds the lookup threshold</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Test not autofill on username-form when the number of form exceeds the lookup threshold
+
+<script>
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({"set": [["signon.usernameOnlyForm.lookupThreshold", 5]]});
+
+ await setStoredLoginsAsync(
+ [window.location.origin, "https://autofill", null, "user1", "pass1"]
+ );
+});
+
+add_task(async function test_autofill_username_only_form() {
+ const win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+
+ await loadFormIntoWindow(window.location.origin, `
+ <!-- no password field, 1 username field -->
+ <form id='form1' action='https://autofill'> 1
+ <input type='text' name='uname' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <form id='form2' action='https://autofill'> 2
+ <input type='text' name='uname' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <form id='form3' action='https://autofill'> 3
+ <input type='text' name='uname' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <form id='form4' action='https://autofill'> 4
+ <input type='text' name='uname' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <form id='form5' action='https://autofill'> 5
+ <input type='text' name='uname' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <form id='form6' action='https://autofill'> 6
+ <input type='text' name='uname' autocomplete='username' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>`, win, 5);
+
+ await checkLoginFormInFrameWithElementValues(win, 1, "user1");
+ await checkLoginFormInFrameWithElementValues(win, 2, "user1");
+ await checkLoginFormInFrameWithElementValues(win, 3, "user1");
+ await checkLoginFormInFrameWithElementValues(win, 4, "user1");
+ await checkLoginFormInFrameWithElementValues(win, 5, "user1");
+ await checkUnmodifiedFormInFrame(win, 6);
+});
+</script>
+
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test"></pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test login autocomplete is activated when focused by js on load</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content">
+ <iframe></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+const iframe = document.getElementsByTagName("iframe")[0];
+let iframeDoc, hostname;
+
+add_setup(async () => {
+ const origin = window.location.origin;
+ await setStoredLoginsAsync(
+ [origin, origin, null, "name", "pass"],
+ [origin, origin, null, "name1", "pass1"]
+ );
+
+ const processedPromise = promiseFormsProcessed();
+ iframe.src = "/tests/toolkit/components/passwordmgr/test/mochitest/form_autofocus_js.html";
+ await new Promise(resolve => {
+ iframe.addEventListener("load", function() {
+ resolve();
+ }, {once: true});
+ });
+
+ await processedPromise;
+
+ hostname = await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ return this.content.document.documentURIObject.host;
+ });
+
+ SimpleTest.requestFlakyTimeout("Giving a chance for the unexpected popupshown to occur");
+});
+
+add_task(async function test_initial_focus() {
+ let results = await notifyMenuChanged(3, "name");
+ checkAutoCompleteResults(results, ["name", "name1"], hostname, "Two login results");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await promiseFormsProcessedInSameProcess();
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ Assert.equal(this.content.document.getElementById("form-basic-password").value, "pass", "Check first password filled");
+ });
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is now closed");
+});
+
+// This depends on the filling from the previous test.
+add_task(async function test_not_reopened_if_filled() {
+ listenForUnexpectedPopupShown();
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ this.content.document.getElementById("form-basic-username").focus();
+ });
+ info("Waiting to see if a popupshown occurs");
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // cleanup
+ gPopupShownExpected = true;
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ this.content.document.getElementById("form-basic-submit").focus();
+ });
+});
+
+add_task(async function test_reopened_after_edit_not_matching_saved() {
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ this.content.document.getElementById("form-basic-username").value = "nam";
+ });
+ await popupBy(async () => {
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ this.content.document.getElementById("form-basic-username").focus();
+ });
+ });
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ this.content.document.getElementById("form-basic-submit").focus();
+ });
+});
+
+add_task(async function test_not_reopened_after_selecting() {
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ this.content.document.getElementById("form-basic-username").value = "";
+ this.content.document.getElementById("form-basic-password").value = "";
+ });
+ listenForUnexpectedPopupShown();
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ let formFillController = SpecialPowers.getFormFillController();
+ let usernameField = this.content.document.getElementById("form-basic-username");
+ formFillController.markAsLoginManagerField(usernameField);
+ });
+
+ info("Waiting to see if a popupshown occurs");
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // Cleanup
+ gPopupShownExpected = true;
+});
+
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic autofill</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: simple form fill
+
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(startTest);
+
+let DEFAULT_ORIGIN = window.location.origin;
+
+/** Test for Login Manager: form fill, multiple forms. **/
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <form id="form1" action="formtest.js">
+ <p>This is form 1.</p>
+ <input id="username-1" type="text" name="uname">
+ <input id="password-1" type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>`, win);
+ await checkLoginFormInFrame(win, "username-1", "testuser", "password-1", "testpass");
+
+ SimpleTest.finish();
+}
+</script>
+
+<p id="display"></p>
+
+<div id="content" style="display: none">
+
+
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test forms with no password fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: forms with no password fields
+<p id="display"></p>
+
+<div id="content" style="display: none">
+
+ <!-- Form with no user field or password field -->
+ <form id="form1" action="formtest.js">
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Form with no user field or password field, but one other field -->
+ <form id="form2" action="formtest.js">
+ <input type="checkbox">
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Form with no user field or password field, but one other field -->
+ <form id="form3" action="formtest.js">
+ <input type="checkbox" name="uname" value="">
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Form with a text field, but no password field -->
+ <form id="form4" action="formtest.js">
+ <input type="text" name="yyyyy">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Form with a user field, but no password field -->
+ <form id="form5" action="formtest.js">
+ <input type="text" name="uname">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+function startTest() {
+ is(getFormElementByName(3, "uname").value, "", "Checking for unfilled checkbox (form 3)");
+ is(getFormElementByName(4, "yyyyy").value, "", "Checking for unfilled text field (form 4)");
+ is(getFormElementByName(5, "uname").value, "", "Checking for unfilled text field (form 5)");
+
+ SimpleTest.finish();
+}
+
+runChecksAfterCommonInit(startTest);
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill for forms with 1 password field</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: forms with 1 password field
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(() => startTest());
+
+let DEFAULT_ORIGIN = window.location.origin;
+</script>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: simple form fill **/
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <!-- no username fields -->
+
+ <form id='form1' action='formtest.js'> 1
+ <!-- Blank, so fill in the password -->
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form2' action='formtest.js'> 2
+ <!-- Already contains the password, so nothing to do. -->
+ <input type='password' name='pname' value='testpass'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form3' action='formtest.js'> 3
+ <!-- Contains unknown password, so don't change it -->
+ <input type='password' name='pname' value='xxxxxxxx'>
+ <button type='submit'>Submit</button>
+ </form>
+
+
+ <!-- username fields -->
+
+ <form id='form4' action='formtest.js'> 4
+ <!-- Blanks, so fill in login -->
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form5' action='formtest.js'> 5
+ <!-- Username already set, so fill in password -->
+ <input type='text' name='uname' value='testuser'>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form6' action='formtest.js'> 6
+ <!-- Unknown username, so don't fill in password -->
+ <input type='text' name='uname' value='xxxxxxxx'>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form7' action='formtest.js'> 7
+ <!-- Password already set, could fill in username but that's weird so we don't -->
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value='testpass'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form8' action='formtest.js'> 8
+ <!-- Unknown password, so don't fill in a username -->
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value='xxxxxxxx'>
+ <button type='submit'>Submit</button>
+ </form>
+
+
+
+ <!-- extra text fields -->
+
+ <form id='form9' action='formtest.js'> 9
+ <!-- text field _after_ password should never be treated as a username field -->
+ <input type='password' name='pname' value=''>
+ <input type='text' name='uname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form10' action='formtest.js'> 10
+ <!-- only the first text field before the password should be for username -->
+ <input type='text' name='other' value=''>
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form11' action='formtest.js'> 11
+ <!-- variation just to make sure extra text field is still ignored -->
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value=''>
+ <input type='text' name='other' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+
+
+ <!-- same as last bunch, but with xxxx in the extra field. -->
+
+ <form id='form12' action='formtest.js'> 12
+ <!-- text field _after_ password should never be treated as a username field -->
+ <input type='password' name='pname' value=''>
+ <input type='text' name='uname' value='xxxxxxxx'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form13' action='formtest.js'> 13
+ <!-- only the first text field before the password should be for username -->
+ <input type='text' name='other' value='xxxxxxxx'>
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form14' action='formtest.js'> 14
+ <!-- variation just to make sure extra text field is still ignored -->
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value=''>
+ <input type='text' name='other' value='xxxxxxxx'>
+ <button type='submit'>Submit</button>
+ </form>`, win, 14);
+
+ var f = 1;
+
+ // 1-3
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass");
+ await checkLoginFormInFrameWithElementValues(win, f++, "xxxxxxxx");
+
+ // 4-8
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, f++, "xxxxxxxx", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, f++, "", "xxxxxxxx");
+
+ // 9-14
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "", "testuser", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass", "xxxxxxxx");
+ await checkLoginFormInFrameWithElementValues(win, f++, "xxxxxxxx", "testuser", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass", "xxxxxxxx");
+
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test forms with 1 password field, part 2</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: forms with 1 password field, part 2
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(() => startTest());
+
+let DEFAULT_ORIGIN = window.location.origin;
+</script>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: simple form fill, part 2 **/
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <form id='form1' action='formtest.js'> 1
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form2' action='formtest.js'> 2
+ <input type='password' name='pname' value='' disabled>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form3' action='formtest.js'> 3
+ <input type='password' name='pname' value='' readonly>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form4' action='formtest.js'> 4
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form5' action='formtest.js'> 5
+ <input type='text' name='uname' value='' disabled>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form6' action='formtest.js'> 6
+ <input type='text' name='uname' value='' readonly>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form7' action='formtest.js'> 7
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value='' disabled>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form8' action='formtest.js'> 8
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value='' readonly>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form9' action='formtest.js'> 9
+ <input type='text' name='uname' value='TESTUSER'>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form10' action='formtest.js'> 10
+ <input type='text' name='uname' value='TESTUSER' readonly>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form11' action='formtest.js'> 11
+ <input type='text' name='uname' value='TESTUSER' disabled>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>`, win, 11);
+
+ var f;
+
+ // Test various combinations of disabled/readonly inputs
+ await checkLoginFormInFrameWithElementValues(win, 1, "testpass"); // control
+ await checkUnmodifiedFormInFrame(win, 2);
+ await checkUnmodifiedFormInFrame(win, 3);
+ await checkLoginFormInFrameWithElementValues(win, 4, "testuser", "testpass"); // control
+ for (f = 5; f <= 8; f++) {
+ await checkUnmodifiedFormInFrame(win, f);
+ }
+ // Test case-insensitive comparison of username field
+ await checkLoginFormInFrameWithElementValues(win, 9, "testuser", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, 10, "TESTUSER", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, 11, "TESTUSER", "testpass");
+
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill for forms with 2 password fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: forms with 2 password fields
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(() => startTest());
+
+let DEFAULT_ORIGIN = window.location.origin;
+</script>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: simple form fill **/
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <!-- no username fields -->
+
+ <form id='form1' action='formtest.js'> 1
+ <!-- simple form, fill in first pw -->
+ <input type='password' name='pname' value=''>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form2' action='formtest.js'> 2
+ <!-- same but reverse pname and qname, field names are ignored. -->
+ <input type='password' name='qname' value=''>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form3' action='formtest.js'> 3
+ <!-- text field after password fields should be ignored, no username. -->
+ <input type='password' name='pname' value=''>
+ <input type='password' name='qname' value=''>
+ <input type='text' name='uname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form4' action='formtest.js'> 4
+ <!-- nothing to do, password already present -->
+ <input type='password' name='pname' value='testpass'>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form5' action='formtest.js'> 5
+ <!-- don't clobber an existing unrecognized password -->
+ <input type='password' name='pname' value='xxxxxxxx'>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form6' action='formtest.js'> 6
+ <!-- fill in first field, 2nd field shouldn't be touched anyway. -->
+ <input type='password' name='pname' value=''>
+ <input type='password' name='qname' value='xxxxxxxx'>
+ <button type='submit'>Submit</button>
+ </form>
+
+
+
+ <!-- with username fields -->
+
+
+
+ <form id='form7' action='formtest.js'> 7
+ <!-- simple form, should fill in username and first pw -->
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value=''>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form8' action='formtest.js'> 8
+ <!-- reverse pname and qname, field names are ignored. -->
+ <input type='text' name='uname' value=''>
+ <input type='password' name='qname' value=''>
+ <input type='password' name='pname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form9' action='formtest.js'> 9
+ <!-- username already filled, so just fill first password -->
+ <input type='text' name='uname' value='testuser'>
+ <input type='password' name='pname' value=''>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form10' action='formtest.js'> 10
+ <!-- unknown username, don't fill in a password -->
+ <input type='text' name='uname' value='xxxxxxxx'>
+ <input type='password' name='pname' value=''>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form11' action='formtest.js'> 11
+ <!-- don't clobber unknown password -->
+ <input type='text' name='uname' value='testuser'>
+ <input type='password' name='pname' value='xxxxxxxx'>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form12' action='formtest.js'> 12
+ <!-- fill in 1st pass, don't clobber 2nd pass -->
+ <input type='text' name='uname' value='testuser'>
+ <input type='password' name='pname' value=''>
+ <input type='password' name='qname' value='xxxxxxxx'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form13' action='formtest.js'> 13
+ <!-- nothing to do, user and pass prefilled. life is easy. -->
+ <input type='text' name='uname' value='testuser'>
+ <input type='password' name='pname' value='testpass'>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form14' action='formtest.js'> 14
+ <!-- shouldn't fill in username because 1st pw field is unknown. -->
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value='xxxxxxxx'>
+ <input type='password' name='qname' value='testpass'>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form15' action='formtest.js'> 15
+ <!-- textfield in the middle of pw fields should be ignored -->
+ <input type='password' name='pname' value=''>
+ <input type='text' name='uname' value=''>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>
+
+ <form id='form16' action='formtest.js'> 16
+ <!-- same, and don't clobber existing unknown password -->
+ <input type='password' name='pname' value='xxxxxxxx'>
+ <input type='text' name='uname' value=''>
+ <input type='password' name='qname' value=''>
+ <button type='submit'>Submit</button>
+ </form>`, win, 16);
+
+ var f = 1;
+
+ // 1-6 no username
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass", "", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "xxxxxxxx", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass", "xxxxxxxx");
+
+ // 7-15 with username
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "xxxxxxxx", "", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "xxxxxxxx", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass", "xxxxxxxx");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testuser", "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "", "xxxxxxxx", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, f++, "testpass", "", "");
+ await checkLoginFormInFrameWithElementValues(win, f++, "xxxxxxxx", "", "");
+
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for form fill with 2 password fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: form fill, 2 password fields
+<p id="display"></p>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: form fill, 2 password fields **/
+
+/*
+ * If a form has two password fields, other things may be going on....
+ *
+ * 1 - The user might be creating a new login (2nd field for typo checking)
+ * 2 - The user is changing a password (old and new password each have field)
+ *
+ * This test is for case #1.
+ */
+
+var numSubmittedForms = 0;
+var numStartingLogins = 0;
+
+function startTest() {
+ // Check for unfilled forms
+ is(getFormElementByName(1, "uname").value, "", "Checking username 1");
+ is(getFormElementByName(1, "pword").value, "", "Checking password 1A");
+ is(getFormElementByName(1, "qword").value, "", "Checking password 1B");
+
+ // Fill in the username and password fields, for account creation.
+ // Form 1
+ SpecialPowers.wrap(getFormElementByName(1, "uname")).setUserInput("newuser1");
+ SpecialPowers.wrap(getFormElementByName(1, "pword")).setUserInput("newpass1");
+ SpecialPowers.wrap(getFormElementByName(1, "qword")).setUserInput("newpass1");
+
+ // eslint-disable-next-line no-unused-vars
+ var button = getFormSubmitButton(1);
+
+ todo(false, "form submission disabled, can't auto-accept dialog yet");
+ SimpleTest.finish();
+}
+
+
+// Called by each form's onsubmit handler.
+function checkSubmit(formNum) {
+ numSubmittedForms++;
+
+ // End the test at the last form.
+ if (formNum == 999) {
+ is(numSubmittedForms, 999, "Ensuring all forms submitted for testing.");
+
+ (async () => {
+ var numEndingLogins = await LoginManager.countLogins("", "", "");
+
+ ok(numEndingLogins > 0, "counting logins at end");
+ is(numStartingLogins, numEndingLogins + 222, "counting logins at end");
+
+ SimpleTest.finish();
+ })();
+ return false; // return false to cancel current form submission
+ }
+
+ // submit the next form.
+ var button = getFormSubmitButton(formNum + 1);
+ button.click();
+
+ return false; // return false to cancel current form submission
+}
+
+
+function getFormSubmitButton(formNum) {
+ var form = $("form" + formNum); // by id, not name
+ ok(form != null, "getting form " + formNum);
+
+ // we can't just call form.submit(), because that doesn't seem to
+ // invoke the form onsubmit handler.
+ var button = form.firstChild;
+ while (button && button.type != "submit") {
+ button = button.nextSibling;
+ }
+ ok(button != null, "getting form submit button");
+
+ return button;
+}
+
+runChecksAfterCommonInit(startTest);
+
+</script>
+</pre>
+<div id="content" style="display: none">
+ <form id="form1" onsubmit="return checkSubmit(1)" action="http://newuser.com">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ <input type="password" name="qword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+</div>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill for forms with 3 password fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: forms with 3 password fields (form filling)
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(() => startTest());
+
+let DEFAULT_ORIGIN = window.location.origin;
+</script>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: form fill, 3 password fields **/
+
+// Test to make sure 3-password forms are filled properly.
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <p>The next three forms are <b>user/pass/passB/passC</b>, as all-empty, preuser(only), and preuser/pass</p>
+ <form id="form1" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ <input type="password" name="qword">
+ <input type="password" name="rword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <form id="form2" action="formtest.js">
+ <input type="text" name="uname" value="testuser">
+ <input type="password" name="pword">
+ <input type="password" name="qword">
+ <input type="password" name="rword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <form id="form3" action="formtest.js">
+ <input type="text" name="uname" value="testuser">
+ <input type="password" name="pword" value="testpass">
+ <input type="password" name="qword">
+ <input type="password" name="rword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+
+ <p>The next three forms are <b>user/passB/pass/passC</b>, as all-empty, preuser(only), and preuser/pass</p>
+ <form id="form4" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="qword">
+ <input type="password" name="pword">
+ <input type="password" name="rword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <form id="form5" action="formtest.js">
+ <input type="text" name="uname" value="testuser">
+ <input type="password" name="qword">
+ <input type="password" name="pword">
+ <input type="password" name="rword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <form id="form6" action="formtest.js">
+ <input type="text" name="uname" value="testuser">
+ <input type="password" name="qword">
+ <input type="password" name="pword" value="testpass">
+ <input type="password" name="rword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <p>The next three forms are <b>user/passB/passC/pass</b>, as all-empty, preuser(only), and preuser/pass</p>
+ <form id="form7" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="qword">
+ <input type="password" name="rword">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <form id="form8" action="formtest.js">
+ <input type="text" name="uname" value="testuser">
+ <input type="password" name="qword">
+ <input type="password" name="rword">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <form id="form9" action="formtest.js">
+ <input type="text" name="uname" value="testuser">
+ <input type="password" name="qword">
+ <input type="password" name="rword">
+ <input type="password" name="pword" value="testpass">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>`, win, 9);
+
+ let TESTCASES = [
+ // Check form 1
+ {
+ formNum: 1,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 1"],
+ ["pword", "testpass", "Checking password 1"],
+ ["qword", "", "Checking password 1 (q)"],
+ ["rword", "", "Checking password 1 (r)"],
+ ],
+ },
+ // Check form 2
+ {
+ formNum: 2,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 2"],
+ ["pword", "testpass", "Checking password 2"],
+ ["qword", "", "Checking password 2 (q)"],
+ ["rword", "", "Checking password 2 (r)"],
+ ],
+ },
+ // Check form 3
+ {
+ formNum: 3,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 3"],
+ ["pword", "testpass", "Checking password 3"],
+ ["qword", "", "Checking password 3 (q)"],
+ ["rword", "", "Checking password 3 (r)"],
+ ],
+ },
+ // Check form 4
+ {
+ formNum: 4,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 4"],
+ ["rword", "", "Checking password 4 (r)"],
+ ],
+ todoIsAssertionTuples: [
+ ["qword", "", "Checking password 4 (q)"],
+ ["pword", "testpass", "Checking password 4"],
+ ],
+ },
+ // Check form 5
+ {
+ formNum: 5,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 5"],
+ ["rword", "", "Checking password 5 (r)"],
+ ],
+ todoIsAssertionTuples: [
+ ["qword", "", "Checking password 5 (q)"],
+ ["pword", "testpass", "Checking password 5"],
+ ],
+ },
+ // Check form 6
+ {
+ formNum: 6,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 6"],
+ ["pword", "testpass", "Checking password 6"],
+ ["rword", "", "Checking password 6 (r)"],
+ ],
+ todoIsAssertionTuples: [
+ ["qword", "", "Checking password 6 (q)"],
+ ],
+ },
+ // Check form 7
+ {
+ formNum: 7,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 7"],
+ ["rword", "", "Checking password 7 (r)"],
+ ],
+ todoIsAssertionTuples: [
+ ["qword", "", "Checking password 7 (q)"],
+ ["pword", "testpass", "Checking password 7"],
+ ],
+ },
+ // Check form 8
+ {
+ formNum: 8,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 8"],
+ ["rword", "", "Checking password 8 (r)"],
+ ],
+ todoIsAssertionTuples: [
+ ["qword", "", "Checking password 8 (q)"],
+ ["pword", "testpass", "Checking password 8"],
+ ],
+ },
+ // Check form 9
+ {
+ formNum: 9,
+ isAssertionTuples: [
+ ["uname", "testuser", "Checking username 9"],
+ ["rword", "", "Checking password 9 (r)"],
+ ["pword", "testpass", "Checking password 9"],
+ ],
+ todoIsAssertionTuples: [
+ ["qword", "", "Checking password 9 (q)"],
+ ],
+ },
+ ];
+
+ await SpecialPowers.spawn(win, [TESTCASES], (testcasesF) => {
+ let doc = this.content.document;
+ for (let testcase of testcasesF) {
+ let { formNum } = testcase;
+ for (let tuple of testcase.isAssertionTuples) {
+ let [name, value, message] = tuple;
+ is(doc.querySelector(`#form${formNum} input[name=${name}]`).value, value, message);
+ }
+ if (!testcase.todoIsAssertionTuples) {
+ continue;
+ }
+ // TODO: Bug 1669614
+ // for (let tuple of testcase.todoIsAssertionTuples) {
+ // let [name, value, message] = tuple;
+ // todo_is(doc.querySelector(`#form${formNum} input[name=${name}]`).value, value, message);
+ // }
+ }
+ });
+
+ // TODO: as with the 2-password cases, add tests to check for creating new
+ // logins and changing passwords.
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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..42214485c8
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_honor_autocomplete_off.html
@@ -0,0 +1,149 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test login autofill autocomplete when signon.autofillForms.autocompleteOff is false</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: autofilling when autocomplete=off
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content"></div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/** Test for Login Manager: multiple login autocomplete. **/
+
+// Check for expected username/password in form.
+function checkFormValues(form, expectedUsername, expectedPassword) {
+ let uname = form.querySelector("[name='uname']");
+ let pword = form.querySelector("[name='pword']");
+ is(uname.value, expectedUsername, `Checking ${form.id} username is: ${expectedUsername}`);
+ is(pword.value, expectedPassword, `Checking ${form.id} password is: ${expectedPassword}`);
+}
+
+async function autoCompleteFieldsFromFirstMatch(form) {
+ // trigger autocomplete from the username field
+ await SimpleTest.promiseFocus(form.ownerGlobal);
+ let uname = form.querySelector("[name='uname']");
+ await popupBy(() => uname.focus());
+
+ let formFilled = promiseFormsProcessedInSameProcess();
+ await synthesizeKey("KEY_ArrowDown"); // open
+ await synthesizeKey("KEY_Enter");
+ await formFilled;
+ await Promise.resolve();
+}
+
+add_setup(async () => {
+ // Set the pref before the document loads.
+ SpecialPowers.setBoolPref("signon.autofillForms.autocompleteOff", false);
+ SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("signon.autofillForms.autocompleteOff");
+ });
+
+ await setStoredLoginsAsync(
+ [window.location.origin, "https://autocomplete", null, "singleuser", "singlepass", "uname", "pword"]
+ );
+ listenForUnexpectedPopupShown();
+});
+
+/* Tests for autofill of single-user forms for when we honor autocomplete=off on password fields */
+add_task(async function honor_password_autocomplete_off() {
+ const form = createLoginForm({
+ action: "https://autocomplete",
+ password: {
+ autocomplete: "off"
+ }
+ });
+ await promiseFormsProcessedInSameProcess();
+ await SimpleTest.promiseFocus(window);
+ // With the pref toggled off, and with autocomplete=off on the password field,
+ // we expect not to have autofilled this form
+ checkFormValues(form, "", "");
+ // ..but it should autocomplete just fine
+ await autoCompleteFieldsFromFirstMatch(form);
+ checkFormValues(form, "singleuser", "singlepass");
+});
+
+add_task(async function honor_username_autocomplete_off() {
+ const form = createLoginForm({
+ action: "https://autocomplete",
+ username: {
+ autocomplete: "off"
+ }
+ });
+ await promiseFormsProcessedInSameProcess();
+ await SimpleTest.promiseFocus(window);
+ // With the pref toggled off, and with autocomplete=off on the username field,
+ // we expect to have autofilled this form
+ checkFormValues(form, "singleuser", "singlepass");
+});
+
+add_task(async function honor_form_autocomplete_off() {
+ const form = createLoginForm({
+ action: "https://autocomplete",
+ autocomplete: "off"
+ });
+ await promiseFormsProcessedInSameProcess();
+ await SimpleTest.promiseFocus(window);
+ // With the pref toggled off, and with autocomplete=off on the form,
+ // we expect to have autofilled this form
+ checkFormValues(form, "singleuser", "singlepass");
+});
+
+add_task(async function honor_username_and_password_autocomplete_off() {
+ const form = createLoginForm({
+ action: "https://autocomplete",
+ username: {
+ autocomplete: "off"
+ },
+ password: {
+ autocomplete: "off"
+ }
+ });
+ await promiseFormsProcessedInSameProcess();
+ await SimpleTest.promiseFocus(window);
+ // With the pref toggled off, and autocomplete=off on the username and password field,
+ // we expect not to have autofilled this form
+ checkFormValues(form, "", "");
+ // ..but it should autocomplete just fine
+ await autoCompleteFieldsFromFirstMatch(form);
+ checkFormValues(form, "singleuser", "singlepass");
+});
+
+add_task(async function reference_form() {
+ const form = createLoginForm({
+ action: "https://autocomplete"
+ });
+ await promiseFormsProcessedInSameProcess();
+ await SimpleTest.promiseFocus(window);
+ // (this is a control, w/o autocomplete=off, to ensure the login
+ // that was being suppressed would have been filled in otherwise)
+ checkFormValues(form, "singleuser", "singlepass");
+});
+
+add_task(async function honor_username_autocomplete_off_without_password() {
+ const form = createLoginForm({
+ action: "https://autocomplete",
+ username: {
+ id: "username",
+ autocomplete: "off"
+ },
+ password: false
+ });
+ await promiseFormsProcessedInSameProcess();
+ await SimpleTest.promiseFocus(window);
+ // With the pref toggled off, and with autocomplete=off on the username field
+ // in a username-only form, we expect to have autofilled this form
+ is(form.uname.value, "singleuser", `Checking form6 username is: singleuser`);
+});
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for html5 input types (email, tel, url, etc.)</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: html5 input types (email, tel, url, etc.)
+<script>
+runChecksAfterCommonInit(() => startTest());
+
+const DEFAULT_ORIGIN = window.location.origin;
+
+runInParent(async function setup() {
+ const login1 = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ const login2 = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ const login3 = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ const login4 = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ const login5 = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+
+ login1.init("http://mochi.test:8888", "http://bug600551-1", null,
+ "testuser@example.com", "testpass1", "", "");
+ login2.init("http://mochi.test:8888", "http://bug600551-2", null,
+ "555-555-5555", "testpass2", "", "");
+ login3.init("http://mochi.test:8888", "http://bug600551-3", null,
+ "http://mozilla.org", "testpass3", "", "");
+ login4.init("http://mochi.test:8888", "http://bug600551-4", null,
+ "123456789", "testpass4", "", "");
+ login5.init("http://mochi.test:8888", "http://bug600551-5", null,
+ "test", "test", "", "");
+
+ await Services.logins.addLogins([
+ login1,
+ login2,
+ login3,
+ login4,
+ login5,
+ ]);
+});
+</script>
+
+<p id="display"></p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/* Test for Login Manager: 600551
+ (Password manager not working with input type=email)
+ */
+async function startTest() {
+ const win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <form id="form1" action="http://bug600551-1">
+ <input type="email" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form2" action="http://bug600551-2">
+ <input type="tel" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form3" action="http://bug600551-3">
+ <input type="url" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form4" action="http://bug600551-4">
+ <input type="number" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form5" action="http://bug600551-5">
+ <input type="search" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <!-- The following forms should not be filled with usernames -->
+ <form id="form6" action="formtest.js">
+ <input type="datetime" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form7" action="formtest.js">
+ <input type="date" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form8" action="formtest.js">
+ <input type="month" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form9" action="formtest.js">
+ <input type="week" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form10" action="formtest.js">
+ <input type="time" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form11" action="formtest.js">
+ <input type="datetime-local" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form12" action="formtest.js">
+ <input type="range" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+ <form id="form13" action="formtest.js">
+ <input type="color" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>`, win, 13);
+
+ await checkLoginFormInFrameWithElementValues(win, 1, "testuser@example.com", "testpass1");
+ await checkLoginFormInFrameWithElementValues(win, 2, "555-555-5555", "testpass2");
+ await checkLoginFormInFrameWithElementValues(win, 3, "http://mozilla.org", "testpass3");
+ await checkLoginFormInFrameWithElementValues(win, 4, "123456789", "testpass4");
+ await checkLoginFormInFrameWithElementValues(win, 5, "test", "test");
+
+ info("type=datetime should not be considered a username");
+ await checkLoginFormInFrameWithElementValues(win, 6, "");
+ info("type=date should not be considered a username");
+ await checkLoginFormInFrameWithElementValues(win, 7, "");
+ info("type=month should not be considered a username");
+ await checkLoginFormInFrameWithElementValues(win, 8, "");
+ info("type=week should not be considered a username");
+ await checkLoginFormInFrameWithElementValues(win, 9, "");
+ info("type=time should not be considered a username");
+ await checkLoginFormInFrameWithElementValues(win, 10, "");
+ info("type=datetime-local should not be considered a username");
+ await checkLoginFormInFrameWithElementValues(win, 11, "");
+ info("type=range should not be considered a username");
+ await checkLoginFormInFrameWithElementValues(win, 12, "50");
+ info("type=color should not be considered a username");
+ await checkLoginFormInFrameWithElementValues(win, 13, "#000000");
+
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=355063
+-->
+<head>
+ <meta charset="utf-8"/>
+ <title>Test for Bug 355063</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="application/javascript">
+ /** Test for Bug 355063 **/
+ gTestDependsOnDeprecatedLogin = true;
+ runChecksAfterCommonInit(async function startTest() {
+ info("startTest");
+ // Password Manager's own listener should always have been added first, so
+ // the test's listener should be called after the pwmgr's listener fills in
+ // a login.
+ //
+ SpecialPowers.addChromeEventListener("DOMFormHasPassword", function eventFired() {
+ SpecialPowers.removeChromeEventListener("DOMFormHasPassword", eventFired);
+ var passField = $("p1");
+ passField.addEventListener("input", checkForm);
+ });
+ await setFormAndWaitForFieldFilled("<form id=form1>form1: <input id=u1><input type=password id=p1></form><br>",
+ {fieldSelector: "#u1", fieldValue: "testuser"});
+ });
+
+ function checkForm() {
+ info("checkForm");
+ var userField = document.getElementById("u1");
+ var passField = document.getElementById("p1");
+ is(userField.value, "testuser", "checking filled username");
+ is(passField.value, "testpass", "checking filled password");
+
+ SimpleTest.finish();
+ }
+</script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=355063">Mozilla Bug 355063</a>
+<p id="display"></p>
+<div id="content">
+forms go here!
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
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..ec9557d2c3
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_basic_form_pwonly.html
@@ -0,0 +1,193 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test forms and logins without a username</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: forms and logins without a username.
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ ["http://mochi.test:8888", "http://mochi.test:8888", null, "testuser", "testpass", "uname", "pword"],
+ ["http://mochi.test:8888", "http://mochi.test:1111", null, "", "1234"],
+ ["http://mochi.test:8888", "http://mochi.test:8888", null, "", "1234"]
+ );
+});
+
+/** Test for Login Manager: password-only logins **/
+add_task(async function startTest() {
+ const win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(location.origin, `
+ <!-- simple form: no username field, 1 password field -->
+ <form id='form1' action='http://mochi.test:1111/formtest.js'> 1
+ <input type='password' name='pname' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- simple form: no username field, 2 password fields -->
+ <form id='form2' action='http://mochi.test:1111/formtest.js'> 2
+ <input type='password' name='pname1' value=''>
+ <input type='password' name='pname2' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- simple form: no username field, 3 password fields -->
+ <form id='form3' action='http://mochi.test:1111/formtest.js'> 3
+ <input type='password' name='pname1' value=''>
+ <input type='password' name='pname2' value=''>
+ <input type='password' name='pname3' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- simple form: no username field, 5 password fields -->
+ <form id='form4' action='http://mochi.test:1111/formtest.js'> 4
+ <input type='password' name='pname1' value=''>
+ <input type='password' name='pname2' value=''>
+ <input type='password' name='pname3' value=''>
+ <input type='password' name='pname4' value=''>
+ <input type='password' name='pname5' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 6 password fields, should be ignored. -->
+ <form id='form5' action='http://mochi.test:1111/formtest.js'> 5
+ <input type='password' name='pname1' value=''>
+ <input type='password' name='pname2' value=''>
+ <input type='password' name='pname3' value=''>
+ <input type='password' name='pname4' value=''>
+ <input type='password' name='pname5' value=''>
+ <input type='password' name='pname6' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 1 username field -->
+ <form id='form6' action='http://mochi.test:1111/formtest.js'> 6
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 1 username field, with a value set -->
+ <form id='form7' action='http://mochi.test:1111/formtest.js'> 7
+ <input type='text' name='uname' value='someuser'>
+ <input type='password' name='pname' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!--
+ (The following forms have 2 potentially-matching logins, on is
+ password-only, the other is username+password)
+ -->
+
+ <!-- 1 username field, with value set. Fill in the matching U+P login -->
+ <form id='form8' action='formtest.js'> 8
+ <input type='text' name='uname' value='testuser'>
+ <input type='password' name='pname' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 1 username field, with value set. Don't fill in U+P login-->
+ <form id='form9' action='formtest.js'> 9
+ <input type='text' name='uname' value='someuser'>
+ <input type='password' name='pname' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 1 username field, too small for U+P login -->
+ <form id='form10' action='formtest.js'> 10
+ <input type='text' name='uname' value='' maxlength="4">
+ <input type='password' name='pname' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 1 username field, too small for U+P login -->
+ <form id='form11' action='formtest.js'> 11
+ <input type='text' name='uname' value='' maxlength="0">
+ <input type='password' name='pname' value=''>
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 1 username field, too small for U+P login -->
+ <form id='form12' action='formtest.js'> 12
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value='' maxlength="4">
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 1 username field, too small for either login -->
+ <form id='form13' action='formtest.js'> 13
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value='' maxlength="1">
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>
+
+ <!-- 1 username field, too small for either login -->
+ <form id='form14' action='formtest.js'> 14
+ <input type='text' name='uname' value=''>
+ <input type='password' name='pname' value='' maxlength="0">
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>`, win, 14);
+
+ await checkLoginFormInFrameWithElementValues(win, 1, "1234");
+ await checkLoginFormInFrameWithElementValues(win, 2, "1234", "");
+ await checkLoginFormInFrameWithElementValues(win, 3, "1234", "", "");
+ await checkLoginFormInFrameWithElementValues(win, 4, "1234");
+ await checkUnmodifiedFormInFrame(win, 5);
+
+ await checkLoginFormInFrameWithElementValues(win, 6, "testuser", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, 7, "someuser", "");
+
+ await checkLoginFormInFrameWithElementValues(win, 8, "testuser", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, 9, "someuser", "");
+
+ await checkLoginFormInFrameWithElementValues(win, 10, "", "1234");
+ await checkLoginFormInFrameWithElementValues(win, 11, "", "1234");
+ await checkLoginFormInFrameWithElementValues(win, 12, "", "1234");
+
+ await checkUnmodifiedFormInFrame(win, 13);
+ await checkUnmodifiedFormInFrame(win, 14);
+});
+</script>
+</pre>
+</body>
+</html>
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..429ec2269c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_bug_627616.html
@@ -0,0 +1,163 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test bug 627616 related to proxy authentication</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script class="testbody" type="text/javascript">
+ SimpleTest.waitForExplicitFinish();
+
+ var Ci = SpecialPowers.Ci;
+
+ function makeXHR(expectedStatus, expectedText, extra) {
+ var xhr = new XMLHttpRequest();
+ xhr.open("GET", "authenticate.sjs?" +
+ "proxy_user=proxy_user&" +
+ "proxy_pass=proxy_pass&" +
+ "proxy_realm=proxy_realm&" +
+ "user=user1name&" +
+ "pass=user1pass&" +
+ "realm=mochirealm&" +
+ extra || "");
+ xhr.onloadend = function() {
+ is(xhr.status, expectedStatus, "xhr.status");
+ is(xhr.statusText, expectedText, "xhr.statusText");
+ runNextTest();
+ };
+ return xhr;
+ }
+
+ function testNonAnonymousCredentials() {
+ var xhr = makeXHR(200, "OK");
+ xhr.send();
+ }
+
+ function testAnonymousCredentials() {
+ // Test that an anonymous request correctly performs proxy authentication
+ var xhr = makeXHR(401, "Authentication required");
+ SpecialPowers.wrap(xhr).channel.loadFlags |= Ci.nsIChannel.LOAD_ANONYMOUS;
+ xhr.send();
+ }
+
+ function testAnonymousNoAuth() {
+ // Next, test that an anonymous request still does not include any non-proxy
+ // authentication headers.
+ var xhr = makeXHR(200, "Authorization header not found", "anonymous=1");
+ SpecialPowers.wrap(xhr).channel.loadFlags |= Ci.nsIChannel.LOAD_ANONYMOUS;
+ xhr.send();
+ }
+
+ var gExpectedDialogs = 0;
+ var gCurrentTest;
+ function runNextTest() {
+ is(gExpectedDialogs, 0, "received expected number of auth dialogs");
+ mm.sendAsyncMessage("prepareForNextTest");
+ mm.addMessageListener("prepareForNextTestDone", function prepared(msg) {
+ mm.removeMessageListener("prepareForNextTestDone", prepared);
+ if (pendingTests.length) {
+ ({expectedDialogs: gExpectedDialogs,
+ test: gCurrentTest} = pendingTests.shift());
+ gCurrentTest.call(this);
+ } else {
+ mm.sendAsyncMessage("cleanup");
+ mm.addMessageListener("cleanupDone", () => {
+ // mm.destroy() is called as a cleanup function by runInParent(), no
+ // need to do it here.
+ SimpleTest.finish();
+ });
+ }
+ });
+ }
+
+ var pendingTests = [{expectedDialogs: 2, test: testNonAnonymousCredentials},
+ {expectedDialogs: 1, test: testAnonymousCredentials},
+ {expectedDialogs: 0, test: testAnonymousNoAuth}];
+
+ const mm = runInParent(() => {
+ const { classes: parentCc, interfaces: parentCi } = Components;
+
+ const {NetUtil} = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+ );
+
+ const channel = NetUtil.newChannel({
+ uri: "http://example.com",
+ loadUsingSystemPrincipal: true,
+ });
+
+ const pps = parentCc["@mozilla.org/network/protocol-proxy-service;1"].
+ getService(parentCi.nsIProtocolProxyService);
+ pps.asyncResolve(channel, 0, {
+ async onProxyAvailable(req, uri, pi, status) {
+ const mozproxy = "moz-proxy://" + pi.host + ":" + pi.port;
+ const login1 = parentCc["@mozilla.org/login-manager/loginInfo;1"].
+ createInstance(parentCi.nsILoginInfo);
+ login1.init(mozproxy, null, "proxy_realm", "proxy_user", "proxy_pass",
+ "", "");
+
+ const login2 = parentCc["@mozilla.org/login-manager/loginInfo;1"].
+ createInstance(parentCi.nsILoginInfo);
+ login2.init("http://mochi.test:8888", null, "mochirealm", "user1name",
+ "user1pass", "", "");
+ await Services.logins.addLogins([login1, login2]);
+
+ sendAsyncMessage("setupDone");
+ },
+ QueryInterface: ChromeUtils.generateQI([parentCi.nsIProtocolProxyCallback]),
+ });
+
+ addMessageListener("prepareForNextTest", message => {
+ parentCc["@mozilla.org/network/http-auth-manager;1"].
+ getService(parentCi.nsIHttpAuthManager).
+ clearAll();
+ sendAsyncMessage("prepareForNextTestDone");
+ });
+
+ const modalType = Services.prefs.getIntPref(
+ "prompts.modalType.httpAuth"
+ );
+ const authPromptIsCommonDialog =
+ modalType === Services.prompt.MODAL_TYPE_WINDOW
+ || (modalType === Services.prompt.MODAL_TYPE_TAB
+ && Services.prefs.getBoolPref(
+ "prompts.tabChromePromptSubDialog",
+ false
+ ));
+
+ const dialogObserverTopic = authPromptIsCommonDialog
+ ? "common-dialog-loaded" : "tabmodal-dialog-loaded";
+
+ function dialogObserver(subj, topic, data) {
+ if (authPromptIsCommonDialog) {
+ subj.Dialog.ui.prompt.document
+ .getElementById("commonDialog")
+ .acceptDialog();
+ } else {
+ const prompt = subj.ownerGlobal.gBrowser.selectedBrowser
+ .tabModalPromptBox.getPrompt(subj);
+ prompt.Dialog.ui.button0.click(); // Accept button
+ }
+ sendAsyncMessage("promptAccepted");
+ }
+
+ Services.obs.addObserver(dialogObserver, dialogObserverTopic);
+
+ addMessageListener("cleanup", message => {
+ Services.obs.removeObserver(dialogObserver, dialogObserverTopic);
+ sendAsyncMessage("cleanupDone");
+ });
+ });
+
+ mm.addMessageListener("promptAccepted", msg => {
+ gExpectedDialogs--;
+ });
+ mm.addMessageListener("setupDone", msg => {
+ runNextTest();
+ });
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=776171
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 776171 related to HTTP auth</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body onload="startTest()">
+<script class="testbody" type="text/javascript">
+
+/**
+ * This test checks we correctly ignore authentication entry
+ * for a subpath and use creds from the URL when provided when XHR
+ * is used with filled user name and password.
+ *
+ * 1. connect auth2/authenticate.sjs that expects user1:pass1 password
+ * 2. connect a dummy URL at the same path
+ * 3. connect authenticate.sjs that again expects user1:pass1 password
+ * in this case, however, we have an entry without an identity
+ * for this path (that is a parent for auth2 path in the first step)
+ */
+
+SimpleTest.waitForExplicitFinish();
+
+function doxhr(URL, user, pass, next) {
+ var xhr = new XMLHttpRequest();
+ if (user && pass) {
+ xhr.open("POST", URL, true, user, pass);
+ } else {
+ xhr.open("POST", URL, true);
+ }
+ xhr.onload = function() {
+ is(xhr.status, 200, "Got status 200");
+ next();
+ };
+ xhr.onerror = function() {
+ ok(false, "request passed");
+ SimpleTest.finish();
+ };
+ xhr.send();
+}
+
+function startTest() {
+ doxhr("auth2/authenticate.sjs?user=user1&pass=pass1&realm=realm1", "user1", "pass1", function() {
+ doxhr("auth2", null, null, function() {
+ doxhr("authenticate.sjs?user=user1&pass=pass1&realm=realm1", "user1", "pass1", SimpleTest.finish);
+ });
+ });
+}
+</script>
+</body>
+</html>
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..8a8499b584
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_case_differences.html
@@ -0,0 +1,100 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autocomplete due to multiple matching logins</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: autocomplete due to multiple matching logins
+<p id="display"></p>
+<!-- we presumably can't hide the content for this test. -->
+<div id="content"></div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/** Test for Login Manager: autocomplete due to multiple matching logins **/
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [location.origin, "https://autocomplete:8888", null, "name", "pass", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "Name", "Pass", "uname", "pword"],
+ [location.origin, "https://autocomplete:8888", null, "USER", "PASS", "uname", "pword"]
+ );
+})
+
+add_task(async function test_empty_first_entry() {
+ const form = createLoginForm({
+ action: "https://autocomplete:8888"
+ });
+
+ // Make sure initial form is empty.
+ checkLoginForm(form.uname, "", form.pword, "");
+
+ // Trigger autocomplete popup
+ form.uname.focus();
+
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+ const { items } = await openPopupOn(form.uname);
+ popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected");
+ checkAutoCompleteResults(items, ["name", "Name", "USER"], "example.com", "initial");
+
+ // Check first entry
+ const index0Promise = notifySelectedIndex(0);
+ synthesizeKey("KEY_ArrowDown");
+ await index0Promise;
+ checkLoginForm(form.uname, "", form.pword, ""); // value shouldn't update
+ synthesizeKey("KEY_Enter");
+ await promiseFormsProcessedInSameProcess();
+ checkLoginForm(form.uname, "name", form.pword, "pass");
+});
+
+add_task(async function test_empty_second_entry() {
+ const form = createLoginForm({
+ action: "https://autocomplete:8888"
+ });
+
+ await openPopupOn(form.uname);
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_Enter");
+ await promiseFormsProcessedInSameProcess();
+ checkLoginForm(form.uname, "Name", form.pword, "Pass");
+});
+
+add_task(async function test_empty_third_entry() {
+ const form = createLoginForm({
+ action: "https://autocomplete:8888"
+ });
+
+ await openPopupOn(form.uname);
+ synthesizeKey("KEY_ArrowDown"); // first
+ synthesizeKey("KEY_ArrowDown"); // second
+ synthesizeKey("KEY_ArrowDown"); // third
+ synthesizeKey("KEY_Enter");
+ await promiseFormsProcessedInSameProcess();
+ checkLoginForm(form.uname, "USER", form.pword, "PASS");
+});
+
+add_task(async function test_preserve_matching_username_case() {
+ const form = createLoginForm({
+ action: "https://autocomplete:8888"
+ });
+ await promiseFormsProcessedInSameProcess();
+
+ await openPopupOn(form.uname, { inputValue: "user" });
+ // Check that we don't clobber user-entered text when tabbing away
+ // (even with no autocomplete entry selected)
+ synthesizeKey("KEY_Tab");
+ await promiseFormsProcessedInSameProcess();
+ checkLoginForm(form.uname, "user", form.pword, "PASS");
+});
+</script>
+</pre>
+</body>
+</html>
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..87638b1132
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_dismissed_doorhanger_in_shadow_DOM.html
@@ -0,0 +1,108 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test the password manager dismissed doorhanger can detect username and password fields in a Shadow DOM.</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<iframe></iframe>
+
+<script type="application/javascript">
+add_setup(async () => {
+ const readyPromise = registerRunTests();
+ info("Waiting for setup and page load");
+ await readyPromise;
+
+ // assert that there are no logins
+ const allLogins = await LoginManager.getAllLogins();
+ is(allLogins.length, 0, "There are no logins");
+});
+
+const IFRAME = document.querySelector("iframe");
+const PASSWORD_VALUE = "!@$*";
+const TESTCASES = [
+ // Check that the Shadow DOM version of form_basic.html works
+ {
+ name: "test_form_each_field_in_its_own_shadow_root",
+ filename: "form_basic_shadow_DOM_each_field_in_its_own_shadow_root.html",
+ },
+ // Check that the Shadow DOM version of formless_basic.html works
+ {
+ name: "test_formless_each_field_in_its_own_shadow_root",
+ filename: "formless_basic_shadow_DOM_each_field_in_its_own_shadow_root.html",
+ },
+ // Check that the nested Shadow DOM version of form_basic.html works
+ {
+ name: "test_form_nested_each_field_in_its_own_shadow_root",
+ filename: "form_nested_shadow_DOM_each_field_in_its_own_shadow_root.html",
+ }
+];
+
+async function editPasswordFieldInShadowDOM() {
+ info("Editing the input field in the form with a Shadow DOM");
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [PASSWORD_VALUE], function(val) {
+ const doc = this.content.document;
+ // Grab the wrapper element to get the shadow root containing the password field
+ let wrapper = doc.getElementById("wrapper-password");
+ if (!wrapper) {
+ // This is a nested Shadow DOM test case
+ const outerWrapper = doc.getElementById("outer-wrapper-password");
+ const outerShadowRoot = outerWrapper.openOrClosedShadowRoot;
+ wrapper = outerShadowRoot.querySelector("#wrapper-password");
+ }
+ // If the ShadowRoot's mode is "closed", it can only be accessed from a chrome-privileged
+ // (Bug 1421568) or addon context (Bug 1439153)
+ const shadowRoot = wrapper.openOrClosedShadowRoot;
+ const passwordField = shadowRoot.querySelector("[name='password']");
+ Assert.equal(passwordField.value, "", "Check password didn't get autofilled");
+ passwordField.setUserInput(val);
+ Assert.equal(passwordField.value, val, "Checking for filled password");
+ }
+ );
+}
+
+async function testForm(testcase) {
+ const iframeLoaded = new Promise(resolve => {
+ IFRAME.addEventListener(
+ "load",
+ function(e) {
+ resolve(true);
+ },
+ { once: true }
+ );
+ });
+
+ // This could complete before the page finishes loading.
+ const formsProcessed = promiseFormsProcessed();
+
+ IFRAME.src = testcase.filename;
+ info("Waiting for test page to load in the iframe");
+ await iframeLoaded;
+
+ info("Waiting for 'input' event listener to be added to the form before editing");
+ await formsProcessed;
+
+ const passwordEditProcessed = getPasswordEditedMessage();
+
+ await editPasswordFieldInShadowDOM();
+
+ info("Waiting for parent process to receive input field edit message from content");
+ await passwordEditProcessed;
+}
+
+for (let testcase of TESTCASES) {
+ const taskName = testcase.name;
+ const tmp = {
+ async [taskName]() {
+ await testForm(testcase);
+ }
+ }
+ add_task(tmp[taskName]);
+}
+</script>
+</body>
+</html>
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..06458893ea
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formLike_rootElement_with_Shadow_DOM.html
@@ -0,0 +1,151 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test that FormLike.rootElement points to right element when the page has Shadow DOM</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<iframe></iframe>
+
+<script type="application/javascript">
+const { LoginFormFactory } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/LoginFormFactory.sys.mjs"
+);
+
+add_setup(async () => {
+ const readyPromise = registerRunTests();
+ info("Waiting for setup and page load");
+ await readyPromise;
+
+ // assert that there are no logins
+ const allLogins = await LoginManager.getAllLogins();
+ is(allLogins.length, 0, "There are no logins");
+});
+
+const IFRAME = document.querySelector("iframe");
+const TESTCASES = [
+ // Check that the Shadow DOM version of form_basic.html works
+ {
+ name: "test_form_each_field_in_its_own_shadow_root",
+ filename: "form_basic_shadow_DOM_each_field_in_its_own_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#wrapper-password", "form"]],
+ },
+ {
+ name: "test_form_both_fields_together_in_a_shadow_root",
+ filename: "form_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#wrapper-un-and-pw", "form"]],
+ },
+ {
+ name: "test_form_form_and_fields_together_in_a_shadow_root",
+ filename: "form_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#wrapper", "form"]],
+ },
+ // Check that the Shadow DOM version of formless_basic.html works
+ {
+ name: "test_formless_each_field_in_its_own_shadow_root",
+ filename: "formless_basic_shadow_DOM_each_field_in_its_own_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#wrapper-password", "html"]],
+ },
+ {
+ name: "test_formless_both_fields_together_in_a_shadow_root",
+ filename: "formless_basic_shadow_DOM_both_fields_together_in_a_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#wrapper-un-and-pw", "html"]],
+ },
+ {
+ name: "test_formless_form_and_fields_together_in_a_shadow_root.html",
+ filename: "formless_basic_shadow_DOM_form_and_fields_together_in_a_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#wrapper", "html"]],
+ },
+ // Check that the nested Shadow DOM version of form_basic.html works
+ {
+ name: "test_form_nested_each_field_in_its_own_shadow_root",
+ filename: "form_nested_shadow_DOM_each_field_in_its_own_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#wrapper-password", "form"]],
+ outerHostElementSelector: "span#outer-wrapper-password",
+ },
+ {
+ name: "test_form_nested_both_fields_together_in_a_shadow_root",
+ filename: "form_nested_shadow_DOM_both_fields_together_in_a_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#inner-wrapper", "form"]],
+ outerHostElementSelector: "span#outer-wrapper",
+ },
+ {
+ name: "test_form_nested_form_and_fields_together_in_a_shadow_root",
+ filename: "form_nested_shadow_DOM_form_and_fields_together_in_a_shadow_root.html",
+ hostAndRootElementSelectorTuples: [["span#inner-wrapper", "form"]],
+ outerHostElementSelector: "span#outer-wrapper",
+ },
+ {
+ name: "test_multiple_forms_shadow_DOM_all_known_variants",
+ filename: "multiple_forms_shadow_DOM_all_known_variants.html",
+ hostAndRootElementSelectorTuples: [
+ ["span#outer-wrapper", "html"],
+ ["span#wrapper-password-form-case-1", "form#form-case-1"],
+ ["span#wrapper-form-case-2", "form#form-case-2"],
+ ["span#wrapper-form-case-3", "form#form-case-3"],
+ ],
+ outerHostElementSelector: "span#outer-wrapper",
+ }
+];
+
+async function testForm(testcase) {
+ const iframeLoaded = new Promise(resolve => {
+ IFRAME.addEventListener(
+ "load",
+ function(e) {
+ resolve(true);
+ },
+ { once: true }
+ );
+ });
+
+ // This could complete before the page finishes loading.
+ const numForms = testcase.hostAndRootElementSelectorTuples.length;
+ const formsProcessed = promiseFormsProcessedInSameProcess(numForms);
+
+ IFRAME.src = testcase.filename;
+ info("Waiting for test page to load in the iframe");
+ await iframeLoaded;
+
+ info(`Wait for ${numForms} form(s) to be processed.`);
+ await formsProcessed;
+
+ const iframeDoc = SpecialPowers.wrap(IFRAME.contentWindow).document;
+ for (let [hostElementSelector, rootElementSelector] of testcase.hostAndRootElementSelectorTuples) {
+ info("Get the expected rootElement from the document");
+ let hostElement = iframeDoc.querySelector(hostElementSelector);
+ let outerShadowRoot = null;
+ if (!hostElement) {
+ // Nested Shadow DOM testcase
+ const outerHostElement = iframeDoc.querySelector(testcase.outerHostElementSelector);
+ outerShadowRoot = outerHostElement.openOrClosedShadowRoot;
+ hostElement = outerShadowRoot.querySelector(hostElementSelector);
+ }
+ const shadowRoot = hostElement.openOrClosedShadowRoot;
+ let expectedRootElement = iframeDoc.querySelector(rootElementSelector);
+ if (!expectedRootElement) {
+ // The form itself is inside a ShadowRoot and/or there is a ShadowRoot in between the field and form
+ expectedRootElement =
+ shadowRoot.querySelector(rootElementSelector) ||
+ outerShadowRoot.querySelector(rootElementSelector);
+ }
+ ok(LoginFormFactory.getRootElementsWeakSetForDocument(iframeDoc).has(expectedRootElement), "Ensure formLike.rootElement has the expected value");
+ }
+}
+
+for (let testcase of TESTCASES) {
+ const taskName = testcase.name;
+ const tmp = {
+ async [taskName]() {
+ await testForm(testcase);
+ }
+ }
+ add_task(tmp[taskName]);
+}
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for considering form action</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: Bug 360493
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(() => startTest());
+
+let DEFAULT_ORIGIN = window.location.origin;
+</script>
+<p id="display"></p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: 360493 (Cross-Site Forms + Password
+ Manager = Security Failure) **/
+
+// This test is designed to make sure variations on the form's |action|
+// and |method| continue to work with the fix for 360493.
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <!-- normal form with normal relative action. -->
+ <form id="form1" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- fully specify the action URL -->
+ <form id="form2" action="http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- fully specify the action URL, and change the path -->
+ <form id="form3" action="http://mochi.test:8888/zomg/wtf/bbq/passwordmgr/test/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- fully specify the action URL, and change the path and filename -->
+ <form id="form4" action="http://mochi.test:8888/zomg/wtf/bbq/passwordmgr/test/not_a_test.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- specify the action URL relative to the current document-->
+ <form id="form5" action="./formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- specify the action URL relative to the current server -->
+ <form id="form6" action="/tests/toolkit/components/passwordmgr/test/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Change the method from get to post -->
+ <form id="form7" action="formtest.js" method="POST">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Blank action URL specified -->
+ <form id="form8" action="">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- |action| attribute entirely missing -->
+ <form id="form9" >
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- action url as javascript -->
+ <form id="form10" action="javascript:alert('this form is not submitted so this alert should not be invoked');">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>`, win, 10);
+
+ // TODO: action=IP.ADDRESS instead of HOSTNAME?
+ // TODO: test with |base href="http://othersite//"| ?
+
+ for (var i = 1; i <= 9; i++) {
+ // Check form i
+ await checkLoginFormInFrameWithElementValues(win, i, "testuser", "testpass");
+ }
+
+ // The login's formActionOrigin isn't "javascript:", so don't fill it in.
+ await checkLoginFormInFrameWithElementValues(win, 10, "", "");
+
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for considering form action</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: Bug 360493
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(() => startTest());
+
+let DEFAULT_ORIGIN = window.location.origin;
+</script>
+<p id="display"></p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: 360493 (Cross-Site Forms + Password Manager = Security Failure) **/
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <!-- The tests in this page exercise things that shouldn't work. -->
+
+ <!-- Change port # of action URL from 8888 to 7777 -->
+ <form id="form1" action="http://localhost:7777/tests/toolkit/components/passwordmgr/test/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- No port # in action URL -->
+ <form id="form2" action="http://localhost/tests/toolkit/components/passwordmgr/test/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Change protocol from http:// to ftp://, include the expected 8888 port # -->
+ <form id="form3" action="ftp://localhost:8888/tests/toolkit/components/passwordmgr/test/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Change protocol from http:// to ftp://, no port # specified -->
+ <form id="form4" action="ftp://localhost/tests/toolkit/components/passwordmgr/test/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Try a weird URL. -->
+ <form id="form5" action="about:blank">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Try a weird URL. (If the normal embedded action URL doesn't work, that should mean other URLs won't either) -->
+ <form id="form6" action="view-source:http://localhost:8888/tests/toolkit/components/passwordmgr/test/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Try a weird URL. -->
+ <form id="form7" action="view-source:formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Action URL points to a different host (this is the archetypical exploit) -->
+ <form id="form8" action="http://www.cnn.com/">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Action URL points to a different host, user field prefilled -->
+ <form id="form9" action="http://www.cnn.com/">
+ <input type="text" name="uname" value="testuser">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- Try wrapping a evil form around a good form, to see if we can confuse the parser. -->
+ <form id="form10-A" action="http://www.cnn.com/">
+ <form id="form10-B" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit (inner)</button>
+ <button type="reset"> Reset (inner)</button>
+ </form>
+ <button type="submit" id="neutered_submit10">Submit (outer)</button>
+ <button type="reset">Reset (outer)</button>
+ </form>
+
+ <!-- Try wrapping a good form around an evil form, to see if we can confuse the parser. -->
+ <form id="form11-A" action="formtest.js">
+ <form id="form11-B" action="http://www.cnn.com/">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit (inner)</button>
+ <button type="reset"> Reset (inner)</button>
+ </form>
+ <button type="submit" id="neutered_submit11">Submit (outer)</button>
+ <button type="reset">Reset (outer)</button>
+ </form>`, win, 11);
+
+ // TODO: probably should have some accounts which have no port # in the action url.
+ // JS too. And different host/proto.
+ // TODO: www.site.com vs. site.com?
+ // TODO: foo.site.com vs. bar.site.com?
+
+ for (var i = 1; i <= 8; i++) {
+ // Check form i
+ await checkLoginFormInFrameWithElementValues(win, i, "", "");
+ }
+
+ await checkLoginFormInFrameWithElementValues(win, 9, "testuser", "");
+
+ await checkLoginFormInFrameWithElementValues(win, "10-A", "", "");
+
+ // The DOM indicates this form could be filled, as the evil inner form
+ // is discarded. And yet pwmgr seems not to fill it. Not sure why.
+ todo(false, "Mangled form combo not being filled when maybe it could be?");
+ await checkLoginFormInFrameWithElementValues(win, "11-A", "testuser", "testpass");
+
+ // Verify this by making sure there are no extra forms in the document, and
+ // that the submit button for the neutered forms don't do anything.
+ // If the test finds extra forms the submit() causes the test to timeout, then
+ // there may be a security issue.
+ await SpecialPowers.spawn(win, [], async function submitForms() {
+ is(this.content.document.forms.length, 11, "Checking for unexpected forms");
+ this.content.document.getElementById("neutered_submit10").click();
+ this.content.document.getElementById("neutered_submit11").click();
+ });
+
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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..717805f06a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_form_action_javascript.html
@@ -0,0 +1,44 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test forms with a JS submit action</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: form with JS submit action
+<script>
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [location.origin, "javascript:", null, "jsuser", "jspass123", "uname", "pword"]
+ );
+});
+
+/** Test for Login Manager: JS action URL **/
+add_task(async function startTest() {
+ const win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(location.origin, `
+ <form id='form1' action='javascript:alert("never shows")'> 1
+ <input name="uname">
+ <input name="pword" type="password">
+
+ <button type='submit'>Submit</button>
+ <button type='reset'> Reset </button>
+ </form>`, win);
+
+ await checkLoginFormInFrameWithElementValues(win, 1, "jsuser", "jspass123");
+});
+</script>
+
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
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..4d1b7582a9
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_autofill.html
@@ -0,0 +1,140 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofilling of fields outside of a form</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script type="application/javascript">
+gTestDependsOnDeprecatedLogin = true;
+let doneSetupPromise = new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.getElementById("loginFrame").addEventListener("load", async evt => {
+ // Tell the parent to setup test logins.
+ await runChecksAfterCommonInit();
+ resolve();
+ });
+ });
+});
+
+add_setup(async () => {
+ info("Waiting for loads and setup");
+ await doneSetupPromise;
+
+ await loadRecipes({
+ siteRecipes: [{
+ hosts: ["mochi.test:8888"],
+ usernameSelector: "input[name='recipeuname']",
+ passwordSelector: "input[name='recipepword']",
+ }],
+ });
+});
+
+
+const DEFAULT_ORIGIN = "http://mochi.test:8888";
+const TESTCASES = [
+ {
+ // Inputs
+ document: `<input type=password>`,
+
+ // Expected outputs
+ expectedInputValues: ["testpass"],
+ },
+ {
+ document: `<input>
+ <input type=password>`,
+ expectedInputValues: ["testuser", "testpass"],
+ },
+ {
+ document: `<input>
+ <input type=password>
+ <input type=password>`,
+ expectedInputValues: ["testuser", "testpass", ""],
+ },
+ {
+ document: `<input>
+ <input type=password>
+ <input type=password>
+ <input type=password>`,
+ expectedInputValues: ["testuser", "testpass", "", ""],
+ },
+ {
+ document: `<input>
+ <input type=password form="form1">
+ <input type=password>
+ <form id="form1">
+ <input>
+ <input type=password>
+ </form>`,
+ expectedFormCount: 2,
+ expectedInputValues: ["testuser", "testpass", "testpass", "", ""],
+ },
+ {
+ document: `<!-- formless password field selector recipe test -->
+ <input>
+ <input type=password>
+ <input>
+ <input type=password name="recipepword">`,
+ expectedInputValues: ["", "", "testuser", "testpass"],
+ },
+ {
+ document: `<!-- formless username and password field selector recipe test -->
+ <input name="recipeuname">
+ <input>
+ <input type=password>
+ <input type=password name="recipepword">`,
+ expectedInputValues: ["testuser", "", "", "testpass"],
+ },
+ {
+ document: `<!-- form and formless recipe field selector test -->
+ <input name="recipeuname">
+ <input>
+ <input type=password form="form1"> <!-- not filled since recipe affects both FormLikes -->
+ <input type=password>
+ <input type=password name="recipepword">
+ <form id="form1">
+ <input>
+ <input type=password>
+ </form>`,
+ expectedFormCount: 2,
+ expectedInputValues: ["testuser", "", "", "", "testpass", "", ""],
+ },
+];
+
+add_task(async function test() {
+ let loginFrame = document.getElementById("loginFrame");
+ let frameDoc = loginFrame.contentWindow.document;
+
+ for (let tc of TESTCASES) {
+ info("Starting testcase: " + JSON.stringify(tc));
+
+ let numFormLikesExpected = tc.expectedFormCount || 1;
+
+ let processedFormPromise = promiseFormsProcessedInSameProcess(numFormLikesExpected);
+
+ frameDoc.documentElement.innerHTML = tc.document;
+ info("waiting for " + numFormLikesExpected + " processed form(s)");
+ await processedFormPromise;
+
+ let testInputs = frameDoc.documentElement.querySelectorAll("input");
+ is(testInputs.length, tc.expectedInputValues.length, "Check number of inputs");
+ for (let i = 0; i < tc.expectedInputValues.length; i++) {
+ let expectedValue = tc.expectedInputValues[i];
+ is(testInputs[i].value, expectedValue, `Check expected input value ${i} : ${expectedValue}`);
+ }
+ }
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="loginFrame" src="http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/blank.html"></iframe>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
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..4e98fd1b9a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit.html
@@ -0,0 +1,242 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test capturing of fields outside of a form</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script type="application/javascript">
+const { LoginFormFactory } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/LoginFormFactory.sys.mjs"
+);
+const { LoginManagerChild } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/LoginManagerChild.sys.mjs"
+);
+
+function loadFrame() {
+ return new Promise(resolve => {
+ document.getElementById("loginFrame").addEventListener("load", (evt) => {
+ if (evt.target.contentWindow.location.href.includes("blank.html")) {
+ resolve();
+ }
+ });
+ });
+}
+
+let loadPromise = new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", () => {
+ resolve(loadFrame());
+ });
+});
+
+add_setup(async () => {
+ info("Waiting for page and frame loads");
+ await loadPromise;
+
+ await loadRecipes({
+ siteRecipes: [{
+ hosts: ["mochi.test:8888"],
+ usernameSelector: "input[name='recipeuname']",
+ passwordSelector: "input[name='recipepword']",
+ }],
+ });
+});
+
+const DEFAULT_ORIGIN = "http://mochi.test:8888";
+const TESTCASES = [
+ {
+ // Inputs
+ document: `<input type=password value="">`,
+ selectorValues: {
+ "[type=password]": "pass1",
+ },
+ inputIndexForFormLike: 0,
+ expectedFormsCount: 1,
+
+ // Expected outputs similar to PasswordManager:onFormSubmit
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: null,
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<input id="u1" value="">
+ <input type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "[type=password]": "pass1",
+ },
+ inputIndexForFormLike: 0,
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<input id="u1" value="">
+ <input type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "[type=password]": "pass1",
+ },
+ inputIndexForFormLike: 1,
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input id="p2" type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "pass1",
+ "#p2": "pass2",
+ },
+ inputIndexForFormLike: 2,
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: "pass1",
+ },
+ {
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input id="p2" type=password value="">
+ <input id="p3" type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "pass1",
+ "#p2": "pass2",
+ "#p3": "pass2",
+ },
+ inputIndexForFormLike: 3,
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: "pass1",
+ },
+ {
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="" form="form1">
+ <input id="p2" type=password value="">
+ <form id="form1">
+ <input id="u2" value="">
+ <input id="p3" type=password value="">
+ </form>`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "user2",
+ "#p2": "pass1",
+ "#u2": "user3",
+ "#p3": "pass2",
+ },
+ inputIndexForFormLike: 2,
+ expectedFormsCount: 2,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<!-- recipe field override -->
+ <input name="recipeuname" value="">
+ <input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input name="recipepword" type=password value="">`,
+ selectorValues: {
+ "[name='recipeuname']": "username from recipe",
+ "#u1": "default field username",
+ "#p1": "pass1",
+ "[name='recipepword']": "pass2",
+ },
+ inputIndexForFormLike: 2,
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "username from recipe",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: null,
+ },
+];
+
+let count = 0;
+async function testFormlessSubmit(tc) {
+ let loginFrame = document.getElementById("loginFrame");
+
+ let frameDoc = SpecialPowers.wrap(loginFrame.contentWindow).document;
+ info("Starting testcase: " + JSON.stringify(tc));
+
+ let formsProcessed = promiseFormsProcessedInSameProcess(tc.expectedFormsCount);
+ frameDoc.documentElement.innerHTML = tc.document;
+ await formsProcessed;
+ // We eliminate no user input as a reason for not capturing by modifying the value
+ setUserInputValues(frameDoc.documentElement, tc.selectorValues);
+
+ let inputForFormLike = frameDoc.querySelectorAll("input")[tc.inputIndexForFormLike];
+
+ let formLike = LoginFormFactory.createFromField(inputForFormLike);
+
+ info("Calling _onFormSubmit with FormLike");
+ let submitProcessed = getSubmitMessage();
+ LoginManagerChild.forWindow(frameDoc.defaultView)._onFormSubmit(formLike);
+
+ let { origin, data } = await submitProcessed;
+
+ // Check data sent via PasswordManager:onFormSubmit
+ is(origin, tc.origin, "Check origin");
+ is(data.formActionOrigin, tc.formActionOrigin, "Check formActionOrigin");
+
+ if (tc.usernameFieldValue === null) {
+ is(data.usernameField, tc.usernameFieldValue, "Check usernameField");
+ } else {
+ is(data.usernameField.value, tc.usernameFieldValue, "Check usernameField");
+ }
+
+ is(data.newPasswordField.value, tc.newPasswordFieldValue, "Check newPasswordFieldValue");
+
+ if (tc.oldPasswordFieldValue === null) {
+ is(data.oldPasswordField, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue");
+ } else {
+ is(data.oldPasswordField.value, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue");
+ }
+
+ loadPromise = loadFrame();
+ loginFrame.contentWindow.location =
+ "http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html?" + count++;
+ await loadPromise;
+}
+
+for (let tc of TESTCASES) {
+ let taskName = "testcase-" + count++;
+ let tmp = {
+ async [taskName]() {
+ await testFormlessSubmit(tc);
+ },
+ };
+ add_task(tmp[taskName]);
+}
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="loginFrame" src="http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"></iframe>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
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..5512c57db2
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal.html
@@ -0,0 +1,287 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test capturing of fields due to form removal</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script type="application/javascript">
+let loadPromise = new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.getElementById("loginFrame").addEventListener("load", (evt) => {
+ resolve();
+ });
+ });
+});
+
+add_setup(async () => {
+ info("Waiting for page and frame loads");
+ await loadPromise;
+
+ await loadRecipes({
+ siteRecipes: [{
+ hosts: ["test1.mochi.test:8888"],
+ usernameSelector: "input[name='recipeuname']",
+ passwordSelector: "input[name='recipepword']",
+ }],
+ });
+});
+
+const DEFAULT_ORIGIN = "http://test1.mochi.test:8888";
+const SCRIPTS = {
+ // Test removing the form(or form-less password field), their parent node, and the top-level node.
+ REMOVE_FORM: `let e = document.querySelector("form"); e.parentNode.removeChild(e);`,
+ REMOVE_PASSWORD: `let e = document.querySelector("[type=password]"); e.parentNode.removeChild(e);`,
+ REMOVE_FORM_PARENT: `let e = document.querySelector("form").parentNode; e.parentNode.removeChild(e);`,
+ REMOVE_PASSWORD_PARENT: `let e = document.querySelector("[type=password]").parentNode; e.parentNode.removeChild(e);`,
+ REMOVE_TOP: `let e = document.querySelector("html"); e.parentNode.removeChild(e);`,
+
+ // Add testcases related to page navigation here to ensure these cases still work
+ // when we have set up form removal observer.
+ PUSHSTATE: `history.pushState({}, "Pushed state", "?pushed");`,
+ WINDOW_LOCATION: `window.location = "data:text/html;charset=utf-8,window.location";`,
+};
+const TESTCASES = [
+ {
+ // Inputs
+ document: `<input type=password value="">`,
+ selectorValues: {
+ "[type=password]": "pass1",
+ },
+ expectedFormsCount: 1,
+
+ // Expected outputs similar to PasswordManager:onFormSubmit
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: null,
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<input id="u1" value="">
+ <input type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "[type=password]": "pass1",
+ },
+
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input id="p2" type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "pass1",
+ "#p2": "pass2",
+ },
+
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: "pass1",
+ },
+ {
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input id="p2" type=password value="">
+ <input id="p3" type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "pass1",
+ "#p2": "pass2",
+ "#p3": "pass2",
+ },
+
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: "pass1",
+ },
+ {
+ // Since there are two FormLikes to auto-submit in this case we mark
+ // one FormLike's password fields with a magic "ignore-form-submission"
+ // value so we can just focus on the other form. We then repeat the testcase
+ // below with the other FormLike ignored.
+ document: `<input id="u1" value="">
+ <input type=password id="p1" value="" form="form1">
+ <input type=password id="p2" value="">
+ <form id="form1">
+ <input id="u2" value="">
+ <input id="p3" type=password value="">
+ </form>`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "ignore-form-submission",
+ "#p2": "pass1",
+ "#u2": "user3",
+ "#p3": "ignore-form-submission",
+ },
+ expectedFormsCount: 2,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ removePassword: true,
+ },
+ { // Same as above but with the other form ignored.
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="" form="form1">
+ <input id="p2" type=password value="">
+ <form id="form1">
+ <input id="u2" value="">
+ <input id="p3" type=password value="">
+ </form>`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "pass2",
+ "#p2": "ignore-form-submission",
+ "#u2": "user3",
+ "#p3": "pass2",
+ },
+
+ expectedFormsCount: 2,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: null,
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: null,
+ },
+ /*
+ XXX - Bug 1698498 :
+ This test case fails because when we call querySelector in LoginRecipes.jsm
+ after the form is removed, querySelector can't find the element.
+ {
+ document: `<!-- recipe field override -->
+ <input name="recipeuname" value="">
+ <input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input name="recipepword" type=password value="">`,
+ selectorValues: {
+ "[name='recipeuname']": "username from recipe",
+ "#u1": "default field username",
+ "#p1": "pass1",
+ "[name='recipepword']": "pass2",
+ },
+
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "username from recipe",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: null,
+ },*/
+];
+
+function filterFormSubmissions({ origin, data }) {
+ return data.newPasswordField.value != "ignore-form-submission";
+}
+
+async function testFormlesSubmitFormRemoval(tc, testDoc, scriptName) {
+ let loginFrame = document.getElementById("loginFrame");
+ let loadedPromise = new Promise((resolve) => {
+ loginFrame.addEventListener("load", function() {
+ resolve();
+ }, {once: true});
+ });
+ loginFrame.src = DEFAULT_ORIGIN + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html";
+ await loadedPromise;
+
+ let frameDoc = SpecialPowers.wrap(loginFrame.contentWindow).document;
+
+ let formsProcessed = promiseFormsProcessed(tc.expectedFormsCount);
+ frameDoc.documentElement.innerHTML = testDoc;
+ await formsProcessed;
+ // We eliminate no user input as a reason for not capturing by modifying the value
+ setUserInputValues(frameDoc.documentElement, tc.selectorValues)
+
+ await SpecialPowers.spawn(frameDoc.defaultView, [], async () => {
+ await content.fetch("http://test1.mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html");
+ });
+
+ let submitProcessed = getSubmitMessage(filterFormSubmissions);
+ info("Running " + scriptName + " script to cause a submission");
+ frameDoc.defaultView.eval(SCRIPTS[scriptName]);
+
+ info("Waiting for formSubmissionProcsssed message");
+ let { origin, data } = await submitProcessed;
+ info("Got for formSubmissionProcsssed message");
+
+ // Check data sent via PasswordManager:onFormSubmit
+ is(origin, tc.origin, "Check origin");
+ is(data.formActionOrigin, tc.formActionOrigin, "Check formActionOrigin");
+
+ if (tc.usernameFieldValue === null) {
+ is(data.usernameField, tc.usernameFieldValue, "Check usernameField");
+ } else {
+ is(data.usernameField.value, tc.usernameFieldValue, "Check usernameField");
+ }
+
+ is(data.newPasswordField.value, tc.newPasswordFieldValue, "Check newPasswordFieldValue");
+
+ if (tc.oldPasswordFieldValue === null) {
+ is(data.oldPasswordField, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue");
+ } else {
+ is(data.oldPasswordField.value, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue");
+ }
+};
+
+let count = 0;
+for (let tc of TESTCASES) {
+ for (let scriptName of Object.keys(SCRIPTS)) {
+ for (let surroundDocumentWithForm of [false, true]) {
+ let testDoc = tc.document;
+ if (surroundDocumentWithForm) {
+ if (testDoc.includes("<form")) {
+ info("Skipping surroundDocumentWithForm case since document already contains a <form>");
+ continue;
+ }
+ testDoc = "<form>" + testDoc + "</form>";
+ }
+
+ if (["REMOVE_FORM", "REMOVE_FORM_PARENT"].includes(scriptName) &&
+ (!testDoc.includes("<form") || tc.removePassword)) {
+ continue;
+ } else if (["REMOVE_PASSWORD","REMOVE_PASSWORD_PARENT"].includes(scriptName) &&
+ testDoc.includes("<form")) {
+ continue;
+ }
+
+ let taskName = `testcase-${count}-${scriptName}${surroundDocumentWithForm ? '-formWrapped' : ''}`;
+ let tmp = {
+ async [taskName]() {
+ info("Starting testcase with script " + scriptName + " and " +
+ (surroundDocumentWithForm ? "a" : "no") + " form wrapper: " + JSON.stringify(tc));
+ await testFormlesSubmitFormRemoval(tc, testDoc, scriptName);
+ },
+ };
+ add_task(tmp[taskName]);
+ }
+ }
+ count++;
+}
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="loginFrame" src="http://test1.mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"></iframe>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
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..dfd7670a12
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_form_removal_negative.html
@@ -0,0 +1,204 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test no capturing of fields outside of a form due to navigation</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script type="application/javascript">
+SimpleTest.requestFlakyTimeout("Testing that a message doesn't arrive");
+
+let loadPromise = new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.getElementById("loginFrame").addEventListener("load", (evt) => {
+ resolve();
+ });
+ });
+});
+
+function submissionProcessed(...args) {
+ ok(false, "No formSubmissionProcessed should occur in this test");
+ info("got: " + JSON.stringify(args));
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["signon.formRemovalCapture.enabled", true],
+ ],
+ });
+
+ info("Waiting for page and frame loads");
+ await loadPromise;
+
+ PWMGR_COMMON_PARENT.addMessageListener("formSubmissionProcessed", submissionProcessed);
+
+ SimpleTest.registerCleanupFunction(() => {
+ PWMGR_COMMON_PARENT.removeMessageListener("formSubmissionProcessed", submissionProcessed);
+ });
+});
+
+const DEFAULT_ORIGIN = "http://test1.mochi.test:8888";
+const SCRIPTS = {
+ // Test form submission is not triggered when there is no user interaction and
+ // no ajax request fired previously.
+ REMOVE_TOP: `let e = document.querySelector("html"); e.parentNode.removeChild(e);`,
+
+ // Test the following scripts don't trigger form submissions because of the
+ // form removal heuristics
+ PUSHSTATE: `history.pushState({}, "Pushed state", "?pushed");`,
+ WINDOW_LOCATION: `window.location = "data:text/html;charset=utf-8,window.location";`,
+ WINDOW_LOCATION_RELOAD: `window.location.reload();`,
+ HISTORY_BACK: `history.back();`,
+ HISTORY_GO_MINUS1: `history.go(-1);`,
+};
+
+const HEURISTICS = [
+ {
+ userInput: true,
+ ajaxSuccess: true,
+ },
+ {
+ userInput: false,
+ ajaxSuccess: true,
+ },
+ {
+ userInput: true,
+ ajaxSuccess: false,
+ },
+ {
+ userInput: false,
+ ajaxSuccess: false,
+ },
+];
+
+const TESTCASES = [
+ // Begin test cases that shouldn't trigger capture.
+ {
+ // Empty password field in a form
+ document: `<form><input type=password value="xxx"></form>`,
+ selectorValues: {
+ "[type=password]": "",
+ },
+ },
+ {
+ // Empty password field
+ document: `<input type=password value="">`,
+ selectorValues: {
+ "[type=password]": "",
+ },
+ },
+ {
+ // Test with an input that would normally be captured but with SCRIPTS that
+ // shouldn't trigger capture.
+ document: `<input type=password value="">`,
+ selectorValues: {
+ "[type=password]": "pass2",
+ },
+ wouldCapture: true,
+ },
+ {
+ // Test with an input that would normally be captured but with SCRIPTS that
+ // shouldn't trigger capture.
+ document: `<form><input type=password value=""></form>`,
+ selectorValues: {
+ "[type=password]": "pass2",
+ },
+ wouldCapture: true,
+ },
+];
+
+async function testFormlesSubmitNavigationNegative(tc, scriptName, heuristic) {
+ let loginFrame = document.getElementById("loginFrame");
+ let waitTime;
+ let android = navigator.appVersion.includes("Android");
+ if (android) {
+ // intermittent failures on Android Debug at 5 seconds
+ waitTime = 10000;
+ } else {
+ waitTime = 5000;
+ }
+
+ let loadedPromise = new Promise((resolve) => {
+ loginFrame.addEventListener("load", function() {
+ resolve();
+ }, {once: true});
+ });
+
+ loginFrame.src = DEFAULT_ORIGIN + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html";
+ await loadedPromise;
+
+ let frameDoc = SpecialPowers.wrap(loginFrame.contentWindow).document;
+ let formsProcessed = promiseFormsProcessed();
+ frameDoc.documentElement.innerHTML = tc.document;
+ await formsProcessed;
+
+ // We eliminate no user input as a reason for not capturing by modifying the value
+ setUserInputValues(frameDoc.documentElement, tc.selectorValues, heuristic.userInput);
+
+ if (heuristic.ajaxSuccess) {
+ await SpecialPowers.spawn(frameDoc.defaultView, [], async () => {
+ await content.fetch("http://test1.mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html");
+ });
+ }
+
+ info("Running " + scriptName + " script to check for a submission");
+ frameDoc.defaultView.eval(SCRIPTS[scriptName]);
+
+ info("Running " + scriptName + " script to check for a submission 1");
+ // Wait to see if the promise above resolves.
+ await new Promise(resolve => setTimeout(resolve, waitTime));
+ info("Running " + scriptName + " script to check for a submission 2");
+ ok(true, "Done waiting for captures");
+
+}
+
+let count = 0;
+for (let tc of TESTCASES) {
+ for (let scriptName of Object.keys(SCRIPTS)) {
+ for (let heuristic of HEURISTICS) {
+ let shouldCaptureAFormRemoval = heuristic.ajaxSuccess && heuristic.userInput;
+ // Only run the following scripts when we are going to observeform removal change
+ // to save some time running this whole test.
+ if (["PUSHSTATE", "WINDOW_LOCATION", "WINDOW_LOCATION_RELOAD", "HISTORY_BACK", "HISTORY_GO_MINUS1"].includes(scriptName)) {
+ if(!shouldCaptureAFormRemoval) {
+ continue;
+ }
+
+ if (tc.wouldCapture && ["PUSHSTATE", "WINDOW_LOCATION"].includes(scriptName)) {
+ // Don't run scripts that should actually capture for this testcase.
+ continue;
+ }
+ } else if (["REMOVE_TOP"].includes(scriptName)) {
+ if (shouldCaptureAFormRemoval) {
+ // Don't run scripts that should actually capture for this testcase.
+ continue;
+ }
+ }
+
+ let taskName = `testcase-${count}-${scriptName}`;
+ let tmp = {
+ async [taskName]() {
+ info("Starting testcase with script " + scriptName + ": " + JSON.stringify(tc) + ": " + JSON.stringify(heuristic));
+ await testFormlesSubmitNavigationNegative(tc, scriptName, heuristic);
+ },
+ };
+ add_task(tmp[taskName]);
+ }
+ }
+}
+
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="loginFrame" src="http://test1.mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"></iframe>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
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..348669a85c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation.html
@@ -0,0 +1,267 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test capturing of fields outside of a form due to navigation</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script type="application/javascript">
+let loadPromise = new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.getElementById("loginFrame").addEventListener("load", (evt) => {
+ resolve();
+ });
+ });
+});
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["signon.formlessCapture.enabled", true],
+ ],
+ });
+
+ info("Waiting for page and frame loads");
+ await loadPromise;
+
+ await loadRecipes({
+ siteRecipes: [{
+ hosts: ["test1.mochi.test:8888"],
+ usernameSelector: "input[name='recipeuname']",
+ passwordSelector: "input[name='recipepword']",
+ }],
+ });
+});
+
+const DEFAULT_ORIGIN = "http://test1.mochi.test:8888";
+const SCRIPTS = {
+ PUSHSTATE: `history.pushState({}, "Pushed state", "?pushed");`,
+ WINDOW_LOCATION: `window.location = "data:text/html;charset=utf-8,window.location";`,
+};
+const TESTCASES = [
+ {
+ // Inputs
+ document: `<input type=password value="">`,
+ selectorValues: {
+ "[type=password]": "pass1",
+ },
+ expectedFormsCount: 1,
+
+ // Expected outputs similar to PasswordManager:onFormSubmit
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: null,
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<input id="u1" value="">
+ <input type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "[type=password]": "pass1",
+ },
+
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input id="p2" type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "pass1",
+ "#p2": "pass2",
+ },
+
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: "pass1",
+ },
+ {
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input id="p2" type=password value="">
+ <input id="p3" type=password value="">`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "pass1",
+ "#p2": "pass2",
+ "#p3": "pass2",
+ },
+
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: "pass1",
+ },
+ {
+ // Since there are two FormLikes to auto-submit in this case we mark
+ // one FormLike's password fields with a magic "ignore-form-submission"
+ // value so we can just focus on the other form. We then repeat the testcase
+ // below with the other FormLike ignored.
+ document: `<input id="u1" value="">
+ <input type=password id="p1" value="" form="form1">
+ <input type=password id="p2" value="">
+ <form id="form1">
+ <input id="u2" value="">
+ <input id="p3" type=password value="">
+ </form>`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "ignore-form-submission",
+ "#p2": "pass1",
+ "#u2": "user3",
+ "#p3": "ignore-form-submission",
+ },
+ expectedFormsCount: 2,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "user1",
+ newPasswordFieldValue: "pass1",
+ oldPasswordFieldValue: null,
+ },
+ { // Same as above but with the other form ignored.
+ document: `<input id="u1" value="">
+ <input id="p1" type=password value="" form="form1">
+ <input id="p2" type=password value="">
+ <form id="form1">
+ <input id="u2" value="">
+ <input id="p3" type=password value="">
+ </form>`,
+ selectorValues: {
+ "#u1": "user1",
+ "#p1": "pass2",
+ "#p2": "ignore-form-submission",
+ "#u2": "user3",
+ "#p3": "pass2",
+ },
+
+ expectedFormsCount: 2,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: null,
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: null,
+ },
+ {
+ document: `<!-- recipe field override -->
+ <input name="recipeuname" value="">
+ <input id="u1" value="">
+ <input id="p1" type=password value="">
+ <input name="recipepword" type=password value="">`,
+ selectorValues: {
+ "[name='recipeuname']": "username from recipe",
+ "#u1": "default field username",
+ "#p1": "pass1",
+ "[name='recipepword']": "pass2",
+ },
+
+ expectedFormsCount: 1,
+ origin: DEFAULT_ORIGIN,
+ formActionOrigin: DEFAULT_ORIGIN,
+ usernameFieldValue: "username from recipe",
+ newPasswordFieldValue: "pass2",
+ oldPasswordFieldValue: null,
+ },
+];
+
+function filterFormSubmissions({ origin, data }) {
+ return data.newPasswordField.value != "ignore-form-submission";
+}
+
+async function testFormlesSubmitNavigation(tc, testDoc, scriptName) {
+
+ let loginFrame = document.getElementById("loginFrame");
+ let loadedPromise = new Promise((resolve) => {
+ loginFrame.addEventListener("load", function() {
+ resolve();
+ }, {once: true});
+ });
+ loginFrame.src = DEFAULT_ORIGIN + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html";
+ await loadedPromise;
+
+ let frameDoc = SpecialPowers.wrap(loginFrame.contentWindow).document;
+
+ let formsProcessed = promiseFormsProcessed(tc.expectedFormsCount);
+ frameDoc.documentElement.innerHTML = testDoc;
+ await formsProcessed;
+ // We eliminate no user input as a reason for not capturing by modifying the value
+ setUserInputValues(frameDoc.documentElement, tc.selectorValues)
+
+ let submitProcessed = getSubmitMessage(filterFormSubmissions);
+ info("Running " + scriptName + " script to cause a submission");
+ frameDoc.defaultView.eval(SCRIPTS[scriptName]);
+
+ info("Waiting for formSubmissionProcsssed message");
+ let { origin, data } = await submitProcessed;
+ info("Got for formSubmissionProcsssed message");
+
+ // Check data sent via PasswordManager:onFormSubmit
+ is(origin, tc.origin, "Check origin");
+ is(data.formActionOrigin, tc.formActionOrigin, "Check formActionOrigin");
+
+ if (tc.usernameFieldValue === null) {
+ is(data.usernameField, tc.usernameFieldValue, "Check usernameField");
+ } else {
+ is(data.usernameField.value, tc.usernameFieldValue, "Check usernameField");
+ }
+
+ is(data.newPasswordField.value, tc.newPasswordFieldValue, "Check newPasswordFieldValue");
+
+ if (tc.oldPasswordFieldValue === null) {
+ is(data.oldPasswordField, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue");
+ } else {
+ is(data.oldPasswordField.value, tc.oldPasswordFieldValue, "Check oldPasswordFieldValue");
+ }
+};
+
+let count = 0;
+for (let tc of TESTCASES) {
+ for (let scriptName of Object.keys(SCRIPTS)) {
+ for (let surroundDocumentWithForm of [false, true]) {
+ let testDoc = tc.document;
+ if (surroundDocumentWithForm) {
+ if (testDoc.includes("<form")) {
+ info("Skipping surroundDocumentWithForm case since document already contains a <form>");
+ continue;
+ }
+ testDoc = "<form>" + testDoc + "</form>";
+ }
+ let taskName = `testcase-${count}-${scriptName}${surroundDocumentWithForm ? '-formWrapped' : ''}`;
+ let tmp = {
+ async [taskName]() {
+ info("Starting testcase with script " + scriptName + " and " +
+ (surroundDocumentWithForm ? "a" : "no") + " form wrapper: " + JSON.stringify(tc));
+ await testFormlesSubmitNavigation(tc, testDoc, scriptName);
+ },
+ };
+ add_task(tmp[taskName]);
+ }
+ }
+ count++;
+}
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="loginFrame" src="http://test1.mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"></iframe>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
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..338cd5d2c1
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_formless_submit_navigation_negative.html
@@ -0,0 +1,148 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test no capturing of fields outside of a form due to navigation</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script type="application/javascript">
+SimpleTest.requestFlakyTimeout("Testing that a message doesn't arrive");
+
+let loadPromise = new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.getElementById("loginFrame").addEventListener("load", (evt) => {
+ resolve();
+ });
+ });
+});
+
+function submissionProcessed(...args) {
+ ok(false, "No formSubmissionProcessed should occur in this test");
+ info("got: " + JSON.stringify(args));
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["signon.formlessCapture.enabled", true],
+ ],
+ });
+
+ info("Waiting for page and frame loads");
+ await loadPromise;
+
+ PWMGR_COMMON_PARENT.addMessageListener("formSubmissionProcessed", submissionProcessed);
+
+ SimpleTest.registerCleanupFunction(() => {
+ PWMGR_COMMON_PARENT.removeMessageListener("formSubmissionProcessed", submissionProcessed);
+ });
+});
+
+const DEFAULT_ORIGIN = "http://test1.mochi.test:8888";
+const SCRIPTS = {
+ PUSHSTATE: `history.pushState({}, "Pushed state", "?pushed");`,
+ WINDOW_LOCATION: `window.location = "data:text/html;charset=utf-8,window.location";`,
+ WINDOW_LOCATION_RELOAD: `window.location.reload();`,
+ HISTORY_BACK: `history.back();`,
+ HISTORY_GO_MINUS1: `history.go(-1);`,
+};
+const TESTCASES = [
+ // Begin test cases that shouldn't trigger capture.
+ {
+ // Empty password field in a form
+ document: `<form><input type=password value="xxx"></form>`,
+ selectorValues: {
+ "[type=password]": "",
+ },
+ },
+ {
+ // Empty password field
+ document: `<input type=password value="">`,
+ selectorValues: {
+ "[type=password]": "",
+ },
+ },
+ {
+ // Test with an input that would normally be captured but with SCRIPTS that
+ // shouldn't trigger capture.
+ document: `<input type=password value="">`,
+ selectorValues: {
+ "[type=password]": "pass2",
+ },
+ wouldCapture: true,
+ },
+ {
+ // Test with an input that would normally be captured but with SCRIPTS that
+ // shouldn't trigger capture.
+ document: `<form><input type=password value=""></form>`,
+ selectorValues: {
+ "[type=password]": "pass2",
+ },
+ wouldCapture: true,
+ },
+];
+
+async function testFormlesSubmitNavigationNegative(tc, scriptName) {
+ let loginFrame = document.getElementById("loginFrame");
+ let waitTime;
+ let android = navigator.appVersion.includes("Android");
+ if (android) {
+ // intermittent failures on Android Debug at 5 seconds
+ waitTime = 10000;
+ } else {
+ waitTime = 5000;
+ }
+
+ let loadedPromise = new Promise((resolve) => {
+ loginFrame.addEventListener("load", function() {
+ resolve();
+ }, {once: true});
+ });
+ loginFrame.src = DEFAULT_ORIGIN + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html";
+ await loadedPromise;
+
+ let frameDoc = SpecialPowers.wrap(loginFrame.contentWindow).document;
+ frameDoc.documentElement.innerHTML = tc.document;
+ // We eliminate no user input as a reason for not capturing by modifying the value
+ setUserInputValues(frameDoc.documentElement, tc.selectorValues);
+
+ info("Running " + scriptName + " script to check for a submission");
+ frameDoc.defaultView.eval(SCRIPTS[scriptName]);
+
+ // Wait to see if the promise above resolves.
+ await new Promise(resolve => setTimeout(resolve, waitTime));
+ ok(true, "Done waiting for captures");
+}
+
+let count = 0;
+for (let tc of TESTCASES) {
+ for (let scriptName of Object.keys(SCRIPTS)) {
+ if (tc.wouldCapture && ["PUSHSTATE", "WINDOW_LOCATION"].includes(scriptName)) {
+ // Don't run scripts that should actually capture for this testcase.
+ continue;
+ }
+ let taskName = `testcase-${count}-${scriptName}`;
+ let tmp = {
+ async [taskName]() {
+ info("Starting testcase with script " + scriptName + ": " + JSON.stringify(tc));
+ await testFormlesSubmitNavigationNegative(tc, scriptName);
+ },
+ };
+ add_task(tmp[taskName]);
+ }
+}
+
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="loginFrame" src="http://test1.mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"></iframe>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_include_other_subdomains_in_lookup.html b/toolkit/components/passwordmgr/test/mochitest/test_include_other_subdomains_in_lookup.html
new file mode 100644
index 0000000000..4c569463c5
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_include_other_subdomains_in_lookup.html
@@ -0,0 +1,202 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test includeOtherSubdomainsInLookup</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+ Test that logins with non-exact match origin appear in autocomplete dropdown.
+
+ The includeOtherSubdomainsInLookup feature offers autocomplete results for
+ subdomain related logins.
+
+ For this testcase, there exists two logins for this origin on different
+ subdomains (old.example.com and new.example.com) but with different
+ passwords, beneath one login for the parent domain (example.com). Both
+ logins should appear in the autocomplete popup.
+
+<template id="form1-template">
+ <form id="form1" action="https://otherexample.com/formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ </form>
+</template>
+
+<script class="testbody" type="text/javascript">
+ const formTemplate = document.getElementById("form1-template");
+
+ const domainname = location.hostname;
+ const loginInfos = [
+ [`https://${domainname}/`, `https://${domainname}/`, null, "example-user", "example-password", "uname", "pword"],
+ [`https://old.${domainname}/`, `https://old.${domainname}/`, null, "example-user", "example-password", "uname", "pword"],
+ [`https://new.${domainname}/`, `https://new.${domainname}/`, null, "example-user", "new-example-password", "uname", "pword"],
+ ];
+
+ // calculates a date representation as used in the autocomplete menu to
+ // distinguish duplicate logins
+ function getDateString() {
+ const dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined, { dateStyle: "medium" });
+ return dateAndTimeFormatter.format(new Date());
+ }
+
+ add_setup(async () => {
+ listenForUnexpectedPopupShown();
+ });
+
+ add_named_task("not including https login when includeOtherSubdomainsInLookup is false", async () => {
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", false]);
+ await setStoredLoginsDuringTask(
+ [`https://${domainname}/`, `https://${domainname}/`, null, "example-user-https", "example-password", "uname", "pword"],
+ );
+ const form = setContentForTask(formTemplate);
+ form.uname.focus();
+
+ await noPopupByArrowDown();
+ ok(true, "no popup has been opened")
+ });
+
+ add_named_task("including https login when includeOtherSubdomainsInLookup is true", async () => {
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", true]);
+ await setStoredLoginsDuringTask(
+ [`https://${domainname}/`, `https://${domainname}/`, null, "example-user-https", "example-password", "uname", "pword"],
+ );
+ const form = setContentForTask(formTemplate);
+ form.uname.focus();
+
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems, ["example-user-https"], window.location.host, "https login showed");
+ });
+
+ add_named_task("including http login when includeOtherSubdomainsInLookup is false", async () => {
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", false]);
+ await setStoredLoginsDuringTask(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ [`http://${domainname}/`, `https://${domainname}/`, null, "example-user-http", "example-password", "uname", "pword"],
+ );
+ const form = setContentForTask(formTemplate);
+ form.uname.focus();
+
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems, ["example-user-http"], window.location.host, "http login showed");
+ });
+
+ add_named_task("including http login when includeOtherSubdomainsInLookup is true", async () => {
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", true]);
+ await setStoredLoginsDuringTask(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ [`http://${domainname}/`, `https://${domainname}/`, null, "example-user-http", "example-password", "uname", "pword"],
+ );
+ const form = setContentForTask(formTemplate);
+ form.uname.focus();
+
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems, ["example-user-http"], window.location.host, "http login showed");
+ });
+
+ add_named_task("including https and http login when includeOtherSubdomainsInLookup is false", async () => {
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", false]);
+ await setStoredLoginsDuringTask(
+ [`https://${domainname}/`, `https://${domainname}/`, null, "example-user-https", "example-password", "uname", "pword"],
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ [`http://${domainname}/`, `https://${domainname}/`, null, "example-user-http", "example-password", "uname", "pword"],
+ );
+ const form = setContentForTask(formTemplate);
+ form.uname.focus();
+
+ const autocompleteItems = await popupByArrowDown();
+ // checkAutoCompleteResults(autocompleteItems, ["example-user-https"], window.location.host, "https login showed");
+ checkAutoCompleteResults(autocompleteItems, ["example-user-http"], window.location.host, "https login showed");
+ });
+
+ add_named_task("including https and http login when includeOtherSubdomainsInLookup is true", async () => {
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", true]);
+ await setStoredLoginsDuringTask(
+ [`https://${domainname}/`, `https://${domainname}/`, null, "example-user-https", "example-password", "uname", "pword"],
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ [`http://${domainname}/`, `https://${domainname}/`, null, "example-user-http", "example-password", "uname", "pword"],
+ );
+ const form = setContentForTask(formTemplate);
+ form.uname.focus();
+
+ const autocompleteItems = await popupByArrowDown();
+ // checkAutoCompleteResults(autocompleteItems, ["example-user-https"], window.location.host, "https login showed");
+ checkAutoCompleteResults(autocompleteItems, ["example-user-http", "example-user-https"], window.location.host, "http and https logins showed");
+ });
+
+ add_named_task("menu does not show login for different subdomain when includeOtherSubdomainsInLookup is false", async () => {
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", false]);
+ await setStoredLoginsDuringTask(
+ ...[
+ // These two logins should never be visible on https: versions of
+ // *.example.com since the login is for http: and an https: login exists
+ // for this username.
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ [`http://${domainname}/`, `https://${domainname}/`, null, "example-user", "example-password", "uname", "pword"],
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ [`http://old.${domainname}/`, `https://old.${domainname}/`, null, "example-user", "old-example-password", "uname", "pword"],
+ ...loginInfos,
+ ]
+ );
+ const form = setContentForTask(formTemplate);
+
+ form.uname.focus();
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems, ["example-user"], window.location.host, "Only exact subdomain showed");
+ });
+
+ add_named_task("menu shows two logins with the same username for different subdomain", async () => {
+ await setStoredLoginsDuringTask(...loginInfos);
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", true]);
+ const form = setContentForTask(formTemplate);
+
+ // The logins are added "today" and since they are duplicates, the date that they were last
+ // changed will be appended.
+ const dateString = getDateString();
+ const username = `example-user (${dateString})`;
+
+ form.uname.focus();
+ const autocompleteItems = await popupByArrowDown();
+ checkAutoCompleteResults(autocompleteItems, [username, username], window.location.host, "Check all menuitems are displayed correctly.");
+ });
+
+ add_named_task("login with different subdomain is selectable", async () => {
+ await setPreferencesForTask(["signon.includeOtherSubdomainsInLookup", true]);
+ await setStoredLoginsDuringTask(...loginInfos);
+ const form = setContentForTask(formTemplate);
+
+ // consume initial autofill event
+ await formAutofillResult(form.id);
+
+ form.uname.focus();
+
+ // select first item
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+
+ await formAutofillResult(form.id);
+ is(form.uname.value, "example-user", "correct username is set");
+ is(form.pword.value, "example-password", "password matches first login");
+
+ // select second item
+ await popupByArrowDown();
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+
+ await formAutofillResult(form.id);
+ is(form.uname.value, "example-user", "correct username is set");
+ is(form.pword.value, "new-example-password", "password matches second login");
+ });
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for input events in Login Manager</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: input events should fire.
+<p id="display"></p>
+<div id="content" style="display: none"></div>
+<pre id="test"></pre>
+<script>
+ add_setup(async () => {
+ await setStoredLoginsAsync(
+ [location.origin, location.origin, null, "testuser", "testpass", "uname", "pword"]
+ );
+ })
+
+ add_task(async function username_events() {
+ return new Promise(resolve => {
+ let inputFired = false;
+ const form = createLoginForm();
+ form.uname.oninput = e => {
+ is(e.target.value, "testuser", "Should get 'testuser' as value in input event");
+ inputFired = true;
+ };
+ form.uname.onchange = e => {
+ ok(inputFired, "Should get input event before change event for username field.");
+ is(e.target.value, "testuser", "Should get 'testuser' as value in change event");
+ resolve();
+ };
+ })
+ })
+
+ add_task(async function password_events() {
+ return new Promise(resolve => {
+ let inputFired = false;
+ const form = createLoginForm();
+ form.pword.oninput = e => {
+ is(e.target.value, "testpass", "Should get 'testpass' as value in input event");
+ inputFired = true;
+ };
+ form.pword.onchange = e => {
+ ok(inputFired, "Should get input event before change event for password field.");
+ is(e.target.value, "testpass", "Should get 'testpass' as value in change event");
+ resolve();
+ };
+ })
+ })
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for input events in Login Manager when username/password are filled in already</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body onload="onNewEvent(event)">
+Login Manager test: input events should fire.
+
+<script>
+runChecksAfterCommonInit();
+
+SimpleTest.requestFlakyTimeout("untriaged");
+
+/** Test for Login Manager: form fill when form is already filled, should not get input events. **/
+
+var onloadFired = false;
+
+function onNewEvent(e) {
+ console.error("Got " + e.type + " event.");
+ if (e.type == "load") {
+ onloadFired = true;
+ getFormElementByName(1, "uname").focus();
+ sendKey("Tab");
+ } else {
+ ok(false, "Got an input event for " + e.target.name + " field, which shouldn't happen.");
+ }
+}
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+
+ <form id="form1" action="formtest.js">
+ <p>This is form 1.</p>
+ <input type="text" name="uname" oninput="onNewEvent(event)" value="testuser">
+ <input type="password" name="pword" oninput="onNewEvent(event)" onfocus="setTimeout(function() { SimpleTest.finish() }, 1000);" value="testpass">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+</div>
+<pre id="test"></pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic login, contextual inscure password warning without saved logins</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: contextual inscure password warning without saved logins
+
+<script>
+let chromeScript = runChecksAfterCommonInit();
+</script>
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content">
+
+ <form id="form1" action="http://autocomplete:8888/formtest.js" onsubmit="return false;">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ <button type="submit">Submit</button>
+ </form>
+
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: contextual insecure password warning without saved logins. **/
+
+let uname = getFormElementByName(1, "uname");
+let pword = getFormElementByName(1, "pword");
+
+// Restore the form to the default state.
+function restoreForm() {
+ uname.value = "";
+ pword.value = "";
+ uname.focus();
+}
+
+function spinEventLoop() {
+ return Promise.resolve();
+}
+
+add_setup(async () => {
+ listenForUnexpectedPopupShown();
+});
+
+add_task(async function test_form1_initial_empty() {
+ await SimpleTest.promiseFocus(window);
+
+ // Make sure initial form is empty.
+ checkLoginForm(uname, "", pword, "");
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+});
+
+add_task(async function test_form1_warning_entry() {
+ await SimpleTest.promiseFocus(window);
+ // Trigger autocomplete popup
+ restoreForm();
+ await popupBy();
+
+ let popupState = await getPopupState();
+ is(popupState.open, true, "Check popup is opened");
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+ let acEvents = await getTelemetryEvents({ process: "parent", filterProps: TelemetryFilterPropsAC, clear: true });
+ is(acEvents.length, 1, "One autocomplete event");
+ checkACTelemetryEvent(acEvents[0], uname, {
+ "hadPrevious": "0",
+ "insecureWarning": "1",
+ "loginsFooter": "1"
+ });
+
+ synthesizeKey("KEY_ArrowDown"); // select insecure warning
+ checkLoginForm(uname, "", pword, ""); // value shouldn't update just by selecting
+ synthesizeKey("KEY_Enter");
+ await spinEventLoop(); // let focus happen
+ checkLoginForm(uname, "", pword, "");
+});
+
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for maxlength attributes</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: Bug 391514
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(() => startTest());
+
+let DEFAULT_ORIGIN = window.location.origin;
+</script>
+<p id="display"></p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/* Test for Login Manager: 391514 (Login Manager gets confused with
+ * password/PIN on usaa.com)
+ */
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <!-- normal form. -->
+ <form id="form1" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- limited username -->
+ <form id="form2" action="formtest.js">
+ <input type="text" name="uname" maxlength="4">
+ <input type="password" name="pword">
+ </form>
+
+ <!-- limited password -->
+ <form id="form3" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword" maxlength="4">
+ </form>
+
+ <!-- limited username and password -->
+ <form id="form4" action="formtest.js">
+ <input type="text" name="uname" maxlength="4">
+ <input type="password" name="pword" maxlength="4">
+ </form>
+
+
+ <!-- limited username -->
+ <form id="form5" action="formtest.js">
+ <input type="text" name="uname" maxlength="0">
+ <input type="password" name="pword">
+ </form>
+
+ <!-- limited password -->
+ <form id="form6" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword" maxlength="0">
+ </form>
+
+ <!-- limited username and password -->
+ <form id="form7" action="formtest.js">
+ <input type="text" name="uname" maxlength="0">
+ <input type="password" name="pword" maxlength="0">
+ </form>
+
+
+ <!-- limited, but ok, username -->
+ <form id="form8" action="formtest.js">
+ <input type="text" name="uname" maxlength="999">
+ <input type="password" name="pword">
+ </form>
+
+ <!-- limited, but ok, password -->
+ <form id="form9" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword" maxlength="999">
+ </form>
+
+ <!-- limited, but ok, username and password -->
+ <form id="form10" action="formtest.js">
+ <input type="text" name="uname" maxlength="999">
+ <input type="password" name="pword" maxlength="999">
+ </form>
+
+
+ <!-- limited, but ok, username -->
+ <!-- (note that filled values are exactly 8 characters) -->
+ <form id="form11" action="formtest.js">
+ <input type="text" name="uname" maxlength="8">
+ <input type="password" name="pword">
+ </form>
+
+ <!-- limited, but ok, password -->
+ <!-- (note that filled values are exactly 8 characters) -->
+ <form id="form12" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword" maxlength="8">
+ </form>
+
+ <!-- limited, but ok, username and password -->
+ <!-- (note that filled values are exactly 8 characters) -->
+ <form id="form13" action="formtest.js">
+ <input type="text" name="uname" maxlength="8">
+ <input type="password" name="pword" maxlength="8">
+ </form>`, win, 13);
+
+ var i;
+
+ await checkLoginFormInFrameWithElementValues(win, 1, "testuser", "testpass");
+
+ for (i = 2; i < 8; i++) {
+ await checkLoginFormInFrameWithElementValues(win, i, "", "");
+ }
+
+ for (i = 8; i < 14; i++) {
+ await checkLoginFormInFrameWithElementValues(win, i, "testuser", "testpass");
+ }
+
+ // Note that tests 11-13 are limited to exactly the expected value.
+ // Assert this lest someone change the login we're testing with.
+ await SpecialPowers.spawn(win, [11, 8], (formNum, length) => {
+ let form = this.content.document.getElementById(`form${formNum}`);
+ let field = form.querySelector("[name='uname']");
+ is(field.value.length, length, "asserting test assumption is valid.");
+ });
+
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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..d2fdf91d4a
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_munged_values.html
@@ -0,0 +1,362 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test handling of possibly-manipulated username values</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script type="application/javascript">
+const DEFAULT_ORIGIN = window.location.origin;
+
+function removeAllUserFacingLoginsInParent() {
+ runInParent(function removeAllUserFacingLogins() {
+ Services.logins.removeAllUserFacingLogins();
+ });
+}
+
+async function add2logins() {
+ removeAllUserFacingLoginsInParent();
+ await addLoginsInParent([DEFAULT_ORIGIN, DEFAULT_ORIGIN, null, "real••••user", "pass1", "", ""], [DEFAULT_ORIGIN, DEFAULT_ORIGIN, null, "user2", "pass2", "", ""]);
+}
+
+async function addSingleLogin() {
+ removeAllUserFacingLoginsInParent();
+ await addLoginsInParent([DEFAULT_ORIGIN, DEFAULT_ORIGIN, null, "real••••user", "pass1", "", ""])
+}
+
+/**
+ * For any test including the character "!", generate a version of that test for every known munge
+ * character.
+ **/
+ function generateTestCases(test) {
+ const MUNGE_CHARS = ["*", ".", "•"];
+
+ const nothingToReplace = Object.values(test).every(value => typeof value !== "string" || !value.includes("!"));
+ if (nothingToReplace) {
+ return test;
+ };
+
+ return MUNGE_CHARS.map(char => {
+ const newTest = {};
+ for (const [propName, val] of Object.entries(test)) {
+ if (typeof val === "string") {
+ newTest[propName] = val.replace(/!/g, char);
+ } else {
+ newTest[propName] = val;
+ }
+ };
+ return newTest;
+})};
+
+const loadPromise = new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", () => {
+ resolve();
+ });
+});
+
+add_setup(async () => {
+ await setStoredLoginsAsync();
+ info("Waiting for page and window loads");
+ await loadPromise;
+});
+
+add_task(async function test_new_logins() {
+ const TEST_CASES = [
+ // ! is replaced with characters commonly used for munging
+ {
+ testName: "test_middle!MaskedUsername",
+ username: "so!!!ne",
+ expected: null,
+ },
+ {
+ testName: "test_start!MaskedUsername",
+ username: "!!!eone",
+ expected: null,
+ },
+ {
+ testName: "test_end!MaskedUsername",
+ username: "some!!!",
+ expected: null,
+ },
+ {
+ testName: "test_ok!Username",
+ username: "obelixand!",
+ expected: "obelixand!",
+ },
+ {
+ testName: "test_ok!Username2",
+ username: "!!username!!",
+ expected: "!!username!!",
+ },
+ {
+ // We should only consider a username munged if it repeats of the same character
+ testName: "test_combinedMungeCharacters",
+ username: "*.•*.•*.•*.•*.•*.•",
+ expected: "*.•*.•*.•*.•*.•*.•",
+ },
+].flatMap(generateTestCases);
+ for (const tc of TEST_CASES) {
+ info("Starting testcase: " + JSON.stringify(tc));
+ // Create a new window for each test case, because if we instead try to use
+ // the same window and change the page using window.location, that will trigger
+ // an onLocationChange event, which can trigger unwanted FormSubmit outside of
+ // clicking the submit button in each test document.
+ const win = window.open("about:blank");
+ const html = `
+ <form id="form1" onsubmit="return false;">
+ <input type="text" name="uname" value="${tc.username}">
+ <input type="password" name="pword" value="thepassword">
+ <button type="submit" id="submitBtn">Submit</button>
+ </form>`;
+ await loadFormIntoWindow(DEFAULT_ORIGIN, html, win);
+ await SpecialPowers.spawn(win, [html], function(contentHtml) {
+ const doc = this.content.document;
+ for (const field of doc.querySelectorAll("input")) {
+ const actualValue = field.value;
+ field.value = "";
+ SpecialPowers.wrap(field).setUserInput(actualValue);
+ }
+ });
+ await SpecialPowers.spawn(win, [tc], function(testcase) {
+ const doc = this.content.document;
+ Assert.equal(doc.querySelector("[name='uname']").value, testcase.username, "Checking for filled username");
+ });
+
+ // Check data sent via PasswordManager:onFormSubmit
+ const processedPromise = getSubmitMessage();
+ await SpecialPowers.spawn(win, [], function() {
+ this.content.document.getElementById("submitBtn").click();
+ });
+
+ const { data } = await processedPromise;
+ info("Got submitted result: " + JSON.stringify(data));
+
+ if (tc.expected === null) {
+ is(data.usernameField, tc.expected, "Check usernameField");
+ } else {
+ is(data.usernameField.value, tc.expected, "Check usernameField");
+ }
+
+ win.close();
+ await SimpleTest.promiseFocus(window);
+ }
+});
+
+add_task(async function test_no_save_dialog_when_password_is_fully_munged() {
+ const TEST_CASES = [
+ {
+ testName: "test_passFullyMungedBy!",
+ password: "!!!!!!!!!",
+ shouldShowPrompt: false,
+ },
+ {
+ testName: "test_passStartsMungedBy!",
+ password: "!!!!!!!butThenAPassword",
+ shouldShowPrompt: true,
+ },
+ {
+ testName: "test_passEndsMungedBy!",
+ password: "aRealPasswordAndThen!!!!!!!",
+ shouldShowPrompt: true,
+ },
+ {
+ testName: "test_passMostlyMungedBy!",
+ password: "!!!a!!!!",
+ shouldShowPrompt: true,
+ },
+ {
+ testName: "test_combinedMungedCharacters",
+ password: "*.•*.•*.•*.•",
+ shouldShowPrompt: true,
+ },
+ ].flatMap(generateTestCases);
+
+ for (const tc of TEST_CASES) {
+ info("Starting testcase: " + tc.testName)
+ // Create a new window for each test case, because if we instead try to use
+ // the same window and change the page using window.location, that will trigger
+ // an onLocationChange event, which can trigger unwanted FormSubmit outside of
+ // clicking the submit button in each test document.
+ const win = window.open("about:blank");
+ const html = `
+ <form id="form1" onsubmit="return false;">
+ <input type="text" name="uname" value="username">
+ <input type="password" name="pword" value="${tc.password}">
+ <button type="submit" id="submitBtn">Submit</button>
+ </form>`;
+ await loadFormIntoWindow(DEFAULT_ORIGIN, html, win);
+ await SpecialPowers.spawn(win, [html], function(contentHtml) {
+ const doc = this.content.document;
+ for (const field of doc.querySelectorAll("input")) {
+ const actualValue = field.value;
+ field.value = "";
+ SpecialPowers.wrap(field).setUserInput(actualValue);
+ }
+ });
+ await SpecialPowers.spawn(win, [tc], function(testcase) {
+ const doc = this.content.document;
+ Assert.equal(doc.querySelector("[name='pword']").value, testcase.password, "Checking for filled password");
+ });
+
+ const formSubmitListener = SpecialPowers.spawn(win, [], function() {
+ return new Promise(resolve => {
+ this.content.windowRoot.addEventListener(
+ "PasswordManager:ShowDoorhanger",
+ event => {
+ info(`PasswordManager:ShowDoorhanger called. Event: ${JSON.stringify(event)}`);
+ resolve(event.detail.messageSent);
+ }
+ );
+ });
+ });
+
+ await SpecialPowers.spawn(win, [], function() {
+ this.content.document.getElementById("submitBtn").click();
+ });
+
+ const dialogRequested = await formSubmitListener;
+
+ is(dialogRequested, tc.shouldShowPrompt, "Verify 'show save/update prompt' message sent to parent process");
+
+ win.close();
+ await SimpleTest.promiseFocus(window);
+ }
+});
+
+add_task(async function test_no_autofill_munged_username_matching_password() {
+ // run this test with 2 matching logins from this origin so we don't autofill
+ await add2logins();
+ const allLogins = await LoginManager.getAllLogins();
+ const matchingLogins = Array.prototype.filter.call(allLogins, l => l.origin == DEFAULT_ORIGIN);
+ is(matchingLogins.length, 2, "Expected number of matching logins");
+
+ const bulletLogin = matchingLogins.find(l => l.username == "real••••user");
+ ok(bulletLogin, "Found the real••••user login");
+
+ const timesUsed = bulletLogin.timesUsed;
+ const guid = bulletLogin.guid;
+
+ const win = window.open("about:blank");
+ const html =
+ `<form id="form1" onsubmit="return false;">
+ <input type="text" name="uname" value="">
+ <input type="password" name="pword" value="">
+ <button type="submit" id="submitBtn">Submit</button>
+ </form>`;
+ await loadFormIntoWindow(DEFAULT_ORIGIN, html, win);
+ await SpecialPowers.spawn(win, [html], function(contentHtml) {
+ const doc = this.content.document;
+ for (const field of doc.querySelectorAll("input")) {
+ const actualValue = field.value;
+ field.value = "";
+ SpecialPowers.wrap(field).setUserInput(actualValue);
+ }
+ });
+ await SpecialPowers.spawn(win, [], function() {
+ const doc = this.content.document;
+ Assert.equal(doc.querySelector("[name='uname']").value, "", "Check username didn't get autofilled");
+ SpecialPowers.wrap(doc.querySelector("[name='uname']")).setUserInput("real••••user");
+ SpecialPowers.wrap(doc.querySelector("[name='pword']")).setUserInput("pass1");
+ });
+
+ // we shouldn't get the save password doorhanger...
+ const popupShownPromise = noPopupBy();
+
+ // Check data sent via PasswordManager:onFormSubmit
+ const processedPromise = getSubmitMessage();
+ await SpecialPowers.spawn(win, [], function() {
+ this.content.document.getElementById("submitBtn").click();
+ });
+
+ const { data } = await processedPromise;
+ info("Got submitted result: " + JSON.stringify(data));
+
+ is(data.usernameField, null, "Check usernameField");
+
+ const updatedLogins = await LoginManager.getAllLogins();
+ const updatedLogin = Array.prototype.find.call(updatedLogins, l => l.guid == guid);
+ ok(updatedLogin, "Got the login via guid");
+ is(updatedLogin.timesUsed, timesUsed + 1, "timesUsed was incremented");
+
+ await popupShownPromise;
+
+ win.close();
+ await SimpleTest.promiseFocus(window);
+});
+
+
+add_task(async function test_autofill_munged_username_matching_password() {
+ // only a single matching login so we autofill the username
+ await addSingleLogin();
+
+ const allLogins = await LoginManager.getAllLogins();
+ const matchingLogins = Array.prototype.filter.call(allLogins, l => l.origin == DEFAULT_ORIGIN);
+ is(matchingLogins.length, 1, "Expected number of matching logins");
+
+ info("matched login: " + matchingLogins[0].username);
+ const bulletLogin = matchingLogins.find(l => l.username == "real••••user");
+ ok(bulletLogin, "Found the real••••user login");
+
+ const timesUsed = bulletLogin.timesUsed;
+ const guid = bulletLogin.guid;
+
+ const win = window.open("about:blank");
+ const html =
+ `<form id="form1" onsubmit="return false;">
+ <input type="text" name="uname" value="">
+ <input type="password" name="pword" value="">
+ <button type="submit" id="submitBtn">Submit</button>
+ </form>`;
+ await loadFormIntoWindow(DEFAULT_ORIGIN, html, win);
+ await SpecialPowers.spawn(win, [html], function(contentHtml) {
+ const doc = this.content.document;
+ for (const field of doc.querySelectorAll("input")) {
+ const actualValue = field.value;
+ field.value = "";
+ SpecialPowers.wrap(field).setUserInput(actualValue);
+ }
+ });
+ await SpecialPowers.spawn(win, [], function() {
+ const doc = this.content.document;
+ Assert.equal(doc.querySelector("[name='uname']").value, "real••••user", "Check username did get autofilled");
+ doc.querySelector("[name='pword']").setUserInput("pass1");
+ });
+
+ // we shouldn't get the save/update password doorhanger as it didn't change
+ const popupShownPromise = noPopupBy();
+
+ // Check data sent via PasswordManager:onFormSubmit
+ const processedPromise = getSubmitMessage();
+ await SpecialPowers.spawn(win, [], function() {
+ this.content.document.getElementById("submitBtn").click();
+ });
+
+ const { data } = await processedPromise;
+ info("Got submitted result: " + JSON.stringify(data));
+
+ is(data.usernameField, null, "Check usernameField");
+
+ const updatedLogins = await LoginManager.getAllLogins();
+ const updatedLogin = Array.prototype.find.call(updatedLogins, l => l.guid == guid);
+ ok(updatedLogin, "Got the login via guid");
+ is(updatedLogin.timesUsed, timesUsed + 1, "timesUsed was incremented");
+
+ await popupShownPromise;
+
+ win.close();
+ await SimpleTest.promiseFocus(window);
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+</div>
+<pre id="test"></pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Don't repeatedly prompt to save the same username and password
+ combination in the same document</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+ let chromeScript = runChecksAfterCommonInit();
+
+ SimpleTest.requestFlakyTimeout("Giving a chance for the unexpected popupshown to occur");
+</script>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+ <form id="form1" onsubmit="return false;">
+ <input type="text" name="uname" id="ufield">
+ <input type="password" name="pword" id="pfield">
+ <button type="submit" id="submitBtn">Submit</button>
+ </form>
+</div>
+
+<pre id="test"></pre>
+<script>
+ /** Test for Login Manager: Don't repeatedly prompt to save the
+ same username and password combination in the same document **/
+
+ add_task(async function test_prompt_does_not_reappear() {
+ let username = document.getElementById("ufield");
+ let password = document.getElementById("pfield");
+ let submitButton = document.getElementById("submitBtn");
+
+ SpecialPowers.wrap(username).setUserInput("user");
+ SpecialPowers.wrap(password).setUserInput("pass");
+
+ let processedPromise = getSubmitMessage();
+ let promptShownPromise = promisePromptShown("passwordmgr-prompt-save");
+ submitButton.click();
+ await processedPromise;
+ await promptShownPromise;
+
+ is(username.value, "user", "Checking for filled username");
+ is(password.value, "pass", "Checking for filled password");
+
+ let promptShown = false;
+ promptShownPromise = promisePromptShown("passwordmgr-prompt-save").then(value => {
+ promptShown = true;
+ });
+ submitButton.click();
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ ok(!promptShown, "Prompt is not shown for the same login values a second time");
+ });
+</script>
+</body>
+</html>
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..18b851c86f
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_onsubmit_value_change.html
@@ -0,0 +1,71 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test input value change right after onsubmit event</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: input value change right after onsubmit event
+
+<script>
+ let chromeScriptPromise = runChecksAfterCommonInit();
+
+ async function getSubmitMessage() {
+ info("getSubmitMessage");
+ let chromeScript = await chromeScriptPromise;
+ return new Promise(resolve => {
+ chromeScript.addMessageListener("formSubmissionProcessed", function processed(...args) {
+ info("got formSubmissionProcessed");
+ chromeScript.removeMessageListener("formSubmissionProcessed", processed);
+ resolve(args[0]);
+ });
+ });
+ }
+</script>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+
+ <form id="form1" action="formTest.js" onsubmit="return false;">
+ <input type="text" name="uname" id="ufield">
+ <input type="password" name="pword" id="pfield">
+ <button type="submit" id="submitBtn">Submit</button>
+ </form>
+
+</div>
+
+<pre id="test"></pre>
+<script>
+ /** Test for Login Manager: input value change right after onsubmit event **/
+ add_task(async function checkFormValues() {
+ SpecialPowers.wrap(document.getElementById("ufield")).setUserInput("testuser");
+ SpecialPowers.wrap(document.getElementById("pfield")).setUserInput("testpass");
+ is(getFormElementByName(1, "uname").value, "testuser", "Checking for filled username");
+ is(getFormElementByName(1, "pword").value, "testpass", "Checking for filled password");
+
+ document.getElementById("form1").addEventListener("submit", () => {
+ // deliberately assign to .value rather than setUserInput:
+ // the scenario under test here is that script is changing/populating
+ // fields after the user has clicked the submit button
+ document.getElementById("ufield").value = "newuser";
+ document.getElementById("pfield").value = "newpass";
+ }, true);
+
+ document.getElementById("form1").addEventListener("submit", (e) => e.preventDefault());
+
+ let processedPromise = getSubmitMessage();
+
+ let button = document.getElementById("submitBtn");
+ button.click();
+
+ let { data } = await processedPromise;
+ is(data.usernameField.value, "testuser", "Should have registered \"testuser\" for username");
+ is(data.newPasswordField.value, "testpass", "Should have registered \"testpass\" for username");
+ });
+</script>
+</body>
+</html>
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..cb7f759cab
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_password_field_autocomplete.html
@@ -0,0 +1,185 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic login autocomplete</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: multiple login autocomplete
+<p id="display"></p>
+
+<!-- we presumably can't hide the content for this test. -->
+<div id="content"></div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/** Test for Login Manager: multiple login autocomplete. **/
+
+const INSECURE_WARNING_TEXT = "This connection is not secure. Logins entered here could be compromised. Learn More";
+
+// Restore the form to the default state.
+async function reinitializeForm(index) {
+ // Using innerHTML is for creating the autocomplete popup again, so the
+ // preference value will be applied to the constructor of
+ // LoginAutoCompleteResult.
+ let form = document.getElementById("form" + index);
+ let temp = form.innerHTML;
+ form.innerHTML = "";
+ form.innerHTML = temp;
+
+ await new Promise(resolve => {
+ let observer = SpecialPowers.wrapCallback(() => {
+ SpecialPowers.removeObserver(observer, "passwordmgr-processed-form");
+ resolve();
+ });
+ SpecialPowers.addObserver(observer, "passwordmgr-processed-form");
+ });
+
+ await SimpleTest.promiseFocus(window);
+
+ const uname = getFormElementByName(index, "uname");
+ const pword = getFormElementByName(index, "pword");
+ uname.value = "";
+ pword.value = "";
+ pword.focus();
+}
+
+function generateDateString(date) {
+ let dateAndTimeFormatter = new Services.intl.DateTimeFormat(undefined,
+ { dateStyle: "medium" });
+ return dateAndTimeFormatter.format(date);
+}
+
+const DATE_NOW_STRING = generateDateString(new Date());
+
+// Check for expected username/password in form.
+function checkACFormPasswordField(expectedPassword) {
+ const pword = getFormElementByName(1, "pword");
+ is(pword.value, expectedPassword, "Checking form1 password is: " + JSON.stringify(expectedPassword));
+}
+
+async function userOpenAutocompleteOnForm1(autoFillInsecureForms) {
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["signon.autofillForms.http", autoFillInsecureForms],
+ ]});
+ await reinitializeForm(1);
+ const autocompleteItems = await popupBy();
+
+ const popupState = await getPopupState();
+ is(popupState.selectedIndex, -1, "Check no entries are selected upon opening");
+
+ const expectedMenuItems = [INSECURE_WARNING_TEXT,
+ "No username (" + DATE_NOW_STRING + ")",
+ "tempuser1",
+ "testuser2",
+ "testuser3",
+ "zzzuser4"];
+ checkAutoCompleteResults(autocompleteItems, expectedMenuItems, "mochi.test", "Check all menuitems are displayed correctly.");
+}
+
+async function userPressedDown_passwordIs(value) {
+ synthesizeKey("KEY_ArrowDown");
+ await Promise.resolve(); // let focus happen
+ checkACFormPasswordField(value);
+}
+
+async function userPressedEnter_passwordIs(value) {
+ synthesizeKey("KEY_Enter");
+ await Promise.resolve(); // let focus happen
+ checkACFormPasswordField(value);
+}
+
+async function noPopupOnForm(formIndex, reason) {
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["signon.autofillForms.http", true],
+ ]});
+ await reinitializeForm(formIndex);
+
+ // Trigger autocomplete popup
+ synthesizeKey("KEY_ArrowDown"); // open
+ let popupState = await getPopupState();
+ is(popupState.open, false, reason);
+}
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ // login0 has no username, so should be filtered out from the autocomplete list.
+ [location.origin, "http://autocomplete:8888", null, "", "user0pass", "", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "tempuser1", "temppass1", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "testuser2", "testpass2", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "testuser3", "testpass3", "uname", "pword"],
+ [location.origin, "http://autocomplete:8888", null, "zzzuser4", "zzzpass4", "uname", "pword"]
+ );
+ createLoginForm({
+ num: 1,
+ action: "http://autocomplete:8888/formtest.js"
+ });
+ createLoginForm({
+ num: 2,
+ actio: "http://autocomplete:8888/formtest.js",
+ password: {
+ readonly: true
+ }
+ });
+ createLoginForm({
+ num: 3,
+ action: "http://autocomplete:8888/formtest.js",
+ password: {
+ disabled: true
+ }
+ });
+ listenForUnexpectedPopupShown();
+});
+
+add_task(async function form1_initial_empty() {
+ await SimpleTest.promiseFocus(window);
+
+ // Make sure initial form is empty.
+ checkACFormPasswordField("");
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is initially closed");
+});
+
+add_task(async function noAutocompleteForReadonlyField() {
+ await noPopupOnForm(2, "Check popup is closed for a readonly field.");
+});
+
+add_task(async function noAutocompleteForDisabledField() {
+ await noPopupOnForm(3, "Check popup is closed for a disabled field.");
+});
+
+add_task(async function insecureAutoFill_EnterOnWarning() {
+ await userOpenAutocompleteOnForm1(true);
+ await userPressedDown_passwordIs("");
+ await userPressedEnter_passwordIs("");
+});
+
+add_task(async function insecureAutoFill_EnterOnLogin() {
+ await userOpenAutocompleteOnForm1(true);
+ await userPressedDown_passwordIs(""); // select insecure warning
+ await userPressedDown_passwordIs(""); // select login
+ await userPressedEnter_passwordIs("user0pass");
+});
+
+add_task(async function noInsecureAutoFill_EnterOnWarning() {
+ await userOpenAutocompleteOnForm1(false);
+ await userPressedDown_passwordIs(""); // select insecure warning
+ await userPressedEnter_passwordIs("");
+});
+
+add_task(async function noInsecureAutoFill_EnterOnLogin() {
+ await userOpenAutocompleteOnForm1(false);
+ await userPressedDown_passwordIs(""); // select insecure warning
+ await userPressedDown_passwordIs(""); // select login
+ await userPressedEnter_passwordIs("user0pass");
+});
+
+</script>
+</pre>
+</body>
+</html>
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..724caf236f
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_password_length.html
@@ -0,0 +1,145 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test handling of different password length</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script type="application/javascript">
+let readyPromise = registerRunTests();
+
+let loadPromise = new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.getElementById("loginFrame").addEventListener("load", (evt) => {
+ resolve();
+ });
+ });
+});
+
+async function loadFormIntoIframe(origin, html) {
+ let loginFrame = document.getElementById("loginFrame");
+ let loadedPromise = new Promise((resolve) => {
+ loginFrame.addEventListener("load", function() {
+ resolve();
+ }, {once: true});
+ });
+ let processedPromise = promiseFormsProcessed();
+ loginFrame.src = origin + "/tests/toolkit/components/passwordmgr/test/mochitest/blank.html";
+ await loadedPromise;
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [html], function(contentHtml) {
+ this.content.document.documentElement.innerHTML = contentHtml;
+ });
+
+ // Wait for the form to be processed before trying to submit.
+ await processedPromise;
+}
+
+add_setup(async () => {
+ info("Waiting for setup and page and frame loads");
+ await readyPromise;
+ await loadPromise;
+});
+
+const DEFAULT_ORIGIN = window.location.origin;
+const TESTCASES = [
+ {
+ testName: "test_control2PasswordFields",
+ pword1: "pass1",
+ pword2: "pass2",
+ expectedNewPassword: { value: "pass2" },
+ expectedOldPassword: { value: "pass1" },
+ },
+ {
+ testName: "test_1characterPassword",
+ pword1: "x",
+ pword2: "pass2",
+ expectedNewPassword: { value: "pass2" },
+ expectedOldPassword: null,
+ },
+ {
+ testName: "test_2characterPassword",
+ pword1: "xy",
+ pword2: "pass2",
+ expectedNewPassword: { value: "pass2" },
+ expectedOldPassword: { value: "xy" },
+ },
+ {
+ testName: "test_1characterNewPassword",
+ pword1: "pass1",
+ pword2: "x",
+ expectedNewPassword: { value: "pass1" },
+ expectedOldPassword: null,
+ },
+];
+
+/**
+ * @return {Promise} resolving when form submission was processed.
+ */
+function getSubmitMessage() {
+ return new Promise((resolve, reject) => {
+ PWMGR_COMMON_PARENT.addMessageListener("formSubmissionProcessed", function processed(...args) {
+ info("got formSubmissionProcessed");
+ PWMGR_COMMON_PARENT.removeMessageListener("formSubmissionProcessed", processed);
+ resolve(args[0]);
+ });
+ });
+}
+
+add_task(async function test_password_lengths() {
+ for (let tc of TESTCASES) {
+ info("Starting testcase: " + tc.testName + ", " + JSON.stringify([tc.pword1, tc.pword2]));
+ await loadFormIntoIframe(DEFAULT_ORIGIN, `<form id="form1" onsubmit="return false;">
+ <input type="text" name="uname" value="myname">
+ <input type="password" name="pword1" value="">
+ <input type="password" name="pword2" value="">
+ <button type="submit" id="submitBtn">Submit</button>
+ </form>`);
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [tc], function(testcase) {
+ let doc = this.content.document;
+ Assert.equal(doc.querySelector("[name='uname']").value, "myname", "Checking for filled username");
+ doc.querySelector("[name='pword1']").setUserInput(testcase.pword1);
+ doc.querySelector("[name='pword2']").setUserInput(testcase.pword2);
+ });
+
+ // Check data sent via PasswordManager:onFormSubmit
+ let processedPromise = getSubmitMessage();
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [], function() {
+ this.content.document.getElementById("submitBtn").click();
+ });
+
+ let { data } = await processedPromise;
+ info("Got submitted result: " + JSON.stringify(data));
+
+ if (tc.expectedNewPassword === null) {
+ is(data.newPasswordField,
+ tc.expectedNewPassword, "Check expectedNewPassword is null");
+ } else {
+ is(data.newPasswordField.value,
+ tc.expectedNewPassword.value,
+ "Check that newPasswordField.value matches the expectedNewPassword.value");
+ }
+ if (tc.expectedOldPassword === null) {
+ is(data.oldPasswordField,
+ tc.expectedOldPassword, "Check expectedOldPassword is null");
+ } else {
+ is(data.oldPasswordField.value,
+ tc.expectedOldPassword.value,
+ "Check that oldPasswordField.value matches expectedOldPassword.value");
+ }
+ }
+});
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="loginFrame" src="/tests/toolkit/components/passwordmgr/test/mochitest/blank.html"></iframe>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test that passwords only get filled in type=password</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: Bug 242956
+<script>
+gTestDependsOnDeprecatedLogin = true;
+runChecksAfterCommonInit(() => startTest());
+
+let DEFAULT_ORIGIN = window.location.origin;
+</script>
+<p id="display"></p>
+<div id="content" style="display: none">
+</div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: 242956 (Stored password is inserted into a
+ readable text input on a second page) **/
+
+// Make sure that pwmgr only puts passwords into type=password <input>s.
+// Might as well test the converse, too (username in password field).
+
+async function startTest() {
+ let win = window.open("about:blank");
+ SimpleTest.registerCleanupFunction(() => win.close());
+
+ // only 4 out of 7 forms are to be autofilled
+ await loadFormIntoWindow(DEFAULT_ORIGIN, `
+ <!-- pword is not a type=password input -->
+ <form id="form1" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="text" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- uname is not a type=text input -->
+ <form id="form2" action="formtest.js">
+ <input type="password" name="uname">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- two "pword" inputs, (text + password) -->
+ <form id="form3" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="text" name="pword">
+ <input type="password" name="qword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- same thing, different order -->
+ <form id="form4" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="password" name="pword">
+ <input type="text" name="qword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- uname is not a type=text input (try a checkbox just for variety) -->
+ <form id="form5" action="formtest.js">
+ <input type="checkbox" name="uname" value="">
+ <input type="password" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- pword is not a type=password input (try a checkbox just for variety) -->
+ <form id="form6" action="formtest.js">
+ <input type="text" name="uname">
+ <input type="checkbox" name="pword" value="">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>
+
+ <!-- pword is not a type=password input -->
+ <form id="form7" action="formtest.js">
+ <input type="text" name="uname" value="testuser">
+ <input type="text" name="pword">
+
+ <button type="submit">Submit</button>
+ <button type="reset"> Reset </button>
+ </form>`, win, 4);
+
+ await checkLoginFormInFrameWithElementValues(win, 1, "", "");
+ await checkLoginFormInFrameWithElementValues(win, 2, "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, 3, "", "testuser", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, 4, "testuser", "testpass", "");
+ await checkLoginFormInFrameWithElementValues(win, 5, "", "testpass");
+ await checkLoginFormInFrameWithElementValues(win, 6, "", "");
+ await checkLoginFormInFrameWithElementValues(win, 7, "testuser", "");
+
+ SimpleTest.finish();
+}
+</script>
+</pre>
+</body>
+</html>
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..4da6929b61
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_primary_password.html
@@ -0,0 +1,296 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for primary password</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../prompts/test/prompt_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: primary password.
+
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+"use strict";
+
+// Force parent to not look for tab-modal prompts, as they're not used for auth prompts.
+modalType = Ci.nsIPrompt.MODAL_TYPE_WINDOW;
+
+const exampleCom = "https://example.com/tests/toolkit/components/passwordmgr/test/mochitest/";
+const exampleOrg = "https://example.org/tests/toolkit/components/passwordmgr/test/mochitest/";
+
+const win = window.open("about:blank");
+SimpleTest.registerCleanupFunction(() => win.close());
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ ["http://mochi.test:8888", "http://mochi.test:8888", null, "testuser", "testpass", "uname", "pword"],
+ ["https://example.com", "https://example.com", null, "user1", "pass1", "uname", "pword"],
+ ["https://example.org", "https://example.org", null, "user2", "pass2", "uname", "pword"]
+ );
+ ok(await isLoggedIn(), "should be initially logged in (no PP)");
+ enablePrimaryPassword();
+ ok(!await isLoggedIn(), "should be logged out after setting PP");
+});
+
+add_task(async function test_1() {
+ // Trigger a MP prompt via the API
+ const state = {
+ msg: "Please enter your Primary Password.",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "ok",
+ passField: LoginTestUtils.primaryPassword.primaryPassword,
+ };
+ const promptDone = handlePrompt(state, action);
+
+ const logins = await LoginManager.getAllLogins();
+
+ await promptDone;
+ is(logins.length, 3, "expected number of logins");
+
+ ok(await isLoggedIn(), "should be logged in after MP prompt");
+ logoutPrimaryPassword();
+ ok(!await isLoggedIn(), "should be logged out");
+});
+
+add_task(async function test_2() {
+ // Try again but click cancel.
+ const state = {
+ msg: "Please enter your Primary Password.",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "cancel",
+ };
+ const promptDone = handlePrompt(state, action);
+
+ const logins = await LoginManager.getAllLogins().catch(() => {});
+ await promptDone;
+ is(logins, undefined, "shouldn't have gotten logins");
+ ok(!await isLoggedIn(), "should still be logged out");
+});
+
+add_task(async function test_3() {
+ const state = {
+ msg: "Please enter your Primary Password.",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "ok",
+ passField: LoginTestUtils.primaryPassword.primaryPassword,
+ };
+ const promptDone = handlePrompt(state, action);
+
+ const fillPromise = promiseFormsProcessed();
+
+ info("Load a single window to trigger a MP");
+ await SimpleTest.promiseFocus(win, true);
+ win.location = exampleCom + "subtst_primary_pass.html";
+
+ await promptDone;
+ info("promptDone");
+ await fillPromise;
+ info("filled");
+
+ // check contents of win fields
+
+ await SpecialPowers.spawn(win, [], function() {
+ const u = this.content.document.getElementById("userfield");
+ const p = this.content.document.getElementById("passfield");
+ Assert.equal(u.value, "user1", "checking expected user to have been filled in");
+ Assert.equal(p.value, "pass1", "checking expected pass to have been filled in");
+ u.value = "";
+ p.value = "";
+ });
+
+ ok(await isLoggedIn(), "should be logged in");
+ logoutPrimaryPassword();
+ ok(!await isLoggedIn(), "should be logged out");
+});
+
+add_task(async function test_4() {
+ const state = {
+ msg: "Please enter your Primary Password.",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "none",
+ };
+ const promptDone = handlePrompt(state, action);
+
+ // first part of loading 2 MP-triggering windows
+ await SimpleTest.promiseFocus(win);
+ win.location = exampleOrg + "subtst_primary_pass.html";
+ // The MP prompt is open but don't take any action yet.
+ await promptDone;
+
+ // check contents of win fields
+ await SpecialPowers.spawn(win, [], function() {
+ const u = this.content.document.getElementById("userfield");
+ const p = this.content.document.getElementById("passfield");
+ Assert.equal(u.value, "", "checking expected empty user");
+ Assert.equal(p.value, "", "checking expected empty pass");
+ });
+
+ ok(!await isLoggedIn(), "should be logged out");
+
+ // XXX check that there's 1 MP window open
+
+ // Load a second login form in an iframe
+ // This should detect that there's already a pending MP prompt, and not
+ // put up a second one.
+
+ // Since the Primary Password prompt is open, we can't focus another tab
+ // to load the second form. Instead, we load the same form into an iframe.
+ const url = exampleOrg + "subtst_primary_pass.html";
+ await SpecialPowers.spawn(win, [url], async function(urlF) {
+ const iframe = this.content.document.querySelector("iframe");
+ const loadPromise = new Promise(resolve => {
+ iframe.addEventListener("load", function onload() {
+ resolve();
+ }, { once: true });
+ });
+ // Use the same origin as the top level to ensure we would autofill
+ // if we could (we don't fill in cross-origin iframes).
+ iframe.src = urlF;
+ await loadPromise;
+ });
+
+ // We can't use promiseFormsProcessed* here, because _fillForm doesn't
+ // run if Primary Password is locked.
+ await new Promise(resolve => {
+ // Testing a negative, wait a little to give the login manager a chance to
+ // (incorrectly) fill in the form. Note, we cannot use setTimeout()
+ // here because the modal window suspends all window timers. Instead we
+ // must use a chrome script to use nsITimer directly.
+ const chromeURL = SimpleTest.getTestFileURL("chrome_timeout.js");
+ const script = SpecialPowers.loadChromeScript(chromeURL);
+ script.addMessageListener("ready", _ => {
+ script.sendAsyncMessage("setTimeout", { delay: 500 });
+ });
+ script.addMessageListener("timeout", resolve);
+ });
+
+ // iframe should load without having triggered a MP prompt (because one
+ // is already waiting)
+
+ // check contents of iframe fields
+ await SpecialPowers.spawn(win, [], function() {
+ const iframe = this.content.document.querySelector("iframe");
+ const frameDoc = iframe.contentDocument;
+ const u = frameDoc.getElementById("userfield");
+ const p = frameDoc.getElementById("passfield");
+ Assert.equal(u.value, "", "checking expected empty user");
+ Assert.equal(p.value, "", "checking expected empty pass");
+ });
+
+ // XXX check that there's 1 MP window open
+ ok(!await isLoggedIn(), "should be logged out");
+
+ // Ok, now enter the MP. The MP prompt is already up.
+ const fillPromise = promiseFormsProcessed(2);
+
+ // fill existing MP dialog with MP.
+ action = {
+ buttonClick: "ok",
+ passField: LoginTestUtils.primaryPassword.primaryPassword,
+ };
+ await handlePrompt(state, action);
+ await fillPromise;
+
+ // We shouldn't have to worry about win's load event racing with
+ // filling of the iframe's data. We notify observers synchronously, so
+ // the iframe's observer will process the iframe before win even finishes
+ // processing the form.
+ ok(await isLoggedIn(), "should be logged in");
+
+ // check contents of win fields
+ await SpecialPowers.spawn(win, [], function() {
+ const u = this.content.document.getElementById("userfield");
+ const p = this.content.document.getElementById("passfield");
+ Assert.equal(u.value, "user2", "checking expected user to have been filled in");
+ Assert.equal(p.value, "pass2", "checking expected pass to have been filled in");
+
+ // clearing fields to not cause a submission when the next document is loaded
+ u.value = "";
+ p.value = "";
+ });
+
+ // check contents of iframe fields
+ await SpecialPowers.spawn(win, [], function() {
+ const iframe = this.content.document.querySelector("iframe");
+ const frameDoc = iframe.contentDocument;
+ const u = frameDoc.getElementById("userfield");
+ const p = frameDoc.getElementById("passfield");
+ Assert.equal(u.value, "user2", "checking expected user to have been filled in");
+ Assert.equal(p.value, "pass2", "checking expected pass to have been filled in");
+
+ // clearing fields to not cause a submission when the next document is loaded
+ u.value = "";
+ p.value = "";
+ });
+});
+
+// XXX do a test5ABC with clicking cancel?
+
+SimpleTest.registerCleanupFunction(function finishTest() {
+ disablePrimaryPassword();
+});
+
+</script>
+</pre>
+</body>
+</html>
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..12d3a7d080
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt.html
@@ -0,0 +1,669 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test prompter.{prompt,asyncPromptPassword,asyncPromptUsernameAndPassword}</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../prompts/test/prompt_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+var state, action;
+var uname = { value: null };
+var pword = { value: null };
+var result = { value: null };
+var isOk;
+
+// Force parent to not look for tab-modal prompts, as they're not used for auth prompts.
+modalType = Ci.nsIPrompt.MODAL_TYPE_WINDOW;
+
+let prompterParent = runInParent(() => {
+ const promptFac = Cc["@mozilla.org/passwordmanager/authpromptfactory;1"].
+ getService(Ci.nsIPromptFactory);
+
+ let chromeWin = Services.wm.getMostRecentWindow("navigator:browser");
+ let prompter1 = promptFac.getPrompt(chromeWin, Ci.nsIAuthPrompt);
+
+ addMessageListener("proxyPrompter", async function onMessage(msg) {
+ let rv = await prompter1[msg.methodName](...msg.args);
+ return {
+ rv,
+ // Send the args back to content so out/inout args can be checked.
+ args: msg.args,
+ };
+ });
+});
+
+let prompter1 = new PrompterProxy(prompterParent);
+
+const defaultTitle = "the title";
+const defaultMsg = "the message";
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ ["http://example.com", null, "http://example.com", "", "examplepass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "user1name", "user1pass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "user2name", "user2pass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "user3.name@host", "user3pass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "100@beef", "user3pass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "100%beef", "user3pass", "", ""]
+ );
+});
+
+add_task(async function test_prompt_accept() {
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "abc",
+ passValue: "",
+ iconClass: "question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: true,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ textField: "xyz",
+ };
+ promptDone = handlePrompt(state, action);
+ isOk = prompter1.prompt(defaultTitle, defaultMsg, "http://example.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, "abc", result);
+ await promptDone;
+
+ ok(isOk, "Checking dialog return value (accept)");
+ is(result.value, "xyz", "Checking prompt() returned value");
+});
+
+add_task(async function test_prompt_cancel() {
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "abc",
+ passValue: "",
+ iconClass: "question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: true,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "cancel",
+ };
+ promptDone = handlePrompt(state, action);
+ isOk = prompter1.prompt(defaultTitle, defaultMsg, "http://example.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, "abc", result);
+ await promptDone;
+ ok(!isOk, "Checking dialog return value (cancel)");
+});
+
+add_task(async function test_promptPassword_defaultAccept() {
+ // Default password provided, existing logins are ignored.
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "inputpw",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "secret",
+ };
+ pword.value = "inputpw";
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://example.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "secret", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_defaultCancel() {
+ // Default password provided, existing logins are ignored.
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "inputpw",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "cancel",
+ };
+ pword.value = "inputpw";
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://example.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ await promptDone;
+ ok(!isOk, "Checking dialog return value (cancel)");
+});
+
+add_task(async function test_promptPassword_emptyAccept() {
+ // No default password provided, realm does not match existing login.
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "secret",
+ };
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://nonexample.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "secret", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_saved() {
+ // No default password provided, matching login is returned w/o prompting.
+ pword.value = null;
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://example.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "examplepass", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_noMatchingPasswordForEmptyUN() {
+ // No default password provided, none of the logins from this host are
+ // password-only so the user is prompted.
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "secret",
+ };
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "secret", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_matchingPWForUN() {
+ // No default password provided, matching login is returned w/o prompting.
+ pword.value = null;
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://user1name@example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "user1pass", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_matchingPWForUN2() {
+ // No default password provided, matching login is returned w/o prompting.
+ pword.value = null;
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://user2name@example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "user2pass", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_matchingPWForUN3() {
+ // No default password provided, matching login is returned w/o prompting.
+ pword.value = null;
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://user3%2Ename%40host@example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "user3pass", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_extraAt() {
+ // No default password provided, matching login is returned w/o prompting.
+ pword.value = null;
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://100@beef@example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "user3pass", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_usernameEncoding() {
+ // No default password provided, matching login is returned w/o prompting.
+ pword.value = null;
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "http://100%25beef@example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "user3pass", "Checking returned password");
+
+ // XXX test saving a password with Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY
+});
+
+add_task(async function test_promptPassword_realm() {
+ // We don't pre-fill or save for NS_GetAuthKey-generated realms, but we should still prompt
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "fill2pass",
+ };
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "example2.com:80 (somerealm)",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "fill2pass", "Checking returned password");
+});
+
+add_task(async function test_promptPassword_realm2() {
+ // We don't pre-fill or save for NS_GetAuthKey-generated realms, but we should still prompt
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: true,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "passField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "fill2pass",
+ };
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptPassword(defaultTitle, defaultMsg, "example2.com:80 (somerealm)",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(pword.value, "fill2pass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_accept() {
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "inuser",
+ passValue: "inpass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ textField: "outuser",
+ passField: "outpass",
+ };
+ uname.value = "inuser";
+ pword.value = "inpass";
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "http://nonexample.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(uname.value, "outuser", "Checking returned username");
+ is(pword.value, "outpass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_cancel() {
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "inuser",
+ passValue: "inpass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "cancel",
+ };
+ uname.value = "inuser";
+ pword.value = "inpass";
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "http://nonexample.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, uname, pword);
+ await promptDone;
+ ok(!isOk, "Checking dialog return value (cancel)");
+});
+
+add_task(async function test_promptUsernameAndPassword_autofill() {
+ // test filling in existing password-only login
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "examplepass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ };
+ uname.value = null;
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "http://example.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(uname.value, "", "Checking returned username");
+ is(pword.value, "examplepass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_multipleExisting() {
+ // test filling in existing login (undetermined from multiple selection)
+ // user2name/user2pass would also be valid to fill here.
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "user1name",
+ passValue: "user1pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ };
+ uname.value = null;
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ ok(uname.value == "user1name" || uname.value == "user2name", "Checking returned username");
+ ok(pword.value == "user1pass" || uname.value == "user2pass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_multipleExisting1() {
+ // test filling in existing login (user1 from multiple selection)
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "user1name",
+ passValue: "user1pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ };
+ uname.value = "user1name";
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(uname.value, "user1name", "Checking returned username");
+ is(pword.value, "user1pass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_multipleExisting2() {
+ // test filling in existing login (user2 from multiple selection)
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "user2name",
+ passValue: "user2pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ };
+ uname.value = "user2name";
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(uname.value, "user2name", "Checking returned username");
+ is(pword.value, "user2pass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_passwordChange() {
+ // test changing password
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "user2name",
+ passValue: "user2pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "NEWuser2pass",
+ };
+ uname.value = "user2name";
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(uname.value, "user2name", "Checking returned username");
+ is(pword.value, "NEWuser2pass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_changePasswordBack() {
+ // test changing password (back to original value)
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "user2name",
+ passValue: "NEWuser2pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "user2pass",
+ };
+ uname.value = "user2name";
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "http://example2.com",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(uname.value, "user2name", "Checking returned username");
+ is(pword.value, "user2pass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_realm() {
+ // We don't pre-fill or save for NS_GetAuthKey-generated realms, but we should still prompt
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ textField: "fill2user",
+ passField: "fill2pass",
+ };
+ uname.value = null;
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "example2.com:80 (somerealm)",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(uname.value, "fill2user", "Checking returned username");
+ is(pword.value, "fill2pass", "Checking returned password");
+});
+
+add_task(async function test_promptUsernameAndPassword_realm2() {
+ // We don't pre-fill or save for NS_GetAuthKey-generated realms, but we should still prompt
+ state = {
+ msg: "the message",
+ title: "the title",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ textField: "fill2user",
+ passField: "fill2pass",
+ };
+ uname.value = null;
+ pword.value = null;
+ promptDone = handlePrompt(state, action);
+ isOk = await prompter1.asyncPromptUsernameAndPassword(defaultTitle, defaultMsg, "example2.com:80 (somerealm)",
+ Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, uname, pword);
+ await promptDone;
+ ok(isOk, "Checking dialog return value (accept)");
+ is(uname.value, "fill2user", "Checking returned username");
+ is(pword.value, "fill2pass", "Checking returned password");
+});
+
+</script>
+</pre>
+</body>
+</html>
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..41a58cb416
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_async.html
@@ -0,0 +1,621 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for Async Auth Prompt</title>
+ <script type="text/javascript" src="/MochiKit/MochiKit.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../prompts/test/prompt_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+
+ <script class="testbody" type="text/javascript">
+ const { NetUtil } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+ );
+ const { TestUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+ const EXAMPLE_COM = "http://example.com/tests/toolkit/components/passwordmgr/test/mochitest/";
+ const EXAMPLE_ORG = "http://example.org/tests/toolkit/components/passwordmgr/test/mochitest/";
+ let mozproxyOrigin;
+
+ // Let prompt_common know what kind of modal type is enabled for auth prompts.
+ modalType = authPromptModalType;
+
+ // These are magically defined on the window due to the iframe IDs
+ /* global iframe1, iframe2a, iframe2b */
+
+ /**
+ * Add a listener to add some logins to be autofilled in the HTTP/proxy auth. prompts later.
+ */
+ let pwmgrParent = runInParent(() => {
+ Services.prefs.setIntPref("network.auth.subresource-http-auth-allow", 2);
+ Services.prefs.setIntPref("prompts.authentication_dialog_abuse_limit", -1);
+
+ addMessageListener("initLogins", async function onMessage(msg) {
+ const loginsData = [
+ [msg.mozproxyOrigin, "proxy_realm", "proxy_user", "proxy_pass"],
+ [msg.mozproxyOrigin, "proxy_realm2", "proxy_user2", "proxy_pass2"],
+ [msg.mozproxyOrigin, "proxy_realm3", "proxy_user3", "proxy_pass3"],
+ [msg.mozproxyOrigin, "proxy_realm4", "proxy_user4", "proxy_pass4"],
+ [msg.mozproxyOrigin, "proxy_realm5", "proxy_user5", "proxy_pass5"],
+ ["http://example.com", "mochirealm", "user1name", "user1pass"],
+ ["http://example.org", "mochirealm2", "user2name", "user2pass"],
+ ["http://example.com", "mochirealm3", "user3name", "user3pass"],
+ ["http://example.com", "mochirealm4", "user4name", "user4pass"],
+ ["http://example.com", "mochirealm5", "user5name", "user5pass"],
+ ["http://example.com", "mochirealm6", "user6name", "user6pass"]
+ ];
+ const logins = loginsData.map(([host, realm, user, pass]) => {
+ const login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
+ login.init(host, null, realm, user, pass, "", "");
+ return login
+ })
+ await Services.logins.addLogins(logins);
+ });
+ }); // end runInParent
+
+ function promiseLoadedContentDoc(frame) {
+ return new Promise(resolve => {
+ frame.addEventListener("load", function onLoad(evt) {
+ resolve(SpecialPowers.wrap(frame).contentDocument);
+ }, { once: true });
+ });
+ }
+
+ function promiseProxyErrorLoad(frame) {
+ return TestUtils.waitForCondition(async function checkForProxyConnectFailure() {
+ try {
+ return await SpecialPowers.spawn(frame, [], function() {
+ return this.content.document.documentURI.includes("proxyConnectFailure");
+ })
+ } catch (e) {
+ // The frame may not be ready for the 'spawn' task right after setting
+ // iframe.src, which will throw an exception when that happens.
+ // Since this test is testing error load, we can't wait until the iframe
+ // is 'loaded' either. So we simply catch the exception here and retry the task
+ // later since we are in the waitForCondition loop.
+ return false;
+ }
+ }, "Waiting for proxyConnectFailure documentURI");
+ }
+
+ /**
+ * Make a channel to get the ProxyInfo used by the test harness so that we
+ * can add logins for the correct proxy origin.
+ */
+ add_task(async function setup_getProxyInfoForHarness() {
+ await new Promise(resolve => {
+ let resolveCallback = SpecialPowers.wrapCallbackObject({
+ // eslint-disable-next-line mozilla/use-chromeutils-generateqi
+ QueryInterface(iid) {
+ const interfaces = [Ci.nsIProtocolProxyCallback, Ci.nsISupports];
+
+ if (!interfaces.some(v => iid.equals(v))) {
+ throw SpecialPowers.Cr.NS_ERROR_NO_INTERFACE;
+ }
+ return this;
+ },
+
+ onProxyAvailable(req, uri, pi, status) {
+ // Add logins using the proxy host and port used by the mochitest harness.
+ mozproxyOrigin = "moz-proxy://" + SpecialPowers.wrap(pi).host + ":" +
+ SpecialPowers.wrap(pi).port;
+
+ pwmgrParent.sendQuery("initLogins", {mozproxyOrigin}).then(resolve)
+ },
+ });
+
+ // Need to allow for arbitrary network servers defined in PAC instead of a hardcoded moz-proxy.
+ let channel = NetUtil.newChannel({
+ uri: "http://example.com",
+ loadUsingSystemPrincipal: true,
+ });
+
+ let pps = SpecialPowers.Cc["@mozilla.org/network/protocol-proxy-service;1"]
+ .getService();
+
+ pps.asyncResolve(channel, 0, resolveCallback);
+ });
+ });
+
+ add_task(async function test_proxyAuthThenTwoHTTPAuth() {
+ // Load through a single proxy with authentication required 3 different
+ // pages, first with one login, other two with their own different login.
+ // We expect to show just a single dialog for proxy authentication and
+ // then two dialogs to authenticate to login 1 and then login 2.
+
+ let iframe1DocPromise = promiseLoadedContentDoc(iframe1);
+ let iframe2aDocPromise = promiseLoadedContentDoc(iframe2a);
+ let iframe2bDocPromise = promiseLoadedContentDoc(iframe2b);
+
+ iframe1.src = EXAMPLE_COM + "authenticate.sjs?" +
+ "r=1&" +
+ "user=user1name&" +
+ "pass=user1pass&" +
+ "realm=mochirealm&" +
+ "proxy_user=proxy_user&" +
+ "proxy_pass=proxy_pass&" +
+ "proxy_realm=proxy_realm";
+ iframe2a.src = EXAMPLE_ORG + "authenticate.sjs?" +
+ "r=2&" +
+ "user=user2name&" +
+ "pass=user2pass&" +
+ "realm=mochirealm2&" +
+ "proxy_user=proxy_user&" +
+ "proxy_pass=proxy_pass&" +
+ "proxy_realm=proxy_realm";
+ iframe2b.src = EXAMPLE_ORG + "authenticate.sjs?" +
+ "r=3&" +
+ "user=user2name&" +
+ "pass=user2pass&" +
+ "realm=mochirealm2&" +
+ "proxy_user=proxy_user&" +
+ "proxy_pass=proxy_pass&" +
+ "proxy_realm=proxy_realm";
+
+ let state = {
+ msg: `The proxy ${mozproxyOrigin} is requesting a username and password. The site says: “proxy_realm”`,
+ title: "Authentication Required",
+ textValue: "proxy_user",
+ passValue: "proxy_pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ await handlePrompt(state, action);
+
+ // We don't know what order these prompts appear in so get both states and check them.
+ // We can't use Promise.all here since we can't start the 2nd timer in chromeScript.js until
+ // the first timer is done since the timer variable gets clobbered, plus we don't want
+ // different actions racing each other.
+ let promptStates = [
+ await handlePromptWithoutChecks(action),
+ await handlePromptWithoutChecks(action),
+ ];
+
+ let expected1 = Object.assign({}, state, {
+ msg: "This site is asking you to sign in. Warning: Your login information will be shared with example.com, not the website you are currently visiting.",
+ textValue: "user1name",
+ passValue: "user1pass",
+ });
+
+ let expected2 = Object.assign({}, state, {
+ msg: "This site is asking you to sign in. Warning: Your login information will be shared with example.org, not the website you are currently visiting.",
+ textValue: "user2name",
+ passValue: "user2pass",
+ });
+
+ // The order isn't important.
+ let expectedPromptStates = [
+ expected1,
+ expected2,
+ ];
+
+ is(promptStates.length, expectedPromptStates.length,
+ "Check we handled the right number of prompts");
+ for (let promptState of promptStates) {
+ let expectedStateIndexForMessage = expectedPromptStates.findIndex(eps => {
+ return eps.msg == promptState.msg;
+ });
+ isnot(expectedStateIndexForMessage, -1, "Check state message was found in expected array");
+ let expectedPromptState = expectedPromptStates.splice(expectedStateIndexForMessage, 1)[0];
+ checkPromptState(promptState, expectedPromptState);
+ }
+
+ await iframe1DocPromise;
+ await iframe2aDocPromise;
+ await iframe2bDocPromise;
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [], function() {
+ let doc = this.content.document;
+ let authok1 = doc.getElementById("ok").textContent;
+ let proxyok1 = doc.getElementById("proxy").textContent;
+ Assert.equal(authok1, "PASS", "WWW Authorization OK, frame1");
+ Assert.equal(proxyok1, "PASS", "Proxy Authorization OK, frame1");
+ });
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 1), [], function() {
+ let doc = this.content.document;
+ let authok2a = doc.getElementById("ok").textContent;
+ let proxyok2a = doc.getElementById("proxy").textContent;
+ Assert.equal(authok2a, "PASS", "WWW Authorization OK, frame2a");
+ Assert.equal(proxyok2a, "PASS", "Proxy Authorization OK, frame2a");
+ });
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 2), [], function() {
+ let doc = this.content.document;
+ let authok2b = doc.getElementById("ok").textContent;
+ let proxyok2b = doc.getElementById("proxy").textContent;
+ Assert.equal(authok2b, "PASS", "WWW Authorization OK, frame2b");
+ Assert.equal(proxyok2b, "PASS", "Proxy Authorization OK, frame2b");
+ });
+ });
+
+ add_task(async function test_threeSubframesWithSameProxyAndHTTPAuth() {
+ // Load an iframe with 3 subpages all requiring the same login through
+ // an authenticated proxy. We expect 2 dialogs, proxy authentication
+ // and web authentication.
+
+ let iframe1DocPromise = promiseLoadedContentDoc(iframe1);
+
+ iframe1.src = EXAMPLE_COM + "subtst_prompt_async.html";
+ iframe2a.src = "about:blank";
+ iframe2b.src = "about:blank";
+
+ let state = {
+ msg: `The proxy ${mozproxyOrigin} is requesting a username and password. The site says: “proxy_realm2”`,
+ title: "Authentication Required",
+ textValue: "proxy_user2",
+ passValue: "proxy_pass2",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ await handlePrompt(state, action);
+
+ Object.assign(state, {
+ msg: "This site is asking you to sign in. Warning: Your login information will be shared with example.com, not the website you are currently visiting.",
+ textValue: "user3name",
+ passValue: "user3pass",
+ });
+ await handlePrompt(state, action);
+
+ await iframe1DocPromise;
+
+ function checkIframe(frameid) {
+ let doc = this.content.document;
+ let authok = doc.getElementById("ok").textContent;
+ let proxyok = doc.getElementById("proxy").textContent;
+
+ Assert.equal(authok, "PASS", "WWW Authorization OK, " + frameid);
+ Assert.equal(proxyok, "PASS", "Proxy Authorization OK, " + frameid);
+ }
+
+ let parentIFrameBC = SpecialPowers.wrap(window).windowGlobalChild
+ .browsingContext.children[0];
+
+ let childIFrame = SpecialPowers.unwrap(parentIFrameBC.children[0]);
+ await SpecialPowers.spawn(childIFrame, ["iframe1"], checkIframe);
+ childIFrame = SpecialPowers.unwrap(parentIFrameBC.children[1]);
+ await SpecialPowers.spawn(childIFrame, ["iframe2"], checkIframe);
+ childIFrame = SpecialPowers.unwrap(parentIFrameBC.children[2]);
+ await SpecialPowers.spawn(childIFrame, ["iframe3"], checkIframe);
+ });
+
+ add_task(async function test_oneFrameWithUnauthenticatedProxy() {
+ // Load in the iframe page through unauthenticated proxy
+ // and discard the proxy authentication. We expect to see
+ // unauthenticated page content and just a single dialog.
+
+ iframe1.src = EXAMPLE_COM + "authenticate.sjs?" +
+ "user=user4name&" +
+ "pass=user4pass&" +
+ "realm=mochirealm4&" +
+ "proxy_user=proxy_user3&" +
+ "proxy_pass=proxy_pass3&" +
+ "proxy_realm=proxy_realm3";
+
+ let state = {
+ msg: `The proxy ${mozproxyOrigin} is requesting a username and password. The site says: “proxy_realm3”`,
+ title: "Authentication Required",
+ textValue: "proxy_user3",
+ passValue: "proxy_pass3",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "cancel",
+ };
+ await handlePrompt(state, action);
+
+ await promiseProxyErrorLoad(iframe1);
+ });
+
+ add_task(async function test_reloadReusingProxyAuthButCancellingHTTPAuth() {
+ // Reload the frame from previous step and pass the proxy authentication
+ // but cancel the WWW authentication. We should get the proxy=ok and WWW=fail
+ // content as a result.
+ let iframe1DocPromise = promiseLoadedContentDoc(iframe1);
+
+ iframe1.src = EXAMPLE_COM + "authenticate.sjs?" +
+ "user=user4name&" +
+ "pass=user4pass&" +
+ "realm=mochirealm4&" +
+ "proxy_user=proxy_user3&" +
+ "proxy_pass=proxy_pass3&" +
+ "proxy_realm=proxy_realm3";
+
+ let state = {
+ msg: `The proxy ${mozproxyOrigin} is requesting a username and password. The site says: “proxy_realm3”`,
+ title: "Authentication Required",
+ textValue: "proxy_user3",
+ passValue: "proxy_pass3",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ await handlePrompt(state, action);
+
+ Object.assign(state, {
+ msg: "This site is asking you to sign in. Warning: Your login information will be shared with example.com, not the website you are currently visiting.",
+ textValue: "user4name",
+ passValue: "user4pass",
+ });
+ action = {
+ buttonClick: "cancel",
+ };
+ await handlePrompt(state, action);
+
+ await iframe1DocPromise;
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [], function() {
+ let doc = this.content.document;
+ let authok1 = doc.getElementById("ok").textContent;
+ let proxyok1 = doc.getElementById("proxy").textContent;
+
+ Assert.equal(authok1, "FAIL", "WWW Authorization FAILED, frame1");
+ Assert.equal(proxyok1, "PASS", "Proxy Authorization OK, frame1");
+ });
+ });
+
+ add_task(async function test_hugePayloadCancelled() {
+ // Same as the previous two steps but let the server generate
+ // huge content load to check http channel is capable to handle
+ // case when auth dialog is canceled or accepted before unauthenticated
+ // content data is load from the server. (This would be better to
+ // implement using delay of server response).
+ iframe1.src = EXAMPLE_COM + "authenticate.sjs?" +
+ "user=user5name&" +
+ "pass=user5pass&" +
+ "realm=mochirealm5&" +
+ "proxy_user=proxy_user4&" +
+ "proxy_pass=proxy_pass4&" +
+ "proxy_realm=proxy_realm4&" +
+ "huge=1";
+
+ let state = {
+ msg: `The proxy ${mozproxyOrigin} is requesting a username and password. The site says: “proxy_realm4”`,
+ title: "Authentication Required",
+ textValue: "proxy_user4",
+ passValue: "proxy_pass4",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "cancel",
+ };
+ await handlePrompt(state, action);
+
+ await promiseProxyErrorLoad(iframe1);
+ });
+
+ add_task(async function test_hugeProxySuccessWWWFail() {
+ // Reload the frame from the previous step and let the proxy
+ // authentication pass but WWW fail. We expect two dialogs
+ // and an unauthenticated page content load.
+
+ let iframe1DocPromise = promiseLoadedContentDoc(iframe1);
+ iframe1.src = EXAMPLE_COM + "authenticate.sjs?" +
+ "user=user5name&" +
+ "pass=user5pass&" +
+ "realm=mochirealm5&" +
+ "proxy_user=proxy_user4&" +
+ "proxy_pass=proxy_pass4&" +
+ "proxy_realm=proxy_realm4&" +
+ "huge=1";
+
+ let state = {
+ msg: `The proxy ${mozproxyOrigin} is requesting a username and password. The site says: “proxy_realm4”`,
+ title: "Authentication Required",
+ textValue: "proxy_user4",
+ passValue: "proxy_pass4",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ await handlePrompt(state, action);
+
+ Object.assign(state, {
+ msg: "This site is asking you to sign in. Warning: Your login information will be shared with example.com, not the website you are currently visiting.",
+ textValue: "user5name",
+ passValue: "user5pass",
+ });
+ action = {
+ buttonClick: "cancel",
+ };
+ await handlePrompt(state, action);
+
+ await iframe1DocPromise;
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [], function() {
+ let doc = this.content.document;
+ let authok1 = doc.getElementById("ok").textContent;
+ let proxyok1 = doc.getElementById("proxy").textContent;
+ let footnote = doc.getElementById("footnote").textContent;
+
+ Assert.equal(authok1, "FAIL", "WWW Authorization FAILED, frame1");
+ Assert.equal(proxyok1, "PASS", "Proxy Authorization OK, frame1");
+ Assert.equal(footnote, "This is a footnote after the huge content fill",
+ "Footnote present and loaded completely");
+ });
+ });
+
+ add_task(async function test_hugeProxySuccessWWWSuccess() {
+ // Reload again and let pass all authentication dialogs.
+ // Check we get the authenticated content not broken by
+ // the unauthenticated content.
+
+ let iframe1DocPromise = promiseLoadedContentDoc(iframe1);
+ await SpecialPowers.spawn(iframe1, [], function() {
+ this.content.document.location.reload();
+ });
+
+ let state = {
+ msg: "This site is asking you to sign in. Warning: Your login information will be shared with example.com, not the website you are currently visiting.",
+ title: "Authentication Required",
+ textValue: "user5name",
+ passValue: "user5pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ await handlePrompt(state, action);
+
+ await iframe1DocPromise;
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [], function() {
+ let doc = this.content.document;
+ let authok1 = doc.getElementById("ok").textContent;
+ let proxyok1 = doc.getElementById("proxy").textContent;
+ let footnote = doc.getElementById("footnote").textContent;
+
+ Assert.equal(authok1, "PASS", "WWW Authorization OK, frame1");
+ Assert.equal(proxyok1, "PASS", "Proxy Authorization OK, frame1");
+ Assert.equal(footnote, "This is a footnote after the huge content fill",
+ "Footnote present and loaded completely");
+ });
+ });
+
+ add_task(async function test_cancelSome() {
+ // Check we process all challenges sent by server when
+ // user cancels prompts
+ let iframe1DocPromise = promiseLoadedContentDoc(iframe1);
+ iframe1.src = EXAMPLE_COM + "authenticate.sjs?" +
+ "user=user6name&" +
+ "pass=user6pass&" +
+ "realm=mochirealm6&" +
+ "proxy_user=proxy_user5&" +
+ "proxy_pass=proxy_pass5&" +
+ "proxy_realm=proxy_realm5&" +
+ "huge=1&" +
+ "multiple=3";
+
+ let state = {
+ msg: `The proxy ${mozproxyOrigin} is requesting a username and password. The site says: “proxy_realm5”`,
+ title: "Authentication Required",
+ textValue: "proxy_user5",
+ passValue: "proxy_pass5",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "cancel",
+ };
+ await handlePrompt(state, action);
+
+ action = {
+ buttonClick: "cancel",
+ };
+ await handlePrompt(state, action);
+
+ action = {
+ buttonClick: "ok",
+ };
+ await handlePrompt(state, action);
+
+ Object.assign(state, {
+ msg: "This site is asking you to sign in. Warning: Your login information will be shared with example.com, not the website you are currently visiting.",
+ textValue: "user6name",
+ passValue: "user6pass",
+ });
+
+ action = {
+ buttonClick: "cancel",
+ };
+ await handlePrompt(state, action);
+
+ action = {
+ buttonClick: "ok",
+ };
+ await handlePrompt(state, action);
+
+ await iframe1DocPromise;
+ await SpecialPowers.spawn(getIframeBrowsingContext(window, 0), [], function() {
+ let doc = this.content.document;
+ let authok1 = doc.getElementById("ok").textContent;
+ let proxyok1 = doc.getElementById("proxy").textContent;
+ let footnote = doc.getElementById("footnote").textContent;
+
+ Assert.equal(authok1, "PASS", "WWW Authorization OK, frame1");
+ Assert.equal(proxyok1, "PASS", "Proxy Authorization OK, frame1");
+ Assert.equal(footnote, "This is a footnote after the huge content fill",
+ "Footnote present and loaded completely");
+ });
+
+ });
+ </script>
+</head>
+<body>
+ <iframe id="iframe1"></iframe>
+ <iframe id="iframe2a"></iframe>
+ <iframe id="iframe2b"></iframe>
+</body>
+</html>
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..f6f6e681dc
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_http.html
@@ -0,0 +1,319 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test HTTP auth prompts by loading authenticate.sjs</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../prompts/test/prompt_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+ <iframe id="iframe"></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+var iframe = document.getElementById("iframe");
+
+// Let prompt_common know what kind of modal type is enabled for auth prompts.
+modalType = authPromptModalType;
+
+const AUTHENTICATE_PATH = new URL("authenticate.sjs", window.location.href).pathname;
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ ["http://mochi.test:8888", null, "mochitest", "mochiuser1", "mochipass1", "", ""],
+ ["http://mochi.test:8888", null, "mochitest2", "mochiuser2", "mochipass2", "", ""],
+ ["http://mochi.test:8888", null, "mochitest3", "mochiuser3", "mochipass3-old", "", ""],
+ // Logins to test scheme upgrades (allowed) and downgrades (disallowed)
+ ["http://example.com", null, "schemeUpgrade", "httpUser", "httpPass", "", ""],
+ ["https://example.com", null, "schemeDowngrade", "httpsUser", "httpsPass", "", ""],
+ // HTTP and HTTPS version of the same domain and realm but with different passwords.
+ ["http://example.org", null, "schemeUpgradeDedupe", "dedupeUser", "httpPass", "", ""],
+ ["https://example.org", null, "schemeUpgradeDedupe", "dedupeUser", "httpsPass", "", ""]
+ );
+});
+
+add_task(async function test_iframe() {
+ let state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "mochiuser1",
+ passValue: "mochipass1",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ promptDone = handlePrompt(state, action);
+
+ // The following tests are driven by iframe loads
+
+ var iframeLoaded = onloadPromiseFor("iframe");
+ iframe.src = "authenticate.sjs?user=mochiuser1&pass=mochipass1";
+ await promptDone;
+ await iframeLoaded;
+ await checkEchoedAuthInfo({user: "mochiuser1", pass: "mochipass1"},
+ iframe);
+
+ state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "mochiuser2",
+ passValue: "mochipass2",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ };
+ promptDone = handlePrompt(state, action);
+ // We've already authenticated to this host:port. For this next
+ // request, the existing auth should be sent, we'll get a 401 reply,
+ // and we should prompt for new auth.
+ iframeLoaded = onloadPromiseFor("iframe");
+ iframe.src = "authenticate.sjs?user=mochiuser2&pass=mochipass2&realm=mochitest2";
+ await promptDone;
+ await iframeLoaded;
+ await checkEchoedAuthInfo({user: "mochiuser2", pass: "mochipass2"},
+ iframe);
+
+ // Now make a load that requests the realm from test 1000. It was
+ // already provided there, so auth will *not* be prompted for -- the
+ // networking layer already knows it!
+ iframeLoaded = onloadPromiseFor("iframe");
+ iframe.src = "authenticate.sjs?user=mochiuser1&pass=mochipass1";
+ await iframeLoaded;
+ await checkEchoedAuthInfo({user: "mochiuser1", pass: "mochipass1"},
+ iframe);
+
+ // Same realm we've already authenticated to, but with a different
+ // expected password (to trigger an auth prompt, and change-password
+ // popup notification).
+ state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "mochiuser1",
+ passValue: "mochipass1",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "mochipass1-new",
+ };
+ promptDone = handlePrompt(state, action);
+ iframeLoaded = onloadPromiseFor("iframe");
+ let promptShownPromise = promisePromptShown("passwordmgr-prompt-change");
+ iframe.src = "authenticate.sjs?user=mochiuser1&pass=mochipass1-new";
+ await promptDone;
+ await iframeLoaded;
+ await checkEchoedAuthInfo({user: "mochiuser1", pass: "mochipass1-new"},
+ iframe);
+ await promptShownPromise;
+
+ // Same as last test, but for a realm we haven't already authenticated
+ // to (but have an existing saved login for, so that we'll trigger
+ // a change-password popup notification.
+ state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "mochiuser3",
+ passValue: "mochipass3-old",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ passField: "mochipass3-new",
+ };
+ promptDone = handlePrompt(state, action);
+ iframeLoaded = onloadPromiseFor("iframe");
+ promptShownPromise = promisePromptShown("passwordmgr-prompt-change");
+ iframe.src = "authenticate.sjs?user=mochiuser3&pass=mochipass3-new&realm=mochitest3";
+ await promptDone;
+ await iframeLoaded;
+ await checkEchoedAuthInfo({user: "mochiuser3", pass: "mochipass3-new"},
+ iframe);
+ await promptShownPromise;
+
+ // Housekeeping: Delete login4 to test the save prompt in the next test.
+ runInParent(() => {
+ var tmpLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].
+ createInstance(Ci.nsILoginInfo);
+ tmpLogin.init("http://mochi.test:8888", null, "mochitest3",
+ "mochiuser3", "mochipass3-old", "", "");
+ Services.logins.removeLogin(tmpLogin);
+
+ // Clear cached auth from this subtest, and avoid leaking due to bug 459620.
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].
+ getService(Ci.nsIHttpAuthManager);
+ authMgr.clearAll();
+ });
+
+ state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "",
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ action = {
+ buttonClick: "ok",
+ textField: "mochiuser3",
+ passField: "mochipass3-old",
+ };
+ // Trigger a new prompt, so we can test adding a new login.
+ promptDone = handlePrompt(state, action);
+
+ iframeLoaded = onloadPromiseFor("iframe");
+ promptShownPromise = promisePromptShown("passwordmgr-prompt-save");
+ iframe.src = "authenticate.sjs?user=mochiuser3&pass=mochipass3-old&realm=mochitest3";
+ await promptDone;
+ await iframeLoaded;
+ await checkEchoedAuthInfo({user: "mochiuser3", pass: "mochipass3-old"},
+ iframe);
+ await promptShownPromise;
+});
+
+add_task(async function test_schemeUpgrade() {
+ let state = {
+ msg: "This site is asking you to sign in. Warning: Your login information " +
+ "will be shared with example.com, not the website you are currently visiting.",
+ title: "Authentication Required",
+ textValue: "httpUser",
+ passValue: "httpPass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ let promptDone = handlePrompt(state, action);
+
+ // The following tests are driven by iframe loads
+
+ let iframeLoaded = onloadPromiseFor("iframe");
+ iframe.src = "https://example.com" + AUTHENTICATE_PATH +
+ "?user=httpUser&pass=httpPass&realm=schemeUpgrade";
+ await promptDone;
+ await iframeLoaded;
+ await checkEchoedAuthInfo({user: "httpUser", pass: "httpPass"},
+ iframe);
+});
+
+add_task(async function test_schemeDowngrade() {
+ const state = {
+ msg: "This site is asking you to sign in. Warning: Your login information " +
+ "will be shared with example.com, not the website you are currently visiting.",
+ title: "Authentication Required",
+ textValue: "", // empty because we shouldn't downgrade
+ passValue: "",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "cancel",
+ };
+ const promptDone = handlePrompt(state, action);
+
+ // The following tests are driven by iframe loads
+
+ const iframeLoaded = onloadPromiseFor("iframe");
+ iframe.src = "http://example.com" + AUTHENTICATE_PATH +
+ "?user=unused&pass=unused&realm=schemeDowngrade";
+ await promptDone;
+ await iframeLoaded;
+});
+
+add_task(async function test_schemeUpgrade_dedupe() {
+ const state = {
+ msg: "This site is asking you to sign in. Warning: Your login information " +
+ "will be shared with example.org, not the website you are currently visiting.",
+ title: "Authentication Required",
+ textValue: "dedupeUser",
+ passValue: "httpsPass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "ok",
+ };
+ const promptDone = handlePrompt(state, action);
+
+ // The following tests are driven by iframe loads
+
+ const iframeLoaded = onloadPromiseFor("iframe");
+ iframe.src = "https://example.org" + AUTHENTICATE_PATH +
+ "?user=dedupeUser&pass=httpsPass&realm=schemeUpgradeDedupe";
+ await promptDone;
+ await iframeLoaded;
+ await checkEchoedAuthInfo({user: "dedupeUser", pass: "httpsPass"},
+ iframe);
+});
+</script>
+</pre>
+</body>
+</html>
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..19d05e47e5
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_noWindow.html
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test HTTP auth prompts by loading authenticate.sjs with no window</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../prompts/test/prompt_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+// Let prompt_common know what kind of modal type is enabled for auth prompts.
+modalType = authPromptModalType;
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ ["http://mochi.test:8888", null, "mochitest", "mochiuser1", "mochipass1", "", ""]
+ );
+});
+
+add_task(async function test_sandbox_xhr() {
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "mochiuser1",
+ passValue: "mochipass1",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "ok",
+ };
+ const promptDone = handlePrompt(state, action);
+
+ const url = new URL("authenticate.sjs?user=mochiuser1&pass=mochipass1", window.location.href);
+ const sandboxConstructor = SpecialPowers.Cu.Sandbox;
+ const sandbox = new sandboxConstructor(this, {wantXrays: true});
+ function sandboxedRequest(sandboxedUrl) {
+ const req = new XMLHttpRequest();
+ req.open("GET", sandboxedUrl, true);
+ req.send(null);
+ }
+
+ const loginModifiedPromise = promiseStorageChanged(["modifyLogin"]);
+ sandbox.sandboxedRequest = sandboxedRequest(url);
+ info("send the XHR request in the sandbox");
+ SpecialPowers.Cu.evalInSandbox("sandboxedRequest;", sandbox);
+
+ await promptDone;
+ info("prompt shown, waiting for metadata updates");
+ // Ensure the timeLastUsed and timesUsed metadata are updated.
+ await loginModifiedPromise;
+});
+</script>
+</pre>
+</body>
+</html>
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..b82ea67faa
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth.html
@@ -0,0 +1,370 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test promptAuth prompts</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../prompts/test/prompt_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+const level = Ci.nsIAuthPrompt2.LEVEL_NONE;
+const authinfo = {
+ username: "",
+ password: "",
+ domain: "",
+
+ flags: Ci.nsIAuthInformation.AUTH_HOST,
+ authenticationScheme: "basic",
+ realm: "",
+};
+
+// Let prompt_common know what kind of modal type is enabled for auth prompts.
+modalType = authPromptModalType;
+
+const prompterParent = runInParent(() => {
+ const promptFac = Cc["@mozilla.org/passwordmanager/authpromptfactory;1"].
+ getService(Ci.nsIPromptFactory);
+
+ const chromeWin = Services.wm.getMostRecentWindow("navigator:browser");
+ const prompter2 = promptFac.getPrompt(chromeWin, Ci.nsIAuthPrompt2);
+ prompter2.QueryInterface(Ci.nsILoginManagerAuthPrompter).browser = chromeWin.gBrowser.selectedBrowser;
+
+ const channels = {};
+ channels.channel1 = Services.io.newChannel("http://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);
+
+ channels.channel2 = Services.io.newChannel("http://example2.com",
+ null,
+ null,
+ null, // aLoadingNode
+ Services.
+ scriptSecurityManager.getSystemPrincipal(),
+ null, // aTriggeringPrincipal
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER);
+
+ addMessageListener("proxyPrompter", function onMessage(msg) {
+ const args = [...msg.args];
+ const channelName = args.shift();
+ // Replace the channel name string (arg. 0) with the channel by that name.
+ args.unshift(channels[channelName]);
+
+ const rv = prompter2[msg.methodName](...args);
+ return {
+ rv,
+ // Send the args back to content so out/inout args can be checked.
+ args: msg.args,
+ };
+ });
+});
+
+const prompter2 = new PrompterProxy(prompterParent);
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ ["http://example.com", null, "http://example.com", "", "examplepass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "user1name", "user1pass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "user2name", "user2pass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "user3.name@host", "user3pass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "100@beef", "user3pass", "", ""],
+ ["http://example2.com", null, "http://example2.com", "100%beef", "user3pass", "", ""]
+ );
+});
+
+add_task(async function test_accept() {
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "inuser",
+ passValue: "inpass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "ok",
+ textField: "outuser",
+ passField: "outpass",
+ };
+ authinfo.username = "inuser";
+ authinfo.password = "inpass";
+ authinfo.realm = "some realm";
+
+ promptDone = handlePrompt(state, action);
+ // Since prompter2 is actually a proxy to send a message to a chrome script and
+ // we can't send a channel in a message, we instead send the channel name that
+ // already exists in the chromeScript.
+ const isOk = prompter2.promptAuth("channel1", level, authinfo);
+ await promptDone;
+
+ ok(isOk, "Checking dialog return value (accept)");
+ is(authinfo.username, "outuser", "Checking returned username");
+ is(authinfo.password, "outpass", "Checking returned password");
+});
+
+add_task(async function test_cancel() {
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "outuser",
+ passValue: "outpass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "cancel",
+ };
+ promptDone = handlePrompt(state, action);
+ const isOk = prompter2.promptAuth("channel1", level, authinfo);
+ await promptDone;
+
+ ok(!isOk, "Checking dialog return value (cancel)");
+});
+
+add_task(async function test_pwonly() {
+ // test filling in password-only login
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "",
+ passValue: "examplepass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "ok",
+ };
+ authinfo.username = "";
+ authinfo.password = "";
+ authinfo.realm = "http://example.com";
+
+ promptDone = handlePrompt(state, action);
+ const isOk = prompter2.promptAuth("channel1", level, authinfo);
+ await promptDone;
+
+ ok(isOk, "Checking dialog return value (accept)");
+ is(authinfo.username, "", "Checking returned username");
+ is(authinfo.password, "examplepass", "Checking returned password");
+});
+
+add_task(async function test_multipleExisting() {
+ // test filling in existing login (undetermined from multiple selection)
+ // user2name/user2pass would also be valid to fill here.
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "user1name",
+ passValue: "user1pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ const action = {
+ buttonClick: "ok",
+ };
+ authinfo.username = "";
+ authinfo.password = "";
+ authinfo.realm = "http://example2.com";
+
+ promptDone = handlePrompt(state, action);
+ const isOk = prompter2.promptAuth("channel2", level, authinfo);
+ await promptDone;
+
+ ok(isOk, "Checking dialog return value (accept)");
+ ok(authinfo.username == "user1name" || authinfo.username == "user2name", "Checking returned username");
+ ok(authinfo.password == "user1pass" || authinfo.password == "user2pass", "Checking returned password");
+});
+
+add_task(async function test_multipleExisting2() {
+ // test filling in existing login (undetermined --> user1)
+ // user2name/user2pass would also be valid to fill here.
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "user1name",
+ passValue: "user1pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ // enter one of the known logins, test 504+505 exercise the two possible states.
+ const action = {
+ buttonClick: "ok",
+ textField: "user1name",
+ passField: "user1pass",
+ };
+ authinfo.username = "";
+ authinfo.password = "";
+ authinfo.realm = "http://example2.com";
+
+ promptDone = handlePrompt(state, action);
+ const isOk = prompter2.promptAuth("channel2", level, authinfo);
+ await promptDone;
+
+ ok(isOk, "Checking dialog return value (accept)");
+ is(authinfo.username, "user1name", "Checking returned username");
+ is(authinfo.password, "user1pass", "Checking returned password");
+});
+
+add_task(async function test_multipleExisting3() {
+ // test filling in existing login (undetermined --> user2)
+ // user2name/user2pass would also be valid to fill here.
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "user1name",
+ passValue: "user1pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ // enter one of the known logins, test 504+505 exercise the two possible states.
+ const action = {
+ buttonClick: "ok",
+ textField: "user2name",
+ passField: "user2pass",
+ };
+ authinfo.username = "";
+ authinfo.password = "";
+ authinfo.realm = "http://example2.com";
+
+ promptDone = handlePrompt(state, action);
+ const isOk = prompter2.promptAuth("channel2", level, authinfo);
+ await promptDone;
+
+ ok(isOk, "Checking dialog return value (accept)");
+ is(authinfo.username, "user2name", "Checking returned username");
+ is(authinfo.password, "user2pass", "Checking returned password");
+});
+
+add_task(async function test_changingMultiple() {
+ // test changing a password (undetermined --> user2 w/ newpass)
+ // user2name/user2pass would also be valid to fill here.
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "user1name",
+ passValue: "user1pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ // force to user2, and change the password
+ const action = {
+ buttonClick: "ok",
+ textField: "user2name",
+ passField: "NEWuser2pass",
+ };
+ authinfo.username = "";
+ authinfo.password = "";
+ authinfo.realm = "http://example2.com";
+
+ promptDone = handlePrompt(state, action);
+ const isOk = prompter2.promptAuth("channel2", level, authinfo);
+ await promptDone;
+
+ ok(isOk, "Checking dialog return value (accept)");
+ is(authinfo.username, "user2name", "Checking returned username");
+ is(authinfo.password, "NEWuser2pass", "Checking returned password");
+});
+
+add_task(async function test_changingMultiple2() {
+ // test changing a password (undetermined --> user2 w/ origpass)
+ // user2name/user2pass would also be valid to fill here.
+ const state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "user1name",
+ passValue: "user1pass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ // force to user2, and change the password back
+ const action = {
+ buttonClick: "ok",
+ textField: "user2name",
+ passField: "user2pass",
+ };
+ authinfo.username = "";
+ authinfo.password = "";
+ authinfo.realm = "http://example2.com";
+
+ promptDone = handlePrompt(state, action);
+ const isOk = prompter2.promptAuth("channel2", level, authinfo);
+ await promptDone;
+
+ ok(isOk, "Checking dialog return value (accept)");
+ is(authinfo.username, "user2name", "Checking returned username");
+ is(authinfo.password, "user2pass", "Checking returned password");
+});
+</script>
+</pre>
+</body>
+</html>
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..6f78d62f1b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_prompt_promptAuth_proxy.html
@@ -0,0 +1,269 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test promptAuth proxy prompts</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../prompts/test/prompt_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content" style="display: none">
+ <iframe id="iframe"></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+/* eslint-disable mozilla/use-chromeutils-generateqi */
+
+const LEVEL = Ci.nsIAuthPrompt2.LEVEL_NONE;
+
+let proxyAuthinfo = {
+ username: "",
+ password: "",
+ domain: "",
+
+ flags: Ci.nsIAuthInformation.AUTH_PROXY,
+ authenticationScheme: "basic",
+ realm: "",
+};
+
+// Let prompt_common know what kind of modal type is enabled for auth prompts.
+modalType = authPromptModalType;
+
+let chromeScript = runInParent(() => {
+ const promptFac = Cc[
+ "@mozilla.org/passwordmanager/authpromptfactory;1"
+ ].getService(Ci.nsIPromptFactory);
+
+ let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+ let chromeWin = Services.wm.getMostRecentWindow("navigator:browser");
+ let prompter2 = promptFac.getPrompt(chromeWin, Ci.nsIAuthPrompt2);
+ prompter2.QueryInterface(Ci.nsILoginManagerAuthPrompter).browser =
+ chromeWin.gBrowser.selectedBrowser;
+
+ let mozproxyURL;
+ let proxyChannel;
+
+ addMessageListener("init", () => init());
+
+ addMessageListener("proxyPrompter", function onMessage(msg) {
+ let args = [...msg.args];
+
+ args[0] = proxyChannel;
+ let rv = prompter2[msg.methodName](...args);
+ return {
+ rv,
+ // Send the args back to content so out/inout args can be checked.
+ args: msg.args,
+ };
+ });
+
+ addMessageListener("getTimeLastUsed", async () => {
+ let logins = await Services.logins.searchLoginsAsync({ origin: mozproxyURL, httpRealm: "Proxy Realm"});
+ return logins[0].QueryInterface(Ci.nsILoginMetaInfo).timeLastUsed;
+ });
+
+ function init() {
+ // 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(
+ "http://example.com",
+ null,
+ null,
+ null, // aLoadingNode
+ systemPrincipal,
+ null, // aTriggeringPrincipal
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ pps.asyncResolve(channel, 0, resolveCallback);
+ }
+
+ class ProxyChannelListener {
+ onStartRequest(request) {
+ sendAsyncMessage("initDone");
+ }
+ onStopRequest(request, status) {}
+ }
+
+ async function initLogins(pi) {
+ mozproxyURL = `moz-proxy://${pi.host}:${pi.port}`;
+
+ let proxyLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+
+ proxyLogin.init(
+ mozproxyURL,
+ null,
+ "Proxy Realm",
+ "proxuser",
+ "proxpass",
+ "",
+ ""
+ );
+
+ await Services.logins.addLoginAsync(proxyLogin);
+ }
+
+ let resolveCallback = {
+ QueryInterface(iid) {
+ const interfaces = [Ci.nsIProtocolProxyCallback, Ci.nsISupports];
+
+ if (
+ !interfaces.some(function (v) {
+ return iid.equals(v);
+ })
+ ) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+ return this;
+ },
+
+ async onProxyAvailable(req, uri, pi, status) {
+ await initLogins(pi);
+
+ // 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
+ systemPrincipal,
+ null, // aTriggeringPrincipal
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ proxyChannel.asyncOpen(new ProxyChannelListener());
+ },
+ };
+});
+
+let prompter2 = new PrompterProxy(chromeScript);
+
+add_setup(async () => {
+ let initComplete = new Promise((resolve) =>
+ chromeScript.addMessageListener("initDone", resolve)
+ );
+ chromeScript.sendAsyncMessage("init");
+ info("Waiting for startup to complete...");
+ await initComplete;
+});
+
+add_task(async function test_noAutologin() {
+ // test proxy login (default = no autologin), make sure it prompts.
+ let state = {
+ msg:
+ "The proxy moz-proxy://127.0.0.1:8888 is requesting a username and password. The site says: “Proxy Realm”",
+ title: "Authentication Required",
+ textValue: "proxuser",
+ passValue: "proxpass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ proxyAuthinfo.username = "";
+ proxyAuthinfo.password = "";
+ proxyAuthinfo.realm = "Proxy Realm";
+ proxyAuthinfo.flags = Ci.nsIAuthInformation.AUTH_PROXY;
+
+ let time1 = await chromeScript.sendQuery("getTimeLastUsed");
+ promptDone = handlePrompt(state, action);
+ let isOk = prompter2.promptAuth(null, LEVEL, proxyAuthinfo);
+ await promptDone;
+ let time2 = await chromeScript.sendQuery("getTimeLastUsed");
+
+ ok(isOk, "Checking dialog return value (accept)");
+ isnot(time1, time2, "Checking that timeLastUsed was updated");
+ is(proxyAuthinfo.username, "proxuser", "Checking returned username");
+ is(proxyAuthinfo.password, "proxpass", "Checking returned password");
+});
+
+add_task(async function test_autologin() {
+ // test proxy login (with autologin)
+
+ // Enable the autologin pref.
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.autologin.proxy", true]],
+ });
+
+ proxyAuthinfo.username = "";
+ proxyAuthinfo.password = "";
+ proxyAuthinfo.realm = "Proxy Realm";
+ proxyAuthinfo.flags = Ci.nsIAuthInformation.AUTH_PROXY;
+
+ let time1 = await chromeScript.sendQuery("getTimeLastUsed");
+ let isOk = prompter2.promptAuth(null, LEVEL, proxyAuthinfo);
+ let time2 = await chromeScript.sendQuery("getTimeLastUsed");
+
+ ok(isOk, "Checking dialog return value (accept)");
+ isnot(time1, time2, "Checking that timeLastUsed was updated");
+ is(proxyAuthinfo.username, "proxuser", "Checking returned username");
+ is(proxyAuthinfo.password, "proxpass", "Checking returned password");
+});
+
+add_task(async function test_autologin_incorrect() {
+ // test proxy login (with autologin), ensure it prompts after a failed auth.
+ let state = {
+ msg:
+ "The proxy moz-proxy://127.0.0.1:8888 is requesting a username and password. The site says: “Proxy Realm”",
+ title: "Authentication Required",
+ textValue: "proxuser",
+ passValue: "proxpass",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+
+ proxyAuthinfo.username = "";
+ proxyAuthinfo.password = "";
+ proxyAuthinfo.realm = "Proxy Realm";
+ proxyAuthinfo.flags =
+ Ci.nsIAuthInformation.AUTH_PROXY | Ci.nsIAuthInformation.PREVIOUS_FAILED;
+
+ let time1 = await chromeScript.sendQuery("getTimeLastUsed");
+ promptDone = handlePrompt(state, action);
+ let isOk = prompter2.promptAuth(null, LEVEL, proxyAuthinfo);
+ await promptDone;
+ let time2 = await chromeScript.sendQuery("getTimeLastUsed");
+
+ ok(isOk, "Checking dialog return value (accept)");
+ isnot(time1, time2, "Checking that timeLastUsed was updated");
+ is(proxyAuthinfo.username, "proxuser", "Checking returned username");
+ is(proxyAuthinfo.password, "proxpass", "Checking returned password");
+});
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for recipes overriding login fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script>
+gTestDependsOnDeprecatedLogin = true;
+var chromeScript = runChecksAfterCommonInit();
+
+let fillPromiseResolvers = [];
+
+function waitForFills(fillCount) {
+ let promises = [];
+ while (fillCount--) {
+ let promise = new Promise(resolve => fillPromiseResolvers.push(resolve));
+ promises.push(promise);
+ }
+
+ return Promise.all(promises);
+}
+
+add_setup(async () => {
+ // This test should run without any existing loaded recipes interfering.
+ await resetRecipes();
+
+ if (document.readyState !== "complete") {
+ await new Promise((resolve) => {
+ document.onreadystatechange = () => {
+ if (document.readyState !== "complete") {
+ return;
+ }
+ document.onreadystatechange = null;
+ resolve();
+ };
+ });
+ }
+
+ document.getElementById("content")
+ .addEventListener("input", function handleInputEvent(evt) {
+ let resolve = fillPromiseResolvers.shift();
+ if (!resolve) {
+ ok(false, "Too many fills");
+ return;
+ }
+
+ resolve(evt.target);
+ });
+});
+
+add_task(async function loadUsernamePasswordSelectorRecipes() {
+ await loadRecipes({
+ siteRecipes: [{
+ hosts: ["mochi.test:8888"],
+ usernameSelector: "input[name='uname1']",
+ passwordSelector: "input[name='pword2']",
+ }],
+ });
+});
+
+add_task(async function testOverriddingFields() {
+ // Insert the form dynamically so autofill is triggered after setup above.
+ document.getElementById("content").innerHTML = `
+ <!-- form with recipe for the username and password -->
+ <form id="form1">
+ <input type="text" name="uname1" data-expected="true">
+ <input type="text" name="uname2" data-expected="false">
+ <input type="password" name="pword1" data-expected="false">
+ <input type="password" name="pword2" data-expected="true">
+ </form>`;
+
+ let elements = await waitForFills(2);
+ for (let element of elements) {
+ is(element.dataset.expected, "true", `${element.name} was filled`);
+ }
+});
+
+add_task(async function testDefaultHeuristics() {
+ // Insert the form dynamically so autofill is triggered after setup above.
+ document.getElementById("content").innerHTML = `
+ <!-- Fallback to the default heuristics since the selectors don't match -->
+ <form id="form2">
+ <input type="text" name="uname3" data-expected="false">
+ <input type="text" name="uname4" data-expected="true">
+ <input type="password" name="pword3" data-expected="true">
+ <input type="password" name="pword4" data-expected="false">
+ </form>`;
+
+ let elements = await waitForFills(2);
+ for (let element of elements) {
+ is(element.dataset.expected, "true", `${element.name} was filled`);
+ }
+});
+
+add_task(async function loadNotUsernameSelectorRecipes() {
+ await resetRecipes();
+ await loadRecipes({
+ siteRecipes: [{
+ hosts: ["mochi.test:8888"],
+ notUsernameSelector: "input[name='not_uname1']",
+ }],
+ });
+});
+
+add_task(async function testNotUsernameField() {
+ document.getElementById("content").innerHTML = `
+ <!-- The field matching notUsernameSelector should be skipped -->
+ <form id="form3">
+ <input type="text" name="uname5" data-expected="true">
+ <input type="text" name="not_uname1" data-expected="false">
+ <input type="password" name="pword5" data-expected="true">
+ </form>`;
+
+ let elements = await waitForFills(2);
+ for (let element of elements) {
+ is(element.dataset.expected, "true", `${element.name} was filled`);
+ }
+});
+
+add_task(async function testNotUsernameFieldNoUsername() {
+ document.getElementById("content").innerHTML = `
+ <!-- The field matching notUsernameSelector should be skipped.
+ No username field should be found and filled in this case -->
+ <form id="form4">
+ <input type="text" name="not_uname1" data-expected="false">
+ <input type="password" name="pword6" data-expected="true">
+ </form>`;
+
+ let elements = await waitForFills(1);
+ for (let element of elements) {
+ is(element.dataset.expected, "true", `${element.name} was filled`);
+ }
+});
+
+add_task(async function loadNotPasswordSelectorRecipes() {
+ await resetRecipes();
+ await loadRecipes({
+ siteRecipes: [{
+ hosts: ["mochi.test:8888"],
+ notPasswordSelector: "input[name='not_pword'], input[name='not_pword2']",
+ }],
+ });
+});
+
+add_task(async function testNotPasswordField() {
+ document.getElementById("content").innerHTML = `
+ <!-- The field matching notPasswordSelector should be skipped -->
+ <form id="form5">
+ <input type="text" name="uname7" data-expected="true">
+ <input type="password" name="not_pword" data-expected="false">
+ <input type="password" name="pword7" data-expected="true">
+ </form>`;
+
+ let elements = await waitForFills(2);
+ for (let element of elements) {
+ is(element.dataset.expected, "true", `${element.name} was filled`);
+ }
+});
+
+add_task(async function testNotPasswordFieldNoPassword() {
+ document.getElementById("content").innerHTML = `
+ <!-- The field matching notPasswordSelector should be skipped.
+ No username or password field should be found and filled in this case.
+ A dummy form7 is added after so we know when the login manager is done
+ considering filling form6. -->
+ <form id="form6">
+ <input type="text" name="uname8" data-expected="false">
+ <input type="password" name="not_pword" data-expected="false">
+ </form>
+ <form id="form7">
+ <input type="password" name="pword9" data-expected="true">
+ </form>`;
+
+ let elements = await waitForFills(1);
+ for (let element of elements) {
+ is(element.dataset.expected, "true", `${element.name} was filled`);
+ }
+});
+
+add_task(async function testNotPasswordField_tooManyToOkay() {
+ document.getElementById("content").innerHTML = `
+ <!-- The field matching notPasswordSelector should be skipped so we won't
+ have too many pw fields to handle (3). -->
+ <form id="form8">
+ <input type="text" name="uname9" data-expected="true">
+ <input type="password" name="not_pword2" data-expected="false">
+ <input type="password" name="not_pword" data-expected="false">
+ <input type="password" name="pword10" data-expected="true">
+ <input type="password" name="pword11" data-expected="false">
+ <input type="password" name="pword12" data-expected="false">
+ </form>`;
+
+ let elements = await waitForFills(2);
+ for (let element of elements) {
+ is(element.dataset.expected, "true", `${element.name} was filled`);
+ }
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ // Forms are inserted dynamically
+</div>
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/toolkit/components/passwordmgr/test/mochitest/test_set_stored_logins_during_task.html b/toolkit/components/passwordmgr/test/mochitest/test_set_stored_logins_during_task.html
new file mode 100644
index 0000000000..36db568708
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_set_stored_logins_during_task.html
@@ -0,0 +1,50 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Login Manager Test Helper: setLoginsDuringTest</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+Testing the test helpers `setStoredLoginsDuringTask` and `setStoredLoginsDuringTest`, which prepares the login storage during a test or task.
+
+<script class="testbody" type="text/javascript">
+add_setup(async () => {
+ await setStoredLoginsDuringTest(
+ ["https://example.com", "https://example.com", null, "example.com-user-one", "password", "uname", "pword"],
+ ["https://example.com", "https://example.com", null, "example.com-user-two", "password", "uname", "pword"],
+ );
+});
+
+add_named_task("logins should have been stored during setup", async () => {
+ const logins = await getLogins();
+ is(logins.length, 2, "two logins from setup should have been stored");
+ is(logins[0][3], "example.com-user-one", "Login one should have been restored");
+ is(logins[1][3], "example.com-user-two", "Login two should have been restored");
+});
+
+add_named_task("logins should be overwritten", async () => {
+ await setStoredLoginsDuringTask(
+ ["https://example.com", "https://example.com", null, "example.com-user-three", "password", "uname", "pword"],
+ );
+ const logins = await getLogins();
+ is(logins.length, 1, "logins should have been set to single login");
+ is(logins[0][3], "example.com-user-three", "Login three should have been restored");
+});
+
+add_named_task("logins should get restored", async () => {
+ const logins = await getLogins();
+ is(logins.length, 2, "Logins should have been restored");
+ is(logins[0][3], "example.com-user-one", "Login one should have been restored");
+ is(logins[1][3], "example.com-user-two", "Login two should have been restored");
+});
+</script>
+</pre>
+</body>
+</html>
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..c31efe6a98
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_submit_without_field_modifications.html
@@ -0,0 +1,311 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Don't send onFormSubmit message on navigation if the user did not interact
+ with the login fields</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+
+<div id="content">
+ <iframe id="loginFrame">
+ </iframe>
+</div>
+
+<pre id="test"></pre>
+<script>
+SimpleTest.requestFlakyTimeout("Giving a chance for the unexpected popup to show");
+
+const EXAMPLE_COM = window.location.origin + "/tests/toolkit/components/passwordmgr/test/mochitest/";
+const PREFILLED_FORM_URL = EXAMPLE_COM + "subtst_prefilled_form.html"
+
+let iframe = document.getElementById("loginFrame");
+
+function waitForLoad() {
+ return new Promise(resolve => {
+ function handleLoad() {
+ iframe.removeEventListener("load", handleLoad);
+ resolve();
+ }
+ iframe.addEventListener("load", handleLoad);
+ });
+}
+
+async function setupWithOneLogin(pageUrl) {
+ let origin = window.location.origin;
+ await setStoredLoginsAsync([origin, origin, null, "user1", "pass1"]);
+
+ let chromeScript = runInParent(async function testSetup() {
+ let logins = await Services.logins.getAllLogins();
+ for (let l of logins) {
+ info("Got login: " + l.username + ", " + l.password);
+ }
+ });
+
+ await setup(pageUrl);
+ return chromeScript;
+}
+
+function resetSavedLogins() {
+ let chromeScript = runInParent(function testTeardown() {
+ Services.logins.removeAllUserFacingLogins();
+ });
+ chromeScript.destroy();
+}
+
+async function setup(pageUrl) {
+ let loadPromise = waitForLoad();
+ let processedFormPromise = promiseFormsProcessed();
+ iframe.src = pageUrl;
+
+ await processedFormPromise;
+ info("initial form processed");
+ await loadPromise;
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ let doc = this.content.document;
+ let link = doc.createElement("a");
+ link.setAttribute("href", "http://mochi.test:8888");
+ doc.body.appendChild(link);
+ });
+}
+
+async function navigateWithoutUserInteraction() {
+ let loadPromise = waitForLoad();
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ let doc = this.content.document;
+ let hadInteracted = doc.userHasInteracted;
+ let target = doc.querySelector("a[href]");
+ if (target) {
+ target.click();
+ } else {
+ target = doc.querySelector("form");
+ target.submit();
+ }
+ is(doc.userHasInteracted, hadInteracted, "document.userHasInteracted shouldn't have changed");
+ });
+ await loadPromise;
+}
+
+async function userInput(selector, value) {
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [selector, value], async function(sel, val) {
+ // use "real" synthesized events rather than setUserInput to ensure
+ // document.userHasInteracted is flipped true
+ let EventUtils = ContentTaskUtils.getEventUtils(content);
+ let target = this.content.document.querySelector(sel);
+ target.focus();
+ target.select();
+ await EventUtils.synthesizeKey("KEY_Backspace", {}, this.content);
+ await EventUtils.sendString(val, this.content);
+ info(
+ `userInput: new target.value: ${target.value}`
+ );
+ target.blur();
+ return Promise.resolve();
+ });
+}
+
+function checkDocumentUserHasInteracted() {
+ return SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ return this.content.document.userHasInteracted;
+ });
+}
+
+add_task(async function test_init() {
+ // For this test, we'll be testing with & without user document interaction.
+ // So we'll reset the pref which dictates the behavior of
+ // LoginFormState._formHasModifiedFields in automation
+ // and ensure all interactions are properly emulated
+ ok(SpecialPowers.getBoolPref("signon.testOnlyUserHasInteractedByPrefValue"), "signon.testOnlyUserHasInteractedByPrefValue should default to true");
+ info("test_init, flipping the signon.testOnlyUserHasInteractedByPrefValue pref");
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["signon.testOnlyUserHasInteractedByPrefValue", false],
+ ]});
+ SimpleTest.registerCleanupFunction(async function cleanup_pref() {
+ await SpecialPowers.popPrefEnv();
+ });
+
+ await SimpleTest.promiseWaitForCondition(() => LoginHelper.testOnlyUserHasInteractedWithDocument === null);
+ is(LoginHelper.testOnlyUserHasInteractedWithDocument, null,
+ "LoginHelper.testOnlyUserHasInteractedWithDocument should be null for this set of tests");
+});
+
+add_task(async function test_no_message_on_navigation() {
+ // If login field values were set by the website, we don't message to save the
+ // login values if the user did not interact with the fields before submiting.
+ await setup(PREFILLED_FORM_URL);
+
+ let submitMessageSent = false;
+ getSubmitMessage().then(value => {
+ submitMessageSent = true;
+ });
+ await navigateWithoutUserInteraction();
+
+ // allow time to pass before concluding no onFormSubmit message was sent
+ await new Promise(res => setTimeout(res, 1000));
+ ok(!submitMessageSent, "onFormSubmit message is not sent on navigation since the login fields were not modified");
+});
+
+add_task(async function test_prefd_off_message_on_navigation() {
+ // Confirm the pref controls capture behavior with non-user-set field values.
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["signon.userInputRequiredToCapture.enabled", false],
+ ]});
+ await setup(PREFILLED_FORM_URL);
+
+ let promiseSubmitMessage = getSubmitMessage();
+ await navigateWithoutUserInteraction();
+ await promiseSubmitMessage;
+ info("onFormSubmit message was sent as expected after navigation");
+
+ SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_message_with_user_interaction_on_navigation() {
+ await setup(PREFILLED_FORM_URL);
+ await userInput("#form-basic-username", "foo");
+
+ let promiseSubmitMessage = getSubmitMessage();
+ await navigateWithoutUserInteraction();
+ await promiseSubmitMessage;
+ info("onFormSubmit message was sent as expected after user interaction");
+});
+
+add_task(async function test_empty_form_with_input_handler() {
+ await setup(EXAMPLE_COM + "formless_basic.html");
+ await userInput("#form-basic-username", "user");
+ await userInput("#form-basic-password", "pass");
+
+ let promiseSubmitMessage = getSubmitMessage();
+ await navigateWithoutUserInteraction();
+ await promiseSubmitMessage;
+ info("onFormSubmit message was sent as expected after user interaction");
+});
+
+add_task(async function test_no_message_on_autofill_without_user_interaction() {
+ let chromeScript = await setupWithOneLogin(EXAMPLE_COM + "form_basic.html");
+ // Check for autofilled values.
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0),
+ "form-basic-username", "user1",
+ "form-basic-password", "pass1");
+
+ info("LoginHelper.testOnlyUserHasInteractedWithDocument:" +
+ LoginHelper.testOnlyUserHasInteractedWithDocument
+ );
+ ok(!(await checkDocumentUserHasInteracted()), "document.userHasInteracted should be initially false");
+ let submitMessageSent = false;
+ getSubmitMessage().then(value => {
+ submitMessageSent = true;
+ });
+ info("Navigating the page")
+ await navigateWithoutUserInteraction();
+
+ // allow time to pass before concluding no onFormSubmit message was sent
+ await new Promise(res => setTimeout(res, 1000));
+
+ chromeScript.destroy();
+ resetSavedLogins();
+
+ ok(!submitMessageSent, "onFormSubmit message is not sent on navigation since the document had no user interaction");
+});
+
+add_task(async function test_message_on_autofill_with_document_interaction() {
+ // We expect that as long as the form values !== their defaultValues,
+ // any document interaction allows the submit message to be sent
+
+ let chromeScript = await setupWithOneLogin(EXAMPLE_COM + "form_basic.html");
+ // Check for autofilled values.
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0),
+ "form-basic-username", "user1",
+ "form-basic-password", "pass1");
+
+ let userInteracted = await checkDocumentUserHasInteracted();
+ ok(!userInteracted, "document.userHasInteracted should be initially false");
+
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), ["#form-basic-username"], async function(sel) {
+ // Click somewhere in the document to ensure document.userHasInteracted is flipped to true
+ let EventUtils = ContentTaskUtils.getEventUtils(content);
+ let target = this.content.document.querySelector(sel);
+
+ await EventUtils.synthesizeMouseAtCenter(target, {}, this.content);
+ });
+
+ userInteracted = await checkDocumentUserHasInteracted();
+ ok(userInteracted, "After synthesizeMouseAtCenter, document.userHasInteracted should be true");
+
+ let promiseSubmitMessage = getSubmitMessage();
+ await navigateWithoutUserInteraction();
+
+ let { data } = await promiseSubmitMessage;
+ ok(data.autoFilledLoginGuid, "Message was sent with autoFilledLoginGuid");
+ info("Message was sent as expected after document user interaction");
+
+ chromeScript.destroy();
+ resetSavedLogins();
+});
+
+add_task(async function test_message_on_autofill_with_user_interaction() {
+ // Editing a field value causes the submit message to be sent as
+ // there is both document interaction and field modification
+ let chromeScript = await setupWithOneLogin(EXAMPLE_COM + "form_basic.html");
+ // Check for autofilled values.
+ await checkLoginFormInFrame(getIframeBrowsingContext(window, 0),
+ "form-basic-username", "user1",
+ "form-basic-password", "pass1");
+
+ userInput("#form-basic-username", "newuser");
+ let promiseSubmitMessage = getSubmitMessage();
+ await navigateWithoutUserInteraction();
+
+ let { data } = await promiseSubmitMessage;
+ ok(data.autoFilledLoginGuid, "Message was sent with autoFilledLoginGuid");
+ is(data.usernameField.value, "newuser", "Message was sent with correct usernameField.value");
+ info("Message was sent as expected after user form interaction");
+
+ chromeScript.destroy();
+ resetSavedLogins();
+});
+
+add_task(async function test_no_message_on_user_input_from_other_form() {
+ // ensure input into unrelated fields on the page don't change login form modified-ness
+ await setup(PREFILLED_FORM_URL);
+
+ // Add a form which will not be submitted and an input associated with that form
+ await SpecialPowers.spawn(getIframeBrowsingContext(window), [], function() {
+ let doc = this.content.document;
+ let loginForm = doc.querySelector("form");
+ let fragment = doc.createDocumentFragment();
+ let otherForm = doc.createElement("form");
+ otherForm.id ="otherForm";
+ fragment.appendChild(otherForm);
+
+ let alienField = doc.createElement("input");
+ alienField.id = "alienField";
+ alienField.type = "text"; // not a password field
+ alienField.setAttribute("form", "otherForm");
+ // new field is child of the login, but a member of different non-login form via its .form property
+ loginForm.appendChild(alienField);
+ doc.body.appendChild(fragment);
+ });
+ await userInput("#alienField", "something");
+
+ let submitMessageSent = false;
+ getSubmitMessage().then(data => {
+ info("submit mesage data: " + JSON.stringify(data));
+ submitMessageSent = true;
+ });
+
+ info("submitting the form");
+ await navigateWithoutUserInteraction();
+
+ // allow time to pass before concluding no onFormSubmit message was sent
+ await new Promise(res => setTimeout(res, 1000));
+ ok(!submitMessageSent, "onFormSubmit message is not sent on navigation since no login fields were modified");
+});
+
+</script>
+</body>
+</html>
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..9c2f9d8e89
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_username_focus.html
@@ -0,0 +1,166 @@
+
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test interaction between autocomplete and focus on username fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../../../satchel/test/satchel_common.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test">
+<script class="testbody" type="text/javascript">
+const action1 = "http://username-focus-1";
+const action2 = "http://username-focus-2";
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ [location.origin, action1, null, "testuser1A", "testpass1A", "", ""],
+ [location.origin, action2, null, "testuser2A", "testpass2A", "", ""],
+ [location.origin, action2, null, "testuser2B", "testpass2B", "", ""]
+ );
+});
+
+add_task(async function autofilled() {
+ const form = createLoginForm({ action: action1 });
+
+ info("Username and password already filled so don't show autocomplete");
+ await noPopupBy(() => form.uname.focus());
+
+ form.submit.focus();
+ form.uname.value = "testuser";
+ info("Focus when we don't have an exact match");
+ await popupBy(() => form.uname.focus());
+});
+
+add_task(async function autofilled_prefilled_un() {
+ const form = createLoginForm({
+ action: action1,
+ username: {
+ value: "testuser1A"
+ }
+ });
+
+ info("Username and password already filled so don't show autocomplete");
+ await noPopupBy(() => form.uname.focus());
+
+ form.submit.focus();
+ form.uname.value = "testuser";
+ info("Focus when we don't have an exact match");
+ await popupBy(() => form.uname.focus());
+});
+
+add_task(async function autofilled_focused_dynamic() {
+ const form = createLoginForm({
+ action: action1,
+ password: {
+ type: "not-yet-password"
+ }
+ });
+
+ info("Username and password will be filled while username focused");
+ await noPopupBy(() => form.uname.focus());
+
+ info("triggering autofill");
+ await noPopupBy(() => form.pword.type = "password");
+
+ const popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is closed");
+
+ form.submit.focus();
+ form.pword.value = "test";
+ info("Focus when we don't have an exact match");
+ await popupBy(() => form.uname.focus());
+});
+
+// Begin testing forms that have multiple saved logins
+
+add_task(async function multiple() {
+ const form = createLoginForm({ action: action2 });
+
+ info("Fields not filled due to multiple so autocomplete upon focus");
+ await popupBy(() => form.uname.focus());
+});
+
+add_task(async function multiple_dynamic() {
+ const form = createLoginForm({
+ action: action2,
+ password: {
+ type: "not-yet-password"
+ }
+ });
+
+ info("Fields not filled but username is focused upon marking so open");
+ await noPopupBy(() => form.uname.focus());
+
+ info("triggering _fillForm code");
+ await popupBy(() => form.pword.type = "password");
+});
+
+add_task(async function multiple_prefilled_un1() {
+ const form = createLoginForm({
+ action: action2,
+ username: {
+ value: "testuser2A"
+ }
+ });
+
+ info("Username and password already filled so don't show autocomplete");
+ await noPopupBy(() => form.uname.focus());
+
+ form.submit.focus();
+ form.uname.value = "testuser";
+ info("Focus when we don't have an exact match");
+ await popupBy(() => form.uname.focus());
+});
+
+add_task(async function multiple_prefilled_un2() {
+ const form = createLoginForm({
+ action: action2,
+ username: {
+ value: "testuser2B"
+ }
+ });
+
+ info("Username and password already filled so don't show autocomplete");
+ await noPopupBy(() => form.uname.focus());
+
+ form.submit.focus();
+ form.uname.value = "testuser";
+ info("Focus when we don't have an exact match");
+ await popupBy(() => form.uname.focus());
+});
+
+add_task(async function multiple_prefilled_focused_dynamic() {
+ const form = createLoginForm({
+ action: action2,
+ username: {
+ value: "testuser2B"
+ },
+ password: {
+ type: "not-yet-password"
+ }
+ });
+
+ info("Username and password will be filled while username focused");
+ await noPopupBy(() => form.uname.focus());
+ info("triggering autofill");
+ await noPopupBy(() => form.pword.type = "password");
+
+ let popupState = await getPopupState();
+ is(popupState.open, false, "Check popup is closed");
+
+ form.submit.focus();
+ form.pword.value = "test";
+ info("Focus when we don't have an exact match");
+ await popupBy(() => form.uname.focus());
+});
+</script>
+</pre>
+</body>
+</html>
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..811a2e7759
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/mochitest/test_xhr.html
@@ -0,0 +1,164 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for XHR prompts</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <script type="text/javascript" src="../../../prompts/test/prompt_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Login Manager test: XHR prompt
+<p id="display"></p>
+
+<div id="content" style="display: none">
+ <iframe id="iframe"></iframe>
+</div>
+
+<pre id="test">
+<script class="testbody" type="text/javascript">
+
+/** Test for Login Manager: XHR prompts. **/
+function makeRequest(uri) {
+ return new Promise((resolve, reject) => {
+ let request = new XMLHttpRequest();
+ request.open("GET", uri, true);
+ request.addEventListener("loadend", function onLoadEnd() {
+ let result = xhrLoad(request.responseXML);
+ resolve(result);
+ });
+ request.send(null);
+ });
+}
+
+function xhrLoad(xmlDoc) {
+ // The server echos back the user/pass it received.
+ var username = xmlDoc.getElementById("user").textContent;
+ var password = xmlDoc.getElementById("pass").textContent;
+ var authok = xmlDoc.getElementById("ok").textContent;
+ return {username, password, authok};
+}
+
+// Let prompt_common know what kind of modal type is enabled for auth prompts.
+modalType = authPromptModalType;
+
+let prompterParent = runInParent(() => {
+ const promptFac = Cc["@mozilla.org/passwordmanager/authpromptfactory;1"].
+ getService(Ci.nsIPromptFactory);
+
+ let chromeWin = Services.wm.getMostRecentWindow("navigator:browser");
+ let prompt = promptFac.getPrompt(chromeWin, Ci.nsIAuthPrompt);
+
+ addMessageListener("proxyPrompter", function onMessage(msg) {
+ let rv = prompt[msg.methodName](...msg.args);
+ return {
+ rv,
+ // Send the args back to content so out/inout args can be checked.
+ args: msg.args,
+ };
+ });
+});
+
+let prompter1 = new PrompterProxy(prompterParent);
+
+add_setup(async () => {
+ await setStoredLoginsAsync(
+ ["http://mochi.test:8888", null, "xhr", "xhruser1", "xhrpass1"],
+ ["http://mochi.test:8888", null, "xhr2", "xhruser2", "xhrpass2"]
+ );
+});
+
+add_task(async function test1() {
+ let state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "xhruser1",
+ passValue: "xhrpass1",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+ let action = {
+ buttonClick: "ok",
+ };
+ let promptDone = handlePrompt(state, action);
+ let requestPromise = makeRequest("authenticate.sjs?user=xhruser1&pass=xhrpass1&realm=xhr");
+ await promptDone;
+ let result = await requestPromise;
+
+ is(result.authok, "PASS", "Checking for successful authentication");
+ is(result.username, "xhruser1", "Checking for username");
+ is(result.password, "xhrpass1", "Checking for password");
+});
+
+add_task(async function test2() {
+ // Test correct parenting, by opening another tab in the foreground,
+ // and making sure the prompt re-focuses the original tab when shown:
+ let newWin = window.open();
+ newWin.focus();
+
+ let state = {
+ msg: "This site is asking you to sign in.",
+ title: "Authentication Required",
+ textValue: "xhruser2",
+ passValue: "xhrpass2",
+ iconClass: "authentication-icon question-icon",
+ titleHidden: true,
+ textHidden: false,
+ passHidden: false,
+ checkHidden: true,
+ checkMsg: "",
+ checked: false,
+ focused: "textField",
+ defButton: "button0",
+ };
+
+ // For window prompts check that the dialog is modal, chrome and dependent;
+ // We can't just check window.opener because that'll be
+ // a content window, which therefore isn't exposed (it'll lie and
+ // be null).
+ if (authPromptModalType === SpecialPowers.Services.prompt.MODAL_TYPE_WINDOW) {
+ state.chrome = true;
+ state.dialog = true;
+ state.chromeDependent = true;
+ state.isWindowModal = true;
+ }
+
+ let action = {
+ buttonClick: "ok",
+ };
+ let promptDone = handlePrompt(state, action);
+ let requestPromise = makeRequest("authenticate.sjs?user=xhruser2&pass=xhrpass2&realm=xhr2");
+ await promptDone;
+ let result = await requestPromise;
+
+ runInParent(() => {
+ // Check that the right tab is focused:
+ let browserWin = Services.wm.getMostRecentWindow("navigator:browser");
+ let spec = browserWin.gBrowser.selectedBrowser.currentURI.spec;
+ assert.ok(spec.startsWith(window.location.origin),
+ `Tab with remote URI (rather than about:blank)
+ should be focused (${spec})`);
+ });
+
+ is(result.authok, "PASS", "Checking for successful authentication");
+ is(result.username, "xhruser2", "Checking for username");
+ is(result.password, "xhrpass2", "Checking for password");
+
+ // Wait for the assert from the parent script to run and send back its reply,
+ // so it's processed before the test ends.
+ await SpecialPowers.executeAfterFlushingMessageQueue();
+
+ newWin.close();
+});
+</script>
+</pre>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=654348
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test XHR auth with user and pass arguments</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="pwmgr_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body onload="startTest()">
+<script class="testbody" type="text/javascript">
+
+/**
+ * This test checks we correctly ignore authentication entry
+ * for a subpath and use creds from the URL when provided when XHR
+ * is used with filled user name and password.
+ *
+ * 1. connect authenticate.sjs that excepts user1:pass1 password
+ * 2. connect authenticate.sjs that this time expects differentuser2:pass2 password
+ * we must use the creds that are provided to the xhr witch are different and expected
+ */
+
+function doxhr(URL, user, pass, code, next) {
+ var xhr = new XMLHttpRequest();
+ if (user && pass) {
+ xhr.open("POST", URL, true, user, pass);
+ } else {
+ xhr.open("POST", URL, true);
+ }
+ xhr.onload = function() {
+ is(xhr.status, code, "expected response code " + code);
+ next();
+ };
+ xhr.onerror = function() {
+ ok(false, "request passed");
+ finishTest();
+ };
+ xhr.send();
+}
+
+function startTest() {
+ doxhr("authenticate.sjs?user=dummy&pass=pass1&realm=realm1&formauth=1", "dummy", "dummy", 403, function() {
+ doxhr("authenticate.sjs?user=dummy&pass=pass1&realm=realm1&formauth=1", "dummy", "pass1", 200, finishTest);
+ });
+}
+
+function finishTest() {
+ SimpleTest.finish();
+}
+
+</script>
+</body>
+</html>
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
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/corruptDB.sqlite
Binary files 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
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/data/key4.db
Binary files 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..bf665faaed
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_LoginManagerParent_onPasswordEditedOrGenerated.js
@@ -0,0 +1,1126 @@
+/**
+ * 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",
+ `<box xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <browser></browser>
+ </box>`,
+ "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(
+ (await 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(
+ (await 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(
+ (await 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(
+ (await 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(
+ (await 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(
+ (await 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(
+ (await 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(
+ (await 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);
+
+ let logins = await Services.logins.getAllLogins();
+ info("Saved initial login: " + JSON.stringify(logins[0]));
+
+ equal(logins.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(
+ (await 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,
+ }
+ );
+ let logins = await Services.logins.getAllLogins();
+ equal(
+ logins.length,
+ 1,
+ "Should just have the previously-saved login with empty username"
+ );
+ assertLoginProperties(logins[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");
+ logins = await Services.logins.getAllLogins();
+ assertLoginProperties(logins[0], login0Props);
+ Assert.ok(logins[0].equals(expected), "Ensure no changes");
+ equal(logins.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(await 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,
+ }
+ );
+ let logins = await Services.logins.getAllLogins();
+ equal(
+ logins.length,
+ 1,
+ "Should just have the previously-saved login with empty username"
+ );
+ assertLoginProperties(logins[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");
+ logins = await Services.logins.getAllLogins();
+ assertLoginProperties(logins[0], login0Props);
+ Assert.ok(logins[0].equals(expected), "Ensure no changes");
+ equal(logins.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 = await 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 = await Services.logins.getAllLogins();
+ equal(
+ savedLogins.length,
+ 2,
+ "Should have saved the generated-password login"
+ );
+ assertLoginProperties(savedLogins[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<FPZd";
+ const encrypted = crypto.encryptData(testString);
+
+ const decryptedString = crypto.decryptData(encrypted, null, "string");
+ Assert.equal(
+ decryptedString,
+ testString,
+ "Decrypted string matches initial value"
+ );
+
+ const decryptedBytes = crypto.decryptData(encrypted, null, "bytes");
+ testString.split("").forEach((c, i) => {
+ 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..c0947ea22b
--- /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,
+});
+
+ChromeUtils.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 = "<form><input id='pw' type=password></form>";
+
+ 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 <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "1 text field outside of a <form> without a password field",
+ document: `<input id="un1">`,
+ 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 <form>",
+ document: `<input id="un1">
+ <input id="pw1" type=password>`,
+ 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 <form>, un1 removed",
+ document: `<input id="un1">
+ <input id="pw1" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "1 username & password field in a <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "5 empty password fields outside of a <form>",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password>
+ <input id="pw3" type=password>
+ <input id="pw4" type=password>
+ <input id="pw5" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "6 empty password fields outside of a <form>",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password>
+ <input id="pw3" type=password>
+ <input id="pw4" type=password>
+ <input id="pw5" type=password>
+ <input id="pw6" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description:
+ "4 password fields outside of a <form> (1 empty, 3 full) with skipEmpty",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password value="pass2">
+ <input id="pw3" type=password value="pass3">
+ <input id="pw4" type=password value="pass4">`,
+ 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: `<form><input id="pw1" type=password></form>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "Form with 2 password fields",
+ document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`,
+ 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: `<form><input id="pw1" type=password></form><input id="pw2" type=password>`,
+ 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: `<form><input id="pw1" type=password></form><input>`,
+ 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: `<form><input></form><input id="pw1" type=password>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form='form1'>
+ <form id="form1"></form>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form + skipEmpty",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: true,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form + skipEmpty with 1 empty",
+ document: `<input id="pw1" type=password value="pass1"><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ 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: `<input id="pw1" type=password>
+ <input id="pw2" type=password value="pass2">
+ <input id="pw3" type=password value="pass3">`,
+ 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 <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="un2">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ 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 <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="un2" autocomplete="username">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ 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 <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="un2" autocomplete="email">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ 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: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" autocomplete="email">
+ <input id="pw1" type=password>
+ </form>`,
+ 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: `<form>
+ <input id="un1" autocomplete="email">
+ <input id="un2" autocomplete="username">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un2",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "2 username fields in a <form>",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" autocomplete="username">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un2",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "2 email fields in a <form>",
+ document: `<form>
+ <input id="un1" autocomplete="email">
+ <input id="un2" autocomplete="email">
+ <input id="un3">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ {
+ description: "the password field precedes the username field",
+ document: `<form>
+ <input id="un1">
+ <input id="pw1" type=password>
+ <input id="un2" autocomplete="username">
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: "pw1",
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [TESTENVIRONMENTS.filledPW1WithGeneratedPassword],
+ extraTestPreferences: [],
+ },
+ // end of getusername heuristic tests
+ {
+ description: "1 username field in a <form>",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: "un1",
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [],
+ extraTestPreferences: [],
+ },
+ {
+ description: "1 input field in a <form>",
+ document: `<form>
+ <input id="un1"">
+ </form>`,
+ returnedFieldIDs: {
+ usernameField: null,
+ newPasswordField: null,
+ oldPasswordField: null,
+ },
+ skipEmptyFields: undefined,
+ extraTestEnvironments: [],
+ extraTestPreferences: [],
+ },
+ {
+ description: "1 username field in a <form> with usernameOnlyForm pref off",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ </form>`,
+ 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 <form> present",
+ document: `<input>`,
+ // Only the IDs of password fields should be in this array
+ returnedFieldIDsByFormLike: [[]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "1 password field outside of a <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDsByFormLike: [["pw1"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "5 empty password fields outside of a <form>",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password>
+ <input id="pw3" type=password>
+ <input id="pw4" type=password>
+ <input id="pw5" type=password>`,
+ returnedFieldIDsByFormLike: [["pw1", "pw2", "pw3", "pw4", "pw5"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "6 empty password fields outside of a <form>",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password>
+ <input id="pw3" type=password>
+ <input id="pw4" type=password>
+ <input id="pw5" type=password>
+ <input id="pw6" type=password>`,
+ returnedFieldIDsByFormLike: [[]],
+ minPasswordLength: undefined,
+ },
+ {
+ description:
+ "4 password fields outside of a <form> (1 empty, 3 full) with minPasswordLength=2",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password value="pass2">
+ <input id="pw3" type=password value="pass3">
+ <input id="pw4" type=password value="pass4">`,
+ returnedFieldIDsByFormLike: [["pw2", "pw3", "pw4"]],
+ minPasswordLength: 2,
+ },
+ {
+ description: "Form with 1 password field",
+ document: `<form><input id="pw1" type=password></form>`,
+ returnedFieldIDsByFormLike: [["pw1"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "Form with 2 password fields",
+ document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`,
+ returnedFieldIDsByFormLike: [["pw1", "pw2"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description: "1 password field in a form, 1 outside",
+ document: `<form><input id="pw1" type=password></form><input id="pw2" type=password>`,
+ returnedFieldIDsByFormLike: [["pw1"], ["pw2"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form='form1'>
+ <form id="form1"></form>`,
+ returnedFieldIDsByFormLike: [["pw1"], ["pw2"]],
+ minPasswordLength: undefined,
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form + minPasswordLength",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ returnedFieldIDsByFormLike: [[], []],
+ minPasswordLength: 2,
+ },
+ {
+ description: "minPasswordLength should also skip white-space only fields",
+ /* eslint-disable no-tabs */
+ document: `<input id="pw-space" type=password value=" ">
+ <input id="pw-tab" type=password value=" ">
+ <input id="pw-newline" type=password form="form1" value="
+ ">
+ <form id="form1"></form>`,
+ /* eslint-enable no-tabs */
+ returnedFieldIDsByFormLike: [[], []],
+ minPasswordLength: 2,
+ },
+ {
+ description: "minPasswordLength should skip too-short field values",
+ document: `<form>
+ <input id="pw-empty" type=password>
+ <input id="pw-tooshort" type=password value="p">
+ <input id="pw" type=password value="pz">
+ </form>`,
+ returnedFieldIDsByFormLike: [["pw"]],
+ minPasswordLength: 2,
+ },
+ {
+ description: "minPasswordLength should allow matching-length field values",
+ document: `<form>
+ <input id="pw-empty" type=password>
+ <input id="pw-matchlen" type=password value="pz">
+ <input id="pw" type=password value="pazz">
+ </form>`,
+ returnedFieldIDsByFormLike: [["pw-matchlen", "pw"]],
+ minPasswordLength: 2,
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form + minPasswordLength with 1 empty",
+ document: `<input id="pw1" type=password value=" pass1 "><input id="pw2" type=password form="form1">
+ <form id="form1"></form>`,
+ 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 <form> with 1 linked via @form + minPasswordLength",
+ document: `<input id="pw1" type=password value="pass1"><input id="pw2" type=password form="form1" value="pass2"><input id="pw3" type=password value="pass3">
+ <form id="form1"><input id="pw4" type=password></form>`,
+ returnedFieldIDsByFormLike: [["pw3"], ["pw2"]],
+ minPasswordLength: 2,
+ fieldOverrideRecipe: {
+ hosts: ["localhost:8080"],
+ notPasswordSelector: "#pw1",
+ },
+ },
+ {
+ beforeGetFunction(doc) {
+ doc.getElementById("pw1").remove();
+ },
+ description:
+ "1 password field outside of a <form> which gets removed/disconnected",
+ document: `<input id="pw1" type=password>`,
+ 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 <form> 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: `<form>
+ <input id="pw" type=password value="💩">
+ </form>`,
+ returnedFieldIDsByFormLike: [["pw"]],
+ minPasswordLength: 2,
+ },
+ {
+ description:
+ "Single characters composed of multiple code units should ideally fail minPasswordLength of 2",
+ document: `<form>
+ <input id="pw" type=password value="👪">
+ </form>`,
+ 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..5ee8440525
--- /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.123.123.123:12345/foo",
+ "moz-proxy://123.123.123.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..2e845bef5d
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_getUserNameAndPasswordFields.js
@@ -0,0 +1,187 @@
+/**
+ * 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 <form>",
+ document: `<input id="pw1" type=password>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "1 text field in a <form> without a password field",
+ document: `<form>
+ <input id="un1">
+ </form>`,
+ returnedFieldIDs: [null, null, null],
+ },
+ {
+ description: "1 text field outside of a <form> without a password field",
+ document: `<input id="un1">`,
+ returnedFieldIDs: [null, null, null],
+ },
+ {
+ description: "1 username & password field outside of a <form>",
+ document: `<input id="un1">
+ <input id="pw1" type=password>`,
+ returnedFieldIDs: ["un1", "pw1", null],
+ },
+ {
+ description: "1 username & password field in a <form>",
+ document: `<form>
+ <input id="un1">
+ <input id="pw1" type=password>
+ </form>`,
+ returnedFieldIDs: ["un1", "pw1", null],
+ },
+ {
+ description: "5 empty password fields outside of a <form>",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password>
+ <input id="pw3" type=password>
+ <input id="pw4" type=password>
+ <input id="pw5" type=password>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "6 empty password fields outside of a <form>",
+ document: `<input id="pw1" type=password>
+ <input id="pw2" type=password>
+ <input id="pw3" type=password>
+ <input id="pw4" type=password>
+ <input id="pw5" type=password>
+ <input id="pw6" type=password>`,
+ returnedFieldIDs: [null, null, null],
+ },
+ {
+ description: "Form with 1 password field",
+ document: `<form><input id="pw1" type=password></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "Form with 2 password fields",
+ document: `<form><input id="pw1" type=password><input id='pw2' type=password></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "1 password field in a form, 1 outside (not processed)",
+ document: `<form><input id="pw1" type=password></form><input id="pw2" type=password>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description:
+ "1 password field in a form, 1 text field outside (not processed)",
+ document: `<form><input id="pw1" type=password></form><input>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description:
+ "1 text field in a form, 1 password field outside (not processed)",
+ document: `<form><input></form><input id="pw1" type=password>`,
+ returnedFieldIDs: [null, null, null],
+ },
+ {
+ description:
+ "2 password fields outside of a <form> with 1 linked via @form",
+ document: `<input id="pw1" type=password><input id="pw2" type=password form='form1'>
+ <form id="form1"></form>`,
+ returnedFieldIDs: [null, "pw1", null],
+ },
+ {
+ description: "1 username field in a <form>",
+ document: `<form>
+ <input id="un1" autocomplete=username>
+ </form>`,
+ returnedFieldIDs: ["un1", null, null],
+ },
+ {
+ description: "1 username field outside of a <form>",
+ document: `<input id="un1" autocomplete=username>`,
+ returnedFieldIDs: [null, null, null],
+ },
+ {
+ description: "username with type=user",
+ document: `<form><input id="un1" type="user"><input id="pwd" type="password"></form>`,
+ returnedFieldIDs: ["un1", "pwd", null],
+ },
+ {
+ description: "username with type=username",
+ document: `<form><input id="un1" type="username"><input id="pwd" type="password"></form>`,
+ returnedFieldIDs: ["un1", "pwd", 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: `<form>
+ <input id="un1" type="text">
+ </form>`,
+ expectations: [false, true],
+ },
+ {
+ description: "1 text input field & 1 hidden input fields",
+ document: `<form>
+ <input id="un1" type="text">
+ <input id="un2" type="hidden">
+ </form>`,
+ expectations: [false, true],
+ },
+ {
+ description: "1 username field",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ </form>`,
+ expectations: [true, true],
+ },
+ {
+ description: "1 username field & 1 hidden input fields",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" type="hidden">
+ </form>`,
+ expectations: [true, true],
+ },
+ {
+ description: "1 username field, 1 hidden input field, & 1 password field",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" type="hidden">
+ <input id="pw1" type=password>
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 password field",
+ document: `<form>
+ <input id="pw1" type=password>
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 username & password field",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="pw1" type=password>
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 username & text field",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" type="text">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "2 text input fields",
+ document: `<form>
+ <input id="un1" type="text">
+ <input id="un2" type="text">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "2 username fields",
+ document: `<form>
+ <input id="un1" autocomplete="username">
+ <input id="un2" autocomplete="username">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 username field with search keyword",
+ document: `<form>
+ <input id="un1" autocomplete="username" placeholder="search by username">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "1 text input field with code keyword",
+ document: `<form>
+ <input id="un1" type="text" placeholder="enter your 6-digit code">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "Form with only a hidden field",
+ document: `<form>
+ <input id="un1" type="hidden" autocomplete="username">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "Form with only a button",
+ document: `<form>
+ <input id="un1" type="button" autocomplete="username">
+ </form>`,
+ expectations: [false, false],
+ },
+ {
+ description: "A username only form matches not username selector",
+ document: `<form>
+ <input id="un1" type="text" name="secret_username">
+ </form>`,
+ 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/",
+ `<form id="id" name="name"></form>`
+ );
+
+ 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/",
+ `<label id="l1" for="id"></label>
+ <label id="l2" for="id"></label>
+ <input id="id" type="text" name="name">`
+ );
+
+ 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/",
+ `<div>
+ <label id="paper-input-label-2">Password</label>
+ <input aria-labelledby="paper-input-label-2" type="password">
+ </div>`
+ );
+ 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 `<input type="password" autocomplete="new-password">`
+ // since isProbablyANewPasswordField explicitly does not run in that case.
+ {
+ description: "Basic login form",
+ document: `
+ <h1>Sign in</h1>
+ <form>
+ <label>Username: <input type="text" name="username"></label>
+ <label>Password: <input type="password" name="password"></label>
+ <input type="submit" value="Sign in">
+ </form>
+ `,
+ expectedResult: [false],
+ },
+ {
+ description: "Basic registration form",
+ document: `
+ <h1>Create account</h1>
+ <form>
+ <label>Username: <input type="text" name="username"></label>
+ <label>Password: <input type="password" name="new-password"></label>
+ <input type="submit" value="Register">
+ </form>
+ `,
+ expectedResult: [true],
+ },
+ {
+ // TODO: Add <placeholder="confirm"> 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: `
+ <h1>Change password</h1>
+ <form>
+ <label>Current Password: <input type="password" name="current-password"></label>
+ <label>New Password: <input type="password" name="new-password"></label>
+ <label>Confirm Password: <input type="password" name="confirm-password" placeholder="confirm"></label>
+ <input type="submit" value="Save">
+ </form>
+ `,
+ expectedResult: [false, true, true],
+ },
+ {
+ description: "Basic login 'form' without a form element",
+ document: `
+ <h1>Sign in</h1>
+ <label>Username: <input type="text" name="username"></label>
+ <label>Password: <input type="password" name="password"></label>
+ <input type="submit" value="Sign in">
+ `,
+ expectedResult: [false],
+ },
+ {
+ description: "Basic registration 'form' without a form element",
+ document: `
+ <h1>Create account</h1>
+ <label>Username: <input type="text" name="username"></label>
+ <label>Password: <input type="password" name="new-password"></label>
+ <input type="submit" value="Register">
+ `,
+ expectedResult: [true],
+ },
+ {
+ description: "Basic password change 'form' without a form element",
+ document: `
+ <h1>Change password</h1>
+ <label>Current Password: <input type="password" name="current-password"></label>
+ <label>New Password: <input type="password" name="new-password"></label>
+ <label>Confirm Password: <input type="password" name="confirm-password"></label>
+ <input type="submit" value="Save">
+ `,
+ 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: `<input type="text">`,
+ expected: true,
+ },
+ {
+ description: "type=email, no autocomplete attribute",
+ document: `<input type="email">`,
+ expected: true,
+ },
+ {
+ description: "type=url, no autocomplete attribute",
+ document: `<input type="url">`,
+ expected: true,
+ },
+ {
+ description: "type=tel, no autocomplete attribute",
+ document: `<input type="tel">`,
+ expected: true,
+ },
+ {
+ description: "type=number, no autocomplete attribute",
+ document: `<input type="number">`,
+ expected: true,
+ },
+ {
+ description: "type=search, no autocomplete attribute",
+ document: `<input type="search">`,
+ expected: true,
+ },
+ {
+ description: "type=range, no autocomplete attribute",
+ document: `<input type="range">`,
+ expected: false,
+ },
+ {
+ description: "type=date, no autocomplete attribute",
+ document: `<input type="date">`,
+ expected: false,
+ },
+ {
+ description: "type=month, no autocomplete attribute",
+ document: `<input type="month">`,
+ expected: false,
+ },
+ {
+ description: "type=week, no autocomplete attribute",
+ document: `<input type="week">`,
+ expected: false,
+ },
+ {
+ description: "type=time, no autocomplete attribute",
+ document: `<input type="time">`,
+ expected: false,
+ },
+ {
+ description: "type=datetime, no autocomplete attribute",
+ document: `<input type="datetime">`,
+ expected: false,
+ },
+ {
+ description: "type=datetime-local, no autocomplete attribute",
+ document: `<input type="datetime-local">`,
+ expected: false,
+ },
+ {
+ description: "type=color, no autocomplete attribute",
+ document: `<input type="color">`,
+ expected: false,
+ },
+];
+
+for (let [name, expected] of Object.entries(autocompleteTypes)) {
+ TESTCASES.push({
+ description: `type=text autocomplete=${name}`,
+ document: `<input type="text" autocomplete="${name}">`,
+ 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..d630ed0e07
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_legacy_empty_formActionOrigin.js
@@ -0,0 +1,112 @@
+/* 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 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.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.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..79a3c4bb4a
--- /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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ style: "loginsFooter",
+ comment: { formHostname: "mochi.test" },
+ },
+ ],
+ },
+ {
+ isSecure: true,
+ hasBeenTypePassword: true,
+ matchingLogins: [],
+ items: [
+ {
+ value: "",
+ label: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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: "Manage Passwords",
+ 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..cc82d6cc0b
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_change.js
@@ -0,0 +1,622 @@
+/* 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
+ );
+ await 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.
+ await 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());
+ await 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);
+ }
+
+ await 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.
+ *
+ * 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 === 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();
+ await 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.
+ await 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);
+ await LoginTestUtils.checkLogins([differentLoginInfo]);
+
+ // It is now possible to add a login with the old type and origin.
+ await Services.logins.addLoginAsync(loginInfo);
+ await 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/
+ );
+ await 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.
+ await 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);
+ await LoginTestUtils.checkLogins([differentLoginInfo]);
+
+ // It is now possible to add a login with the old type and origin.
+ await Services.logins.addLoginAsync(loginInfo);
+ await 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/
+ );
+ await 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((await Services.logins.getAllLogins()).length, 0);
+ }
+
+ await 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..533bd9dd8d
--- /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.
+ let savedLogins = await Services.logins.getAllLogins();
+ Assert.equal(savedLogins.length, 0, "getAllLogins length");
+ await Assert.rejects(Services.logins.searchLoginsAsync({}), /is required/);
+ 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);
+ await LoginTestUtils.checkLogins(logins);
+ Assert.equal(
+ (await Services.logins.getAllLogins()).length,
+ logins.length,
+ "getAllLogins length"
+ );
+ Assert.equal(Services.logins.countLogins("", "", ""), logins.length * 2);
+
+ // Finding logins doesn't return the non-decryptable duplicates.
+ Assert.equal(
+ (
+ await Services.logins.searchLoginsAsync({
+ origin: "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((await 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((await 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..e851564981
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_metainfo.js
@@ -0,0 +1,304 @@
+/* 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.
+ */
+async function retrieveOriginMatching(origin) {
+ let logins = await Services.logins.searchLoginsAsync({ 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 = await retrieveOriginMatching(gLoginInfo1.origin);
+ 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 = await retrieveOriginMatching(gLoginInfo2.origin);
+ assertMetaInfoEqual(gLoginMetaInfo2, gLoginInfo2);
+
+ // Add an authentication login to the database before continuing.
+ await Services.logins.addLoginAsync(gLoginInfo3);
+ gLoginMetaInfo3 = await retrieveOriginMatching(gLoginInfo3.origin);
+ await 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.
+ await LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]);
+});
+
+/**
+ * Tests that the existing metadata is not changed when modifyLogin is called
+ * with an nsILoginInfo argument.
+ */
+add_task(async 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 = await retrieveOriginMatching(gLoginInfo1.origin);
+ assertMetaInfoEqual(newLoginInfo, gLoginMetaInfo1);
+});
+
+/**
+ * Tests the modifyLogin function with an nsIProperyBag argument.
+ */
+add_task(async 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 = await retrieveOriginMatching(gLoginInfo1.origin);
+ 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 = await retrieveOriginMatching(gLoginInfo2.origin);
+ 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 = await retrieveOriginMatching(gLoginInfo2.origin);
+ 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 = await retrieveOriginMatching(gLoginInfo2.origin);
+ 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(async function test_modifyLogin_nsIProperyBag_metainfo_duplicate() {
+ Assert.throws(
+ () =>
+ Services.logins.modifyLogin(
+ gLoginInfo1,
+ newPropertyBag({
+ guid: gLoginInfo2.guid,
+ })
+ ),
+ /specified GUID already exists/
+ );
+ await 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 storage 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();
+ await LoginTestUtils.checkLogins([gLoginInfo1, gLoginInfo2, gLoginInfo3]);
+
+ assertMetaInfoEqual(
+ await retrieveOriginMatching(gLoginInfo1.origin),
+ gLoginMetaInfo1
+ );
+ assertMetaInfoEqual(
+ await retrieveOriginMatching(gLoginInfo2.origin),
+ gLoginMetaInfo2
+ );
+ assertMetaInfoEqual(
+ await retrieveOriginMatching(gLoginInfo3.origin),
+ 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..c3d10b60ac
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_logins_search.js
@@ -0,0 +1,227 @@
+/**
+ * Tests methods that find specific logins in the store (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 searchLogins, and countLogins with the same query.
+ *
+ * @param aQuery
+ * The "origin", "formActionOrigin", and "httpRealm" properties of this
+ * object are passed as parameters to 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 countLogins function 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 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 searchLogins, and countLogins with basic queries.
+ */
+add_task(function test_search_all_basic() {
+ // Find all logins, using no filters in the search functions.
+ checkAllSearches({}, 28);
+
+ // Find all form logins, then all authentication logins.
+ checkAllSearches({ httpRealm: null }, 17);
+ checkAllSearches({ formActionOrigin: null }, 11);
+
+ // 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: "" }, 16);
+
+ // 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 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..fbf494d52b
--- /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 = await 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(
+ (await 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..e3fa2b0ea3
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginCSVImport.js
@@ -0,0 +1,866 @@
+/* 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";
+/**
+ * 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);
+ 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);
+
+ await 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"
+ );
+
+ await 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"
+ );
+
+ await 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"
+ );
+
+ await 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"
+ );
+
+ await 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);
+ await 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);
+
+ await 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);
+
+ await 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);
+
+ await 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);
+
+ await 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);
+
+ await 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);
+
+ await 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);
+
+ await 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);
+
+ await 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);
+
+ await 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","<password>","https://example.org",""`,
+ ]);
+
+ await LoginCSVImport.importFromCSV(csvFilePath);
+
+ await LoginTestUtils.checkLogins(
+ [
+ TestData.formLogin({
+ formActionOrigin: "",
+ httpRealm: null,
+ origin: "https://example.org",
+ password: "<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`
+ );
+ await 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`
+ );
+ await 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"
+ );
+
+ await 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([
+ "<body>this is totally not a csv file</body>",
+ ]);
+
+ await Assert.rejects(
+ LoginCSVImport.importFromCSV(csvFilePath),
+ /FILE_FORMAT_ERROR/,
+ "Check that the errorType is file format error"
+ );
+
+ await 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 = (await Services.logins.getAllLogins()).length;
+
+ let results = await LoginCSVImport.importFromCSV(csvFilePath);
+ let afterImportLoginCount = (await 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 = (await Services.logins.getAllLogins()).length;
+
+ let results = await LoginCSVImport.importFromCSV(csvFilePath);
+ let afterImportLoginCount = (await 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 = (await Services.logins.getAllLogins()).length;
+
+ let results = await LoginCSVImport.importFromCSV(csvFilePath);
+ let afterImportLoginCount = (await 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 = (await Services.logins.getAllLogins()).length;
+
+ let results = await LoginCSVImport.importFromCSV(csvFilePath);
+ let afterImportLoginCount = (await 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..6c83ff0005
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js
@@ -0,0 +1,222 @@
+/* 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 = { getAllLogins: 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",
+ "everSynced",
+ "syncCounter",
+ "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.getAllLogins.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.getAllLogins.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.getAllLogins.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.getAllLogins.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",,,,,',
+ '"http://example.net","the username","the password","",,,,,',
+ '"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..3ada39d246
--- /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);
+ await 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);
+ await LoginTestUtils.checkLogins([testuser2]);
+
+ /* ========== 4 ========== */
+ testnum++;
+ testdesc = "removeLogin";
+
+ expectedNotification = "removeLogin";
+ expectedData = testuser2;
+ Services.logins.removeLogin(testuser2);
+ Assert.equal(expectedNotification, null);
+ await LoginTestUtils.checkLogins([]);
+
+ /* ========== 5 ========== */
+ testnum++;
+ testdesc = "removeAllLogins";
+
+ expectedNotification = "removeAllLogins";
+ expectedData = null;
+ Services.logins.removeAllLogins();
+ Assert.equal(expectedNotification, null);
+ await 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);
+ await 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);
+ await 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);
+ await 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/",
+ "<form>"
+ ).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..55bbe9d371
--- /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 storage 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();
+ await 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..9fd5a7dfaa
--- /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;
+
+ 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.toml b/toolkit/components/passwordmgr/test/unit/xpcshell.toml
new file mode 100644
index 0000000000..c048d42c2c
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.toml
@@ -0,0 +1,118 @@
+[DEFAULT]
+head = "head.js"
+skip-if = ["os == 'android'"] # Not supported on GV because we can't add/remove from storage.
+support-files = ["data/**"]
+
+["test_CSVParser.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_OSCrypto_win.js"]
+run-if = ["os == 'win'"]
+
+["test_PasswordGenerator.js"]
+skip-if = ["os == 'android'"] # Not packaged/used on Android
+
+["test_PasswordRulesManager_generatePassword.js"]
+
+["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_legacy_validation.js"]
+
+["test_login_autocomplete_result.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_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_module_LoginExport.js"]
+skip-if = ["os == 'android'"] # there is no export for android
+
+["test_module_LoginManager.js"]
+
+["test_module_LoginStore.js"]
+skip-if = ["os == 'android'"]
+
+["test_notifications.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