diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/passwordmgr/test/browser | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
104 files changed, 17255 insertions, 0 deletions
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.ini b/toolkit/components/passwordmgr/test/browser/browser.ini new file mode 100644 index 0000000000..ae41ef2c47 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser.ini @@ -0,0 +1,162 @@ +[DEFAULT] +support-files = + ../formsubmit.sjs + authenticate.sjs + empty.html + form_basic.html + form_basic_iframe.html + form_basic_login.html + form_basic_signup.html + form_basic_no_username.html + formless_basic.html + form_multipage.html + form_same_origin_action.html + form_cross_origin_secure_action.html + form_cross_origin_insecure_action.html + form_expanded.html + insecure_test_subframe.html + head.js + multiple_forms.html + ../../../../../browser/components/aboutlogins/tests/browser/head.js + + +[browser_DOMFormHasPassword.js] +[browser_DOMFormHasPossibleUsername.js] +[browser_DOMInputPasswordAdded.js] +skip-if = (os == "linux") || (os == "mac") # Bug 1337606 +[browser_autocomplete_autofocus_with_frame.js] +support-files = + form_autofocus_frame.html +[browser_autocomplete_disabled_readonly_passwordField.js] +support-files = + form_disabled_readonly_passwordField.html +[browser_autocomplete_footer.js] +skip-if = + !debug && os == "linux" && bits == 64 && os_version == "18.04" # Bug 1591126 + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_autocomplete_generated_password_private_window.js] +[browser_autocomplete_import.js] +https_first_disabled = true +skip-if = + os == "mac" # Bug 1775902 + os == "win" && !debug # Bug 1775902 +[browser_autocomplete_insecure_warning.js] +[browser_autocomplete_primary_password.js] +[browser_autofill_hidden_document.js] +skip-if = + (os == "win" && os_version == "10.0" && debug) # bug 1530935 + apple_catalina && fission && !debug # high frequency intermittent, Bug 1716486 + +[browser_autofill_http.js] +https_first_disabled = true +skip-if = verify +[browser_autofill_track_filled_logins.js] +[browser_basicAuth_multiTab.js] +skip-if = os == "android" +[browser_basicAuth_rateLimit.js] +[browser_basicAuth_switchTab.js] +skip-if = (debug && os == "mac") # Bug 1530566 +[browser_context_menu.js] +[browser_context_menu_autocomplete_interaction.js] +skip-if = + verify +[browser_context_menu_generated_password.js] +[browser_context_menu_iframe.js] +[browser_crossOriginSubmissionUsesCorrectOrigin.js] +support-files = + form_cross_origin_secure_action.html +[browser_deleteLoginsBackup.js] +skip-if = os == "android" +[browser_doorhanger_autocomplete_values.js] +[browser_doorhanger_autofill_then_save_password.js] +[browser_doorhanger_crossframe.js] +support-files = + form_crossframe.html + form_crossframe_inner.html +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_doorhanger_dismissed_for_ccnumber.js] +[browser_doorhanger_empty_password.js] +[browser_doorhanger_form_password_edit.js] +[browser_doorhanger_generated_password.js] +support-files = + form_basic_with_confirm_field.html + form_password_change.html +[browser_doorhanger_httpsUpgrade.js] +support-files = + subtst_notifications_1.html + subtst_notifications_8.html +[browser_doorhanger_multipage_form.js] +[browser_doorhanger_password_edits.js] +[browser_doorhanger_promptToChangePassword.js] +[browser_doorhanger_remembering.js] +[browser_doorhanger_replace_dismissed_with_visible_while_opening.js] +[browser_doorhanger_save_password.js] +[browser_doorhanger_submit_telemetry.js] +skip-if = + tsan # Bug 1661305 + os == "linux" && (debug || asan) # Bug 1658056, asan: 1695395 +[browser_doorhanger_target_blank.js] +support-files = + subtst_notifications_12_target_blank.html +[browser_doorhanger_toggles.js] +[browser_doorhanger_username_edits.js] +[browser_doorhanger_window_open.js] +support-files = + subtst_notifications_11.html + subtst_notifications_11_popup.html +skip-if = os == "linux" # Bug 1312981, bug 1313136 +[browser_entry_point_telemetry.js] +[browser_exceptions_dialog.js] +[browser_fileURIOrigin.js] +[browser_focus_before_first_DOMContentLoaded.js] +support-files = + file_focus_before_DOMContentLoaded.sjs +[browser_form_history_fallback.js] +https_first_disabled = true # TODO remove that line and move test to HTTPS, see Bug 1776350 +skip-if = os == "linux" && debug # Bug 1334336 +support-files = + subtst_notifications_1.html + subtst_notifications_2.html + subtst_notifications_2pw_0un.html + subtst_notifications_2pw_1un_1text.html + subtst_notifications_3.html + subtst_notifications_4.html + subtst_notifications_5.html + subtst_notifications_6.html + subtst_notifications_8.html + subtst_notifications_9.html + subtst_notifications_10.html + subtst_notifications_change_p.html +[browser_formless_submit_chrome.js] +skip-if = tsan # Bug 1683730 +[browser_insecurePasswordConsoleWarning.js] +https_first_disabled = true +skip-if = verify +[browser_isProbablyASignUpForm.js] +support-files = + form_signup_detection.html +[browser_localip_frame.js] +skip-if = + os == 'mac' && bits == 64 # Bug 1683848 + os == 'linux' && !debug && bits == 64 # Bug 1683848 + win10_2004 && !fission # Bug 1723573 +[browser_message_onFormSubmit.js] +[browser_openPasswordManager.js] +[browser_preselect_login.js] +[browser_private_window.js] +support-files = + subtst_privbrowsing_1.html + form_password_change.html +skip-if = + os == 'linux' && bits == 64 && os_version == '18.04' && !debug # Bug 1744976 + os == 'win' && os_version == '10.0' && debug # Bug 1782656 +[browser_proxyAuth_prompt.js] +skip-if = os == "android" +[browser_relay_telemetry.js] +[browser_telemetry_SignUpFormRuleset.js] +[browser_test_changeContentInputValue.js] +[browser_username_only_form_telemetry.js] +[browser_username_select_dialog.js] +support-files = + subtst_notifications_change_p.html
\ No newline at end of file diff --git a/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js new file mode 100644 index 0000000000..6d4d333369 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPassword.js @@ -0,0 +1,152 @@ +const ids = { + INPUT_ID: "input1", + FORM1_ID: "form1", + FORM2_ID: "form2", + CHANGE_INPUT_ID: "input2", +}; + +function task(contentIds) { + let resolve; + let promise = new Promise(r => { + resolve = r; + }); + + function unexpectedContentEvent(evt) { + Assert.ok(false, "Received a " + evt.type + " event on content"); + } + + var gDoc = null; + + addEventListener("load", tabLoad, true); + + function tabLoad() { + if (content.location.href == "about:blank") { + return; + } + removeEventListener("load", tabLoad, true); + + gDoc = content.document; + gDoc.addEventListener("DOMFormHasPassword", unexpectedContentEvent); + addEventListener("DOMFormHasPassword", unexpectedContentEvent); + gDoc.defaultView.setTimeout(test_inputAdd, 0); + } + + function test_inputAdd() { + addEventListener("DOMFormHasPassword", test_inputAddHandler, { + once: true, + capture: true, + }); + let input = gDoc.createElementNS("http://www.w3.org/1999/xhtml", "input"); + input.setAttribute("type", "password"); + input.setAttribute("id", contentIds.INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.getElementById(contentIds.FORM1_ID).appendChild(input); + } + + function test_inputAddHandler(evt) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM1_ID, + evt.type + " event targets correct form element (added password element)" + ); + gDoc.defaultView.setTimeout(test_inputChangeForm, 0); + } + + function test_inputChangeForm() { + addEventListener("DOMFormHasPassword", test_inputChangeFormHandler, { + once: true, + capture: true, + }); + let input = gDoc.getElementById(contentIds.INPUT_ID); + input.setAttribute("form", contentIds.FORM2_ID); + } + + function test_inputChangeFormHandler(evt) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM2_ID, + evt.type + " event targets correct form element (changed form)" + ); + gDoc.defaultView.setTimeout(test_inputChangesType, 0); + } + + function test_inputChangesType() { + addEventListener("DOMFormHasPassword", test_inputChangesTypeHandler, { + once: true, + capture: true, + }); + let input = gDoc.getElementById(contentIds.CHANGE_INPUT_ID); + input.setAttribute("type", "password"); + } + + function test_inputChangesTypeHandler(evt) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM1_ID, + evt.type + " event targets correct form element (changed type)" + ); + gDoc.defaultView.setTimeout(finish, 0); + } + + function finish() { + removeEventListener("DOMFormHasPassword", unexpectedContentEvent); + gDoc.removeEventListener("DOMFormHasPassword", unexpectedContentEvent); + resolve(); + } + + return promise; +} + +add_task(async function test_disconnectedInputs() { + const tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + const unexpectedEvent = evt => { + Assert.ok( + false, + `${evt.type} should not be fired for disconnected forms.` + ); + }; + + addEventListener("DOMFormHasPassword", unexpectedEvent); + + const form = content.document.createElement("form"); + const passwordInput = content.document.createElement("input"); + passwordInput.setAttribute("type", "password"); + form.appendChild(passwordInput); + + // Delay the execution for a bit to allow time for any asynchronously + // dispatched 'DOMFormHasPassword' events to be processed. + // This is necessary because such events might not be triggered immediately, + // and we want to ensure that if they are dispatched, they are captured + // before we remove the event listener. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 50)); + removeEventListener("DOMFormHasPassword", unexpectedEvent); + }); + + Assert.ok(true, "Test completed"); + gBrowser.removeCurrentTab(); +}); + +add_task(async function () { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + + let promise = ContentTask.spawn(tab.linkedBrowser, ids, task); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + `data:text/html;charset=utf-8, + <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..7f39395587 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_DOMFormHasPossibleUsername.js @@ -0,0 +1,254 @@ +const ids = { + INPUT_ID: "input1", + FORM1_ID: "form1", + FORM2_ID: "form2", + CHANGE_INPUT_ID: "input2", + INPUT_TYPE: "", +}; + +function task({ contentIds, expected }) { + let resolve; + let promise = new Promise(r => { + resolve = r; + }); + + function unexpectedContentEvent(evt) { + Assert.ok(false, "Received a " + evt.type + " event on content"); + } + + var gDoc = null; + + addEventListener("load", tabLoad, true); + + function tabLoad() { + if (content.location.href == "about:blank") { + return; + } + removeEventListener("load", tabLoad, true); + + gDoc = content.document; + gDoc.addEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); + addEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); + gDoc.defaultView.setTimeout(test_inputAdd, 0); + } + + function test_inputAdd() { + if (expected) { + addEventListener("DOMFormHasPossibleUsername", test_inputAddHandler, { + once: true, + capture: true, + }); + } else { + gDoc.defaultView.setTimeout(test_inputAddHandler, 0); + } + let input = gDoc.createElementNS("http://www.w3.org/1999/xhtml", "input"); + input.setAttribute("type", contentIds.INPUT_TYPE); + input.setAttribute("id", contentIds.INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.getElementById(contentIds.FORM1_ID).appendChild(input); + } + + function test_inputAddHandler(evt) { + if (expected) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM1_ID, + evt.type + + " event targets correct form element (added possible username element)" + ); + } + gDoc.defaultView.setTimeout(test_inputChangeForm, 0); + } + + function test_inputChangeForm() { + if (expected) { + addEventListener( + "DOMFormHasPossibleUsername", + test_inputChangeFormHandler, + { once: true, capture: true } + ); + } else { + gDoc.defaultView.setTimeout(test_inputChangeFormHandler, 0); + } + let input = gDoc.getElementById(contentIds.INPUT_ID); + input.setAttribute("form", contentIds.FORM2_ID); + } + + function test_inputChangeFormHandler(evt) { + if (expected) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM2_ID, + evt.type + " event targets correct form element (changed form)" + ); + } + // TODO(Bug 1864405): Refactor this test to not expect a DOM event + // when the type is set to the same value + const nextTask = + expected && contentIds.INPUT_TYPE === "text" + ? finish + : test_inputChangesType; + gDoc.defaultView.setTimeout(nextTask, 0); + } + + function test_inputChangesType() { + if (expected) { + addEventListener( + "DOMFormHasPossibleUsername", + test_inputChangesTypeHandler, + { once: true, capture: true } + ); + } else { + gDoc.defaultView.setTimeout(test_inputChangesTypeHandler, 0); + } + let input = gDoc.getElementById(contentIds.CHANGE_INPUT_ID); + input.setAttribute("type", contentIds.INPUT_TYPE); + } + + function test_inputChangesTypeHandler(evt) { + if (expected) { + evt.stopPropagation(); + Assert.equal( + evt.target.id, + contentIds.FORM1_ID, + evt.type + " event targets correct form element (changed type)" + ); + } + gDoc.defaultView.setTimeout(finish, 0); + } + + function finish() { + removeEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); + gDoc.removeEventListener( + "DOMFormHasPossibleUsername", + unexpectedContentEvent + ); + resolve(); + } + + return promise; +} + +add_setup(async function () { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); +}); + +add_task(async function test_disconnectedInputs() { + const tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + const unexpectedEvent = evt => { + Assert.ok( + false, + `${evt.type} should not be fired for disconnected forms.` + ); + }; + + addEventListener("DOMFormHasPossibleUsername", unexpectedEvent); + const form = content.document.createElement("form"); + const textInput = content.document.createElement("input"); + textInput.setAttribute("type", "text"); + form.appendChild(textInput); + + // Delay the execution for a bit to allow time for any asynchronously + // dispatched 'DOMFormHasPossibleUsername' events to be processed. + // This is necessary because such events might not be triggered immediately, + // and we want to ensure that if they are dispatched, they are captured + // before we remove the event listener. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 50)); + removeEventListener("DOMFormHasPossibleUsername", unexpectedEvent); + }); + + Assert.ok(true, "Test completed"); + gBrowser.removeCurrentTab(); +}); + +add_task(async function test_usernameOnlyForm() { + for (let type of ["text", "email"]) { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + + ids.INPUT_TYPE = type; + let promise = ContentTask.spawn( + tab.linkedBrowser, + { contentIds: ids, expected: true }, + task + ); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + `data:text/html;charset=utf-8, + <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.loadURIString( + 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.loadURIString( + 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..11ca2ac1cd --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_DOMInputPasswordAdded.js @@ -0,0 +1,101 @@ +const consts = { + HTML_NS: "http://www.w3.org/1999/xhtml", + + INPUT_ID: "input1", + FORM1_ID: "form1", + FORM2_ID: "form2", + CHANGE_INPUT_ID: "input2", + BODY_INPUT_ID: "input3", +}; + +function task(contentConsts) { + let resolve; + let promise = new Promise(r => { + resolve = r; + }); + + function unexpectedContentEvent(evt) { + Assert.ok(false, "Received a " + evt.type + " event on content"); + } + + var gDoc = null; + + addEventListener("load", tabLoad, true); + + function tabLoad() { + removeEventListener("load", tabLoad, true); + gDoc = content.document; + // These events shouldn't escape to content. + gDoc.addEventListener("DOMInputPasswordAdded", unexpectedContentEvent); + gDoc.defaultView.setTimeout(test_inputAddOutsideForm, 0); + } + + function test_inputAddOutsideForm() { + addEventListener( + "DOMInputPasswordAdded", + test_inputAddOutsideFormHandler, + false + ); + let input = gDoc.createElementNS(contentConsts.HTML_NS, "input"); + input.setAttribute("type", "password"); + input.setAttribute("id", contentConsts.BODY_INPUT_ID); + input.setAttribute("data-test", "unique-attribute"); + gDoc.body.appendChild(input); + info("Done appending the input element to the body"); + } + + function test_inputAddOutsideFormHandler(evt) { + removeEventListener(evt.type, test_inputAddOutsideFormHandler, false); + Assert.equal( + evt.target.id, + contentConsts.BODY_INPUT_ID, + evt.type + + " event targets correct input element (added password element outside form)" + ); + gDoc.defaultView.setTimeout(test_inputChangesType, 0); + } + + function test_inputChangesType() { + addEventListener( + "DOMInputPasswordAdded", + test_inputChangesTypeHandler, + false + ); + let input = gDoc.getElementById(contentConsts.CHANGE_INPUT_ID); + input.setAttribute("type", "password"); + } + + function test_inputChangesTypeHandler(evt) { + removeEventListener(evt.type, test_inputChangesTypeHandler, false); + Assert.equal( + evt.target.id, + contentConsts.CHANGE_INPUT_ID, + evt.type + " event targets correct input element (changed type)" + ); + gDoc.defaultView.setTimeout(completeTest, 0); + } + + function completeTest() { + Assert.ok(true, "Test completed"); + gDoc.removeEventListener("DOMInputPasswordAdded", unexpectedContentEvent); + resolve(); + } + + return promise; +} + +add_task(async function () { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); + let promise = ContentTask.spawn(tab.linkedBrowser, consts, task); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + `data:text/html;charset=utf-8, + <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..0be137d88d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_import.js @@ -0,0 +1,259 @@ +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); +const { ExperimentAPI } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +// Dummy migrator to change and detect importable behavior. +const gTestMigrator = { + profiles: [], + + getSourceProfiles() { + return this.profiles; + }, + + migrate: sinon + .stub() + .callsFake(() => + LoginTestUtils.addLogin({ username: "import", password: "pass" }) + ), +}; + +// Showing importables updates counts delayed, so adjust and cleanup. +add_setup(async function setup() { + const debounce = sinon + .stub(LoginManagerParent, "SUGGEST_IMPORT_DEBOUNCE_MS") + .value(0); + const importable = sinon + .stub(ChromeMigrationUtils, "getImportableLogins") + .resolves(["chrome"]); + const migrator = sinon + .stub(MigrationUtils, "getMigrator") + .resolves(gTestMigrator); + + const doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "password-autocomplete", + value: { directMigrateSingleProfile: true }, + }); + + // This makes the last autocomplete test *not* show import suggestions. + Services.prefs.setIntPref("signon.suggestImportCount", 3); + + registerCleanupFunction(async () => { + await doExperimentCleanup(); + debounce.restore(); + importable.restore(); + migrator.restore(); + Services.prefs.clearUserPref("signon.suggestImportCount"); + }); +}); + +add_task(async function check_fluent_ids() { + await document.l10n.ready; + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + + const host = "testhost.com"; + for (const browser of ChromeMigrationUtils.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) { + const id = `autocomplete-import-logins-${browser}`; + const message = await document.l10n.formatValue(id, { host }); + Assert.ok( + message.includes(`data-l10n-name="line1"`), + `${id} included line1` + ); + Assert.ok( + message.includes(`data-l10n-name="line2"`), + `${id} included line2` + ); + Assert.ok(message.includes(host), `${id} replaced host`); + } +}); + +/** + * Tests that if the user selects the password import suggestion from + * the autocomplete popup, and there is more than one profile available + * to import from, that the migration wizard opens to guide the user + * through importing those logins. + */ +add_task(async function import_suggestion_wizard() { + let wizard; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "form_basic.html", + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, "#form-basic-username"); + + const importableItem = popup.querySelector( + `[originaltype="importableLogins"]` + ); + Assert.ok(importableItem, "Got importable suggestion richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !importableItem.collapsed, + "Wait for importable suggestion to show" + ); + + // Pretend there's 2+ profiles to trigger the wizard. + gTestMigrator.profiles.length = 2; + + info("Clicking on importable suggestion"); + const wizardPromise = BrowserTestUtils.waitForMigrationWizard(window); + + // The modal window blocks execution, so avoid calling directly. + executeSoon(() => EventUtils.synthesizeMouseAtCenter(importableItem, {})); + + wizard = await wizardPromise; + Assert.ok(wizard, "Wizard opened"); + Assert.equal( + gTestMigrator.migrate.callCount, + 0, + "Direct migrate not used" + ); + + await closePopup(popup); + } + ); + + // Close the wizard in the end of the test. If we close the wizard when the tab + // is still opened, the username field will be focused again, which triggers another + // importable suggestion. + await BrowserTestUtils.closeMigrationWizard(wizard); +}); + +add_task(async function import_suggestion_learn_more() { + let supportTab; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "form_basic.html", + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, "#form-basic-username"); + + const learnMoreItem = popup.querySelector(`[type="importableLearnMore"]`); + Assert.ok(learnMoreItem, "Got importable learn more richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !learnMoreItem.collapsed, + "Wait for importable learn more to show" + ); + + info("Clicking on importable learn more"); + const supportTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-import" + ); + EventUtils.synthesizeMouseAtCenter(learnMoreItem, {}); + supportTab = await supportTabPromise; + Assert.ok(supportTab, "Support tab opened"); + + await closePopup(popup); + } + ); + + // Close the tab in the end of the test to avoid the username field being + // focused again. + await BrowserTestUtils.removeTab(supportTab); +}); + +add_task(async function import_suggestion_migrate() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "form_basic.html", + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, "#form-basic-username"); + + const importableItem = popup.querySelector( + `[originaltype="importableLogins"]` + ); + Assert.ok(importableItem, "Got importable suggestion richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !importableItem.collapsed, + "Wait for importable suggestion to show" + ); + + // Pretend there's 1 profile to trigger migrate. + gTestMigrator.profiles.length = 1; + + info("Clicking on importable suggestion"); + const migratePromise = BrowserTestUtils.waitForCondition( + () => gTestMigrator.migrate.callCount, + "Wait for direct migration attempt" + ); + EventUtils.synthesizeMouseAtCenter(importableItem, {}); + + const callCount = await migratePromise; + Assert.equal(callCount, 1, "Direct migrate used once"); + + const importedItem = await BrowserTestUtils.waitForCondition( + () => popup.querySelector(`[originaltype="loginWithOrigin"]`), + "Wait for imported login to show" + ); + EventUtils.synthesizeMouseAtCenter(importedItem, {}); + + const username = await SpecialPowers.spawn( + browser, + [], + () => content.document.getElementById("form-basic-username").value + ); + Assert.equal(username, "import", "username from import filled in"); + + LoginTestUtils.clearData(); + } + ); +}); + +add_task(async function import_suggestion_not_shown() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "form_basic.html", + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + let opened = false; + openACPopup(popup, browser, "#form-basic-password").then( + () => (opened = true) + ); + + await TestUtils.waitForCondition(() => { + EventUtils.synthesizeKey("KEY_ArrowDown"); + return opened; + }); + + const footer = popup.querySelector(`[originaltype="loginsFooter"]`); + Assert.ok(footer, "Got footer richlistitem"); + + await TestUtils.waitForCondition(() => { + return !EventUtils.isHidden(footer); + }, "Waiting for footer to become visible"); + + Assert.ok( + !popup.querySelector(`[originaltype="importableLogins"]`), + "No importable suggestion shown" + ); + + await closePopup(popup); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js new file mode 100644 index 0000000000..f00ea80937 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_insecure_warning.js @@ -0,0 +1,44 @@ +"use strict"; + +const EXPECTED_SUPPORT_URL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "insecure-password"; + +add_task(async function test_clickInsecureFieldWarning() { + let url = + "https://example.com" + + DIRECTORY_PATH + + "form_cross_origin_insecure_action.html"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, "#form-basic-username"); + await new Promise(requestAnimationFrame); + + let warningItem = popup.querySelector(`[type="insecureWarning"]`); + Assert.ok(warningItem, "Got warning richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !warningItem.collapsed, + "Wait for warning to show" + ); + + info("Clicking on warning"); + let supportTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + EXPECTED_SUPPORT_URL + ); + EventUtils.synthesizeMouseAtCenter(warningItem, {}); + let supportTab = await supportTabPromise; + Assert.ok(supportTab, "Support tab opened"); + await closePopup(popup); + BrowserTestUtils.removeTab(supportTab); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autocomplete_primary_password.js b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_primary_password.js new file mode 100644 index 0000000000..c3152740cd --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autocomplete_primary_password.js @@ -0,0 +1,121 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +const HOST = "https://example.com"; +const URL = + HOST + "/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"; +const TIMEOUT_PREF = "signon.masterPasswordReprompt.timeout_ms"; + +const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" +); +const BRAND_FULL_NAME = BRAND_BUNDLE.GetStringFromName("brandFullName"); + +// Waits for the primary password prompt and cancels it when close() is called on the return value. +async function waitForDialog() { + let [subject] = await TestUtils.topicObserved("common-dialog-loaded"); + let dialog = subject.Dialog; + let expected = "Password Required - " + BRAND_FULL_NAME; + Assert.equal(dialog.args.title, expected, "Check common dialog title"); + return { + async close(win = window) { + dialog.ui.button1.click(); + return BrowserTestUtils.waitForEvent(win, "DOMModalDialogClosed"); + }, + }; +} + +add_setup(async function () { + let login = LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }); + await Services.logins.addLoginAsync(login); + LoginTestUtils.primaryPassword.enable(); + + registerCleanupFunction(function () { + LoginTestUtils.primaryPassword.disable(); + }); + + // Set primary password prompt timeout to 3s. + // If this test goes intermittent, you likely have to increase this value. + await SpecialPowers.pushPrefEnv({ set: [[TIMEOUT_PREF, 3000]] }); +}); + +// Test that autocomplete does not trigger a primary password prompt +// for a certain time after it was cancelled. +add_task(async function test_mpAutocompleteTimeout() { + // Wait for initial primary password dialog after opening the tab. + let dialogShown = waitForDialog(); + + await BrowserTestUtils.withNewTab(URL, async function (browser) { + (await dialogShown).close(); + + await SpecialPowers.spawn(browser, [], async function () { + // Focus the password field to trigger autocompletion. + content.document.getElementById("form-basic-password").focus(); + }); + + // Wait 4s, dialog should not have been shown + // (otherwise the code below will not work). + await new Promise(c => setTimeout(c, 4000)); + + dialogShown = waitForDialog(); + await SpecialPowers.spawn(browser, [], async function () { + // Re-focus the password field to trigger autocompletion. + content.document.getElementById("form-basic-username").focus(); + content.document.getElementById("form-basic-password").focus(); + }); + (await dialogShown).close(); + closePopup(document.getElementById("PopupAutoComplete")); + }); + + // Wait 4s for the timer to pass again and not interfere with the next test. + await new Promise(c => setTimeout(c, 4000)); +}); + +// Test that autocomplete does not trigger a primary password prompt +// if one is already showing. +add_task(async function test_mpAutocompleteUIBusy() { + // Wait for initial primary password dialog after adding the login. + let dialogShown = waitForDialog(); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + Services.tm.dispatchToMainThread(async () => { + try { + // Trigger a MP prompt in the new window by saving a login + await Services.logins.addLoginAsync(LoginTestUtils.testData.formLogin()); + } catch (e) { + // Handle throwing from MP cancellation + } + }); + let { close } = await dialogShown; + + let windowGlobal = + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal; + let loginManagerParent = windowGlobal.getActor("LoginManager"); + let origin = "https://www.example.com"; + let data = { + actionOrigin: "", + searchString: "", + previousResult: null, + hasBeenTypePassword: true, + isSecure: false, + isProbablyANewPasswordField: true, + }; + + function dialogObserver(subject, topic, data) { + Assert.ok(false, "A second dialog shouldn't have been shown"); + Services.obs.removeObserver(dialogObserver, topic); + } + Services.obs.addObserver(dialogObserver, "common-dialog-loaded"); + + let results = await loginManagerParent.doAutocompleteSearch(origin, data); + Assert.equal(results.logins.length, 0, "No results since uiBusy is true"); + await close(win); + + await BrowserTestUtils.closeWindow(win); + + Services.obs.removeObserver(dialogObserver, "common-dialog-loaded"); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autofill_hidden_document.js b/toolkit/components/passwordmgr/test/browser/browser_autofill_hidden_document.js new file mode 100644 index 0000000000..e7af2f8b84 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autofill_hidden_document.js @@ -0,0 +1,205 @@ +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/aboutlogins/tests/browser/head.js", + this +); + +const TEST_URL_PATH = "/browser/toolkit/components/passwordmgr/test/browser/"; +const INITIAL_URL = `about:blank`; +const FORM_URL = `https://example.org${TEST_URL_PATH}form_basic.html`; +const FORMLESS_URL = `https://example.org${TEST_URL_PATH}formless_basic.html`; +const FORM_MULTIPAGE_URL = `https://example.org${TEST_URL_PATH}form_multipage.html`; +const testUrls = [FORM_URL, FORMLESS_URL, FORM_MULTIPAGE_URL]; +const testUrlsWithForm = [FORM_URL, FORM_MULTIPAGE_URL]; +const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" +); +const BRAND_FULL_NAME = BRAND_BUNDLE.GetStringFromName("brandFullName"); + +async function getDocumentVisibilityState(browser) { + let visibility = await SpecialPowers.spawn(browser, [], async function () { + return content.document.visibilityState; + }); + return visibility; +} + +add_setup(async function () { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); + + Services.logins.removeAllUserFacingLogins(); + let login = LoginTestUtils.testData.formLogin({ + origin: "https://example.org", + formActionOrigin: "https://example.org", + username: "user1", + password: "pass1", + }); + await Services.logins.addLoginAsync(login); +}); + +testUrlsWithForm.forEach(testUrl => { + add_task(async function test_processed_form_fired() { + // Sanity check. If this doesnt work any results for the subsequent tasks are suspect + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + let tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser); + Assert.equal( + tab1Visibility, + "visible", + "The first tab should be foreground" + ); + + let formProcessedPromise = listenForTestNotification("FormProcessed"); + BrowserTestUtils.loadURIString(tab1.linkedBrowser, testUrl); + await formProcessedPromise; + gBrowser.removeTab(tab1); + }); +}); + +testUrls.forEach(testUrl => { + add_task(async function test_defer_autofill_until_visible() { + let result, tab1Visibility; + // open 2 tabs + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + const tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + + // confirm document is hidden + tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser); + Assert.equal( + tab1Visibility, + "hidden", + "The first tab should be backgrounded" + ); + + // we shouldn't even try to autofill while hidden, so wait for the document to be in the + // non-visible pending queue instead. + let formFilled = false; + listenForTestNotification("FormProcessed").then(() => { + formFilled = true; + }); + BrowserTestUtils.loadURIString(tab1.linkedBrowser, testUrl); + + await TestUtils.waitForCondition(() => { + let windowGlobal = tab1.linkedBrowser.browsingContext.currentWindowGlobal; + if (!windowGlobal || windowGlobal.documentURI.spec == "about:blank") { + return false; + } + + let actor = windowGlobal.getActor("LoginManager"); + return actor.sendQuery("PasswordManager:formIsPending"); + }); + + Assert.ok( + !formFilled, + "Observer should not be notified when form is loaded into a hidden document" + ); + + // Add the observer before switching tab + let formProcessedPromise = listenForTestNotification("FormProcessed"); + await BrowserTestUtils.switchTab(gBrowser, tab1); + result = await formProcessedPromise; + tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser); + Assert.equal( + tab1Visibility, + "visible", + "The first tab should be foreground" + ); + Assert.ok( + result, + "Observer should be notified when input's document becomes visible" + ); + + // the form should have been autofilled with the login + let fieldValues = await SpecialPowers.spawn( + tab1.linkedBrowser, + [], + function () { + let doc = content.document; + return { + username: doc.getElementById("form-basic-username").value, + password: doc.getElementById("form-basic-password")?.value, + }; + } + ); + Assert.equal(fieldValues.username, "user1", "Checking filled username"); + + // skip password test for a username-only form + if (![FORM_MULTIPAGE_URL].includes(testUrl)) { + Assert.equal(fieldValues.password, "pass1", "Checking filled password"); + } + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + }); +}); + +testUrlsWithForm.forEach(testUrl => { + add_task(async function test_immediate_autofill_with_primarypassword() { + LoginTestUtils.primaryPassword.enable(); + await LoginTestUtils.reloadData(); + info( + `Have enabled primaryPassword, now isLoggedIn? ${Services.logins.isLoggedIn}` + ); + + registerCleanupFunction(async function () { + LoginTestUtils.primaryPassword.disable(); + await LoginTestUtils.reloadData(); + }); + + // open 2 tabs + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + const tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + INITIAL_URL + ); + + info( + "load a background login form tab with a matching saved login " + + "and wait to see if the primary password dialog is shown" + ); + Assert.equal( + await getDocumentVisibilityState(tab2.linkedBrowser), + "visible", + "The second tab should be visible" + ); + + const tab1Visibility = await getDocumentVisibilityState(tab1.linkedBrowser); + Assert.equal( + tab1Visibility, + "hidden", + "The first tab should be backgrounded" + ); + + const dialogObserved = waitForMPDialog("authenticate", tab1.ownerGlobal); + + // In this case we will try to autofill while hidden, so look for the passwordmgr-processed-form + // to be observed + let formProcessedPromise = listenForTestNotification("FormProcessed"); + BrowserTestUtils.loadURIString(tab1.linkedBrowser, testUrl); + await Promise.all([formProcessedPromise, dialogObserved]); + + Assert.ok( + formProcessedPromise, + "Observer should be notified when form is loaded into a hidden document" + ); + Assert.ok( + dialogObserved, + "MP Dialog should be shown when form is loaded into a hidden document" + ); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + }); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autofill_http.js b/toolkit/components/passwordmgr/test/browser/browser_autofill_http.js new file mode 100644 index 0000000000..df80693673 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autofill_http.js @@ -0,0 +1,135 @@ +const TEST_URL_PATH = + "://example.org/browser/toolkit/components/passwordmgr/test/browser/"; + +add_setup(async function () { + const login1 = LoginTestUtils.testData.formLogin({ + origin: "http://example.org", + formActionOrigin: "http://example.org", + username: "username", + password: "password", + }); + const login2 = LoginTestUtils.testData.formLogin({ + origin: "http://example.org", + formActionOrigin: "http://example.com", + username: "username", + password: "password", + }); + await Services.logins.addLogins([login1, login2]); + await SpecialPowers.pushPrefEnv({ + set: [["signon.autofillForms.http", false]], + }); +}); + +add_task(async function test_http_autofill() { + for (let scheme of ["http", "https"]) { + let formFilled = listenForTestNotification("FormProcessed"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `${scheme}${TEST_URL_PATH}form_basic.html` + ); + + await formFilled; + + let [username, password] = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + let doc = content.document; + let contentUsername = doc.getElementById("form-basic-username").value; + let contentPassword = doc.getElementById("form-basic-password").value; + return [contentUsername, contentPassword]; + } + ); + + Assert.equal( + username, + scheme == "http" ? "" : "username", + "Username filled correctly" + ); + Assert.equal( + password, + scheme == "http" ? "" : "password", + "Password filled correctly" + ); + + gBrowser.removeTab(tab); + } +}); + +add_task(async function test_iframe_in_http_autofill() { + for (let scheme of ["http", "https"]) { + // Wait for parent and child iframe to be processed. + let formFilled = listenForTestNotification("FormProcessed", 2); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `${scheme}${TEST_URL_PATH}form_basic_iframe.html` + ); + + await formFilled; + + let [username, password] = await SpecialPowers.spawn( + gBrowser.selectedBrowser.browsingContext.children[0], + [], + async function () { + let doc = this.content.document; + return [ + doc.getElementById("form-basic-username").value, + doc.getElementById("form-basic-password").value, + ]; + } + ); + + Assert.equal( + username, + scheme == "http" ? "" : "username", + "Username filled correctly" + ); + Assert.equal( + password, + scheme == "http" ? "" : "password", + "Password filled correctly" + ); + + gBrowser.removeTab(tab); + } +}); + +add_task(async function test_http_action_autofill() { + for (let type of ["insecure", "secure"]) { + let formFilled = listenForTestNotification("FormProcessed"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `https${TEST_URL_PATH}form_cross_origin_${type}_action.html` + ); + + await formFilled; + + let [username, password] = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + let doc = this.content.document; + return [ + doc.getElementById("form-basic-username").value, + doc.getElementById("form-basic-password").value, + ]; + } + ); + + Assert.equal( + username, + type == "insecure" ? "" : "username", + "Username filled correctly" + ); + Assert.equal( + password, + type == "insecure" ? "" : "password", + "Password filled correctly" + ); + + gBrowser.removeTab(tab); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_autofill_track_filled_logins.js b/toolkit/components/passwordmgr/test/browser/browser_autofill_track_filled_logins.js new file mode 100644 index 0000000000..df9580828d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_autofill_track_filled_logins.js @@ -0,0 +1,111 @@ +"use strict"; + +const TEST_HOSTNAME = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; +const BASIC_FORM_NO_USERNAME_PAGE_PATH = + DIRECTORY_PATH + "form_basic_no_username.html"; + +add_task(async function test() { + let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + for (let usernameRequested of [true, false]) { + info( + "Testing page with " + + (usernameRequested ? "" : "no ") + + "username requested" + ); + let url = usernameRequested + ? TEST_HOSTNAME + BASIC_FORM_PAGE_PATH + : TEST_HOSTNAME + BASIC_FORM_NO_USERNAME_PAGE_PATH; + + // The login here must be a different domain than the page for this testcase. + let login = new nsLoginInfo( + "https://example.org", + "https://example.org", + null, + "bob", + "mypassword", + "form-basic-username", + "form-basic-password" + ); + login = await Services.logins.addLoginAsync(login); + Assert.equal( + login.timesUsed, + 1, + "The timesUsed should be 1 after creation" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url, + }); + + // Convert the login object to a plain JS object for passing across process boundaries. + login = LoginHelper.loginToVanillaObject(login); + await SpecialPowers.spawn( + tab.linkedBrowser, + [{ login, usernameRequested }], + async ({ login: addedLogin, usernameRequested: aUsernameRequested }) => { + const { LoginFormFactory } = ChromeUtils.importESModule( + "resource://gre/modules/LoginFormFactory.sys.mjs" + ); + const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" + ); + const { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" + ); + + let password = content.document.querySelector("#form-basic-password"); + let formLike = LoginFormFactory.createFromField(password); + info("Calling _fillForm with FormLike"); + addedLogin = LoginHelper.vanillaObjectToLogin(addedLogin); + LoginManagerChild.forWindow(content)._fillForm( + formLike, + [addedLogin], + null, + { + autofillForm: true, + clobberUsername: true, + clobberPassword: true, + userTriggered: true, + } + ); + + if (aUsernameRequested) { + let username = content.document.querySelector("#form-basic-username"); + Assert.equal(username.value, "bob", "Filled username should match"); + } + Assert.equal( + password.value, + "mypassword", + "Filled password should match" + ); + } + ); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.getElementById("form-basic").submit(); + }); + await processedPromise; + + let logins = Services.logins.getAllLogins(); + + Assert.equal(logins.length, 1, "There should only be one login saved"); + Assert.equal( + logins[0].guid, + login.guid, + "The saved login should match the one added and used above" + ); + checkOnlyLoginWasUsedTwice({ justChanged: false }); + + BrowserTestUtils.removeTab(tab); + + // Reset all passwords before next iteration. + Services.logins.removeAllUserFacingLogins(); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_basicAuth_multiTab.js b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_multiTab.js new file mode 100644 index 0000000000..68f21d0ea4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_multiTab.js @@ -0,0 +1,158 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Tests that can show multiple auth prompts in different tabs and handle them + * correctly. + */ + +const ORIGIN1 = "https://example.com"; +const ORIGIN2 = "https://example.org"; + +const AUTH_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"; + +/** + * Opens a tab and navigates to the auth test path. + * @param {String} origin - Origin to open with test path. + * @param {Object} authOptions - Authentication options to pass to server and + * test for. + * @param {String} authOptions.user - Expected username. + * @param {String} authOptions.pass - Expected password. + * @param {String} authOptions.realm - Realm to return on auth request. + * @returns {Object} - An object containing passed origin and authOptions, + * opened tab, a promise which resolves once the tab loads, a promise which + * resolves once the prompt has been opened. + */ +async function openTabWithAuthPrompt(origin, authOptions) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + + let promptPromise = PromptTestUtils.waitForPrompt(tab.linkedBrowser, { + modalType: Services.prompt.MODAL_TYPE_TAB, + promptType: "promptUserAndPass", + }); + let url = new URL(origin + AUTH_PATH); + Object.entries(authOptions).forEach(([key, value]) => + url.searchParams.append(key, value) + ); + let loadPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + url.toString() + ); + info("Loading " + url.toString()); + BrowserTestUtils.loadURIString(tab.linkedBrowser, url.toString()); + return { origin, tab, authOptions, loadPromise, promptPromise }; +} + +/** + * Waits for tab to load and tests for expected auth state. + * @param {boolean} expectAuthed - true: auth success, false otherwise. + * @param {Object} tabInfo - Information about the tab as generated by + * openTabWithAuthPrompt. + */ +async function testTabAuthed(expectAuthed, { tab, loadPromise, authOptions }) { + // Wait for tab to load after auth. + await loadPromise; + Assert.ok(true, "Tab loads after auth"); + + // Fetch auth results from body (set by authenticate.sjs). + let { loginResult, user, pass } = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + let result = {}; + result.loginResult = content.document.getElementById("ok").innerText; + result.user = content.document.getElementById("user").innerText; + result.pass = content.document.getElementById("pass").innerText; + return result; + } + ); + + Assert.equal( + loginResult == "PASS", + expectAuthed, + "Site has expected auth state" + ); + Assert.equal(user, expectAuthed ? authOptions.user : "", "Sent correct user"); + Assert.equal( + pass, + expectAuthed ? authOptions.pass : "", + "Sent correct password" + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + // This test relies on tab auth prompts. + set: [["prompts.modalType.httpAuth", Services.prompt.MODAL_TYPE_TAB]], + }); +}); + +add_task(async function test() { + let tabA = await openTabWithAuthPrompt(ORIGIN1, { + user: "userA", + pass: "passA", + realm: "realmA", + }); + // Tab B and C share realm and credentials. + // However, since the auth happens in separate tabs we should get two prompts. + let tabB = await openTabWithAuthPrompt(ORIGIN2, { + user: "userB", + pass: "passB", + realm: "realmB", + }); + let tabC = await openTabWithAuthPrompt(ORIGIN2, { + user: "userB", + pass: "passB", + realm: "realmB", + }); + let tabs = [tabA, tabB, tabC]; + + info(`Opening ${tabs.length} tabs with auth prompts`); + let prompts = await Promise.all(tabs.map(tab => tab.promptPromise)); + + Assert.equal(prompts.length, tabs.length, "Should have one prompt per tab"); + + for (let i = 0; i < prompts.length; i++) { + let titleEl = prompts[i].ui.prompt.document.querySelector("#titleText"); + Assert.equal( + titleEl.textContent, + new URL(tabs[i].origin).host, + "Prompt matches the tab's host" + ); + } + + // Interact with the prompts. This is deliberately done out of order + // (no FIFO, LIFO). + let [promptA, promptB, promptC] = prompts; + + // Accept prompt B with correct login details. + await PromptTestUtils.handlePrompt(promptB, { + loginInput: tabB.authOptions.user, + passwordInput: tabB.authOptions.pass, + }); + await testTabAuthed(true, tabB); + + // Accept prompt A with correct login details + await PromptTestUtils.handlePrompt(promptA, { + loginInput: tabA.authOptions.user, + passwordInput: tabA.authOptions.pass, + }); + await testTabAuthed(true, tabA); + + // Cancel prompt C + await PromptTestUtils.handlePrompt(promptC, { + buttonNumClick: 1, + }); + await testTabAuthed(false, tabC); + + // Cleanup tabs + tabs.forEach(({ tab }) => BrowserTestUtils.removeTab(tab)); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_basicAuth_rateLimit.js b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_rateLimit.js new file mode 100644 index 0000000000..1da16090c9 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_rateLimit.js @@ -0,0 +1,146 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This tests that the basic auth dialog can not be used for DOS attacks +// and that the protections are reset on user-initiated navigation/reload. + +let promptModalType = Services.prefs.getIntPref("prompts.modalType.httpAuth"); + +function promiseAuthWindowShown() { + return PromptTestUtils.handleNextPrompt( + window, + { modalType: promptModalType, promptType: "promptUserAndPass" }, + { buttonNumClick: 1 } + ); +} + +add_task(async function test() { + await BrowserTestUtils.withNewTab( + "https://example.com", + async function (browser) { + let cancelDialogLimit = Services.prefs.getIntPref( + "prompts.authentication_dialog_abuse_limit" + ); + + let authShown = promiseAuthWindowShown(); + let browserLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString( + browser, + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs" + ); + await authShown; + Assert.ok(true, "Seen dialog number 1"); + await browserLoaded; + Assert.ok(true, "Loaded document number 1"); + + // Reload the document a bit more often than should be allowed. + // As long as we're in the acceptable range we should receive + // auth prompts, otherwise we should not receive them and the + // page should just load. + // We've already seen the dialog once, hence we start the loop at 1. + for (let i = 1; i < cancelDialogLimit + 2; i++) { + if (i < cancelDialogLimit) { + authShown = promiseAuthWindowShown(); + } + browserLoaded = BrowserTestUtils.browserLoaded(browser); + SpecialPowers.spawn(browser, [], function () { + content.document.location.reload(); + }); + if (i < cancelDialogLimit) { + await authShown; + Assert.ok(true, `Seen dialog number ${i + 1}`); + } + await browserLoaded; + Assert.ok(true, `Loaded document number ${i + 1}`); + } + + let reloadButton = document.getElementById("reload-button"); + await TestUtils.waitForCondition( + () => !reloadButton.hasAttribute("disabled") + ); + + // Verify that we can click the reload button to reset the counter. + authShown = promiseAuthWindowShown(); + browserLoaded = BrowserTestUtils.browserLoaded(browser); + reloadButton.click(); + await authShown; + Assert.ok(true, "Seen dialog number 1"); + await browserLoaded; + Assert.ok(true, "Loaded document number 1"); + + // Now check loading subresources with auth on the page. + browserLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, "https://example.com"); + await browserLoaded; + + // We've already seen the dialog once, hence we start the loop at 1. + for (let i = 1; i < cancelDialogLimit + 2; i++) { + if (i < cancelDialogLimit) { + authShown = promiseAuthWindowShown(); + } + + let iframeLoaded = SpecialPowers.spawn(browser, [], async function () { + let doc = content.document; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + let loaded = new Promise(resolve => { + iframe.addEventListener( + "load", + function (e) { + resolve(); + }, + { once: true } + ); + }); + iframe.src = + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"; + await loaded; + }); + + if (i < cancelDialogLimit) { + await authShown; + Assert.ok(true, `Seen dialog number ${i + 1}`); + } + + await iframeLoaded; + Assert.ok(true, `Loaded iframe number ${i + 1}`); + } + + // Verify that third party subresources can not spawn new auth dialogs. + let iframeLoaded = SpecialPowers.spawn(browser, [], async function () { + let doc = content.document; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + let loaded = new Promise(resolve => { + iframe.addEventListener( + "load", + function (e) { + resolve(); + }, + { once: true } + ); + }); + iframe.src = + "https://example.org/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"; + await loaded; + }); + + await iframeLoaded; + Assert.ok( + true, + "Loaded a third party iframe without showing the auth dialog" + ); + + // Verify that pressing enter in the urlbar also resets the counter. + authShown = promiseAuthWindowShown(); + browserLoaded = BrowserTestUtils.browserLoaded(browser); + gURLBar.value = + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs"; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + await authShown; + await browserLoaded; + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_basicAuth_switchTab.js b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_switchTab.js new file mode 100644 index 0000000000..32bcd13ae4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_basicAuth_switchTab.js @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +let modalType = Services.prefs.getIntPref("prompts.modalType.httpAuth"); + +add_task(async function test() { + let tab = BrowserTestUtils.addTab(gBrowser); + isnot(tab, gBrowser.selectedTab, "New tab shouldn't be selected"); + + let authPromptShown = PromptTestUtils.waitForPrompt(tab.linkedBrowser, { + modalType, + promptType: "promptUserAndPass", + }); + + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/authenticate.sjs" + ); + + // Wait for the basic auth prompt + let dialog = await authPromptShown; + + Assert.equal(gBrowser.selectedTab, tab, "Should have selected the new tab"); + + // Cancel the auth prompt + PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 1 }); + + // After closing the prompt the load should finish + await loadPromise; + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu.js new file mode 100644 index 0000000000..63e161c003 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu.js @@ -0,0 +1,678 @@ +/** + * Test the password manager context menu. + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const MULTIPLE_FORMS_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/multiple_forms.html"; + +const CONTEXT_MENU = document.getElementById("contentAreaContextMenu"); +const POPUP_HEADER = document.getElementById("fill-login"); + +/** + * Initialize logins needed for the tests and disable autofill + * for login forms for easier testing of manual fill. + */ +add_task(async function test_initialize() { + Services.prefs.setBoolPref("signon.autofillForms", false); + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.autofillForms"); + Services.prefs.clearUserPref("signon.schemeUpgrades"); + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); + await Services.logins.addLogins(loginList()); +}); + +/** + * Check if the context menu is populated with the right + * menuitems for the target password input field. + */ +add_task(async function test_context_menu_populate_password_noSchemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-password-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 2); + + await closePopup(CONTEXT_MENU); + } + ); +}); + +/** + * Check if the context menu is populated with the right + * menuitems for the target password input field. + */ +add_task(async function test_context_menu_populate_password_schemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-password-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 3); + + await closePopup(CONTEXT_MENU); + } + ); +}); + +/** + * Check if the context menu is populated with the right menuitems + * for the target username field with a password field present. + */ +add_task( + async function test_context_menu_populate_username_with_password_noSchemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + TEST_ORIGIN + + "/browser/toolkit/components/" + + "passwordmgr/test/browser/multiple_forms.html", + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-username-3"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 2); + + await closePopup(CONTEXT_MENU); + } + ); + } +); +/** + * Check if the context menu is populated with the right menuitems + * for the target username field with a password field present. + */ +add_task( + async function test_context_menu_populate_username_with_password_schemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + TEST_ORIGIN + + "/browser/toolkit/components/" + + "passwordmgr/test/browser/multiple_forms.html", + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-username-3"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 3); + + await closePopup(CONTEXT_MENU); + } + ); + } +); + +/** + * Check if the context menu is populated with the right menuitems + * for the target username field without a password field present. + */ +add_task( + async function test_context_menu_populate_username_with_password_noSchemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + TEST_ORIGIN + + "/browser/toolkit/components/" + + "passwordmgr/test/browser/multiple_forms.html", + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-username-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 2); + + await closePopup(CONTEXT_MENU); + } + ); + } +); +/** + * Check if the context menu is populated with the right menuitems + * for the target username field without a password field present. + */ +add_task( + async function test_context_menu_populate_username_with_password_schemeUpgrades() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + TEST_ORIGIN + + "/browser/toolkit/components/" + + "passwordmgr/test/browser/multiple_forms.html", + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-username-1"); + + // Check the content of the password manager popup + let popupMenu = document.getElementById("fill-login-popup"); + checkMenu(popupMenu, 3); + + await closePopup(CONTEXT_MENU); + } + ); + } +); + +/** + * Check if the password field is correctly filled when one + * login menuitem is clicked. + */ +add_task(async function test_context_menu_password_fill() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + let formDescriptions = await SpecialPowers.spawn( + browser, + [], + async function () { + let forms = Array.from( + content.document.getElementsByClassName("test-form") + ); + return forms.map(f => f.getAttribute("description")); + } + ); + + for (let description of formDescriptions) { + info("Testing form: " + description); + + let passwordInputIds = await SpecialPowers.spawn( + browser, + [{ description }], + async function ({ description }) { + let formElement = content.document.querySelector( + `[description="${description}"]` + ); + let passwords = Array.from( + formElement.querySelectorAll( + "input[type='password'], input[data-type='password']" + ) + ); + return passwords.map(p => p.id); + } + ); + + for (let inputId of passwordInputIds) { + info("Testing password field: " + inputId); + + // Synthesize a right mouse click over the password input element. + await openPasswordContextMenu( + browser, + "#" + inputId, + async function () { + let inputDisabled = await SpecialPowers.spawn( + browser, + [{ inputId }], + async function ({ inputId }) { + let input = content.document.getElementById(inputId); + return input.disabled || input.readOnly; + } + ); + + // If the password field is disabled or read-only, we want to see + // the disabled Fill Password popup header. + if (inputDisabled) { + Assert.ok(!POPUP_HEADER.hidden, "Popup menu is not hidden."); + Assert.ok(POPUP_HEADER.disabled, "Popup menu is disabled."); + await closePopup(CONTEXT_MENU); + } + Assert.equal( + POPUP_HEADER.getAttribute("data-l10n-id"), + "main-context-menu-use-saved-password", + "top-level label is correct" + ); + + return !inputDisabled; + } + ); + + if (CONTEXT_MENU.state != "open") { + continue; + } + + // The only field affected by the password fill + // should be the target password field itself. + await assertContextMenuFill(browser, description, null, inputId, 1); + await SpecialPowers.spawn( + browser, + [{ inputId }], + async function ({ inputId }) { + let passwordField = content.document.getElementById(inputId); + Assert.equal( + passwordField.value, + "password1", + "Check upgraded login was actually used" + ); + } + ); + + await closePopup(CONTEXT_MENU); + } + } + } + ); +}); + +/** + * Check if the form is correctly filled when one + * username context menu login menuitem is clicked. + */ +add_task(async function test_context_menu_username_login_fill() { + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + let formDescriptions = await SpecialPowers.spawn( + browser, + [], + async function () { + let forms = Array.from( + content.document.getElementsByClassName("test-form") + ); + return forms.map(f => f.getAttribute("description")); + } + ); + + for (let description of formDescriptions) { + info("Testing form: " + description); + let usernameInputIds = await SpecialPowers.spawn( + browser, + [{ description }], + async function ({ description }) { + let formElement = content.document.querySelector( + `[description="${description}"]` + ); + let inputs = Array.from( + formElement.querySelectorAll( + "input[type='text']:not([data-type='password'])" + ) + ); + return inputs.map(p => p.id); + } + ); + + for (let inputId of usernameInputIds) { + info("Testing username field: " + inputId); + + // Synthesize a right mouse click over the username input element. + await openPasswordContextMenu( + browser, + "#" + inputId, + async function () { + let headerHidden = POPUP_HEADER.hidden; + let headerDisabled = POPUP_HEADER.disabled; + let headerLabelID = POPUP_HEADER.getAttribute("data-l10n-id"); + + let data = { + description, + inputId, + headerHidden, + headerDisabled, + headerLabelID, + }; + let shouldContinue = await SpecialPowers.spawn( + browser, + [data], + async function (data) { + let { + description, + inputId, + headerHidden, + headerDisabled, + headerLabelID, + } = data; + let formElement = content.document.querySelector( + `[description="${description}"]` + ); + let usernameField = content.document.getElementById(inputId); + // We always want to check if the first password field is filled, + // since this is the current behavior from the _fillForm function. + let passwordField = formElement.querySelector( + "input[type='password'], input[data-type='password']" + ); + + // If we don't want to see the actual popup menu, + // check if the popup is hidden or disabled. + if ( + !passwordField || + usernameField.disabled || + usernameField.readOnly || + passwordField.disabled || + passwordField.readOnly + ) { + if (!passwordField) { + // Should show popup for a username-only form. + if (usernameField.autocomplete == "username") { + Assert.ok(!headerHidden, "Popup menu is not hidden."); + } else { + Assert.ok(headerHidden, "Popup menu is hidden."); + } + } else { + Assert.ok(!headerHidden, "Popup menu is not hidden."); + Assert.ok(headerDisabled, "Popup menu is disabled."); + } + return false; + } + Assert.equal( + headerLabelID, + "main-context-menu-use-saved-login", + "top-level label is correct" + ); + return true; + } + ); + + if (!shouldContinue) { + await closePopup(CONTEXT_MENU); + } + + return shouldContinue; + } + ); + + if (CONTEXT_MENU.state != "open") { + continue; + } + + let passwordFieldId = await SpecialPowers.spawn( + browser, + [{ description }], + async function ({ description }) { + let formElement = content.document.querySelector( + `[description="${description}"]` + ); + return formElement.querySelector( + "input[type='password'], input[data-type='password']" + ).id; + } + ); + + // We shouldn't change any field that's not the target username field or the first password field + await assertContextMenuFill( + browser, + description, + inputId, + passwordFieldId, + 1 + ); + + await SpecialPowers.spawn( + browser, + [{ passwordFieldId }], + async function ({ passwordFieldId }) { + let passwordField = + content.document.getElementById(passwordFieldId); + if (!passwordField.hasAttribute("expectedFail")) { + Assert.equal( + passwordField.value, + "password1", + "Check upgraded login was actually used" + ); + } + } + ); + + await closePopup(CONTEXT_MENU); + } + } + } + ); +}); + +/** + * Check event telemetry is correctly recorded when opening the saved logins / management UI + * from the context menu + */ +add_task(async function test_context_menu_open_management() { + Services.prefs.setBoolPref("signon.schemeUpgrades", false); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + MULTIPLE_FORMS_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu(browser, "#test-password-1"); + + let openingFunc = () => gContextMenu.openPasswordManager(); + // wait until the management UI opens + let passwordManager = await openPasswordManager(openingFunc); + info("Management UI dialog was opened"); + + TelemetryTestUtils.assertEvents( + [["pwmgr", "open_management", "contextmenu"]], + { category: "pwmgr", method: "open_management" }, + { clear: true, process: "content" } + ); + + await passwordManager.close(); + await closePopup(CONTEXT_MENU); + } + ); +}); + +/** + * Verify that only the expected form fields are filled. + */ +async function assertContextMenuFill( + browser, + formId, + usernameFieldId, + passwordFieldId, + loginIndex +) { + let popupMenu = document.getElementById("fill-login-popup"); + let unchangedSelector = `[description="${formId}"] input:not(#${passwordFieldId})`; + + if (usernameFieldId) { + unchangedSelector += `:not(#${usernameFieldId})`; + } + + await SpecialPowers.spawn( + browser, + [{ unchangedSelector }], + async function ({ unchangedSelector }) { + let unchangedFields = + content.document.querySelectorAll(unchangedSelector); + + // Store the value of fields that should remain unchanged. + if (unchangedFields.length) { + for (let field of unchangedFields) { + field.setAttribute("original-value", field.value); + } + } + } + ); + + // Execute the default command of the specified login menuitem found in the context menu. + let loginItem = + popupMenu.getElementsByClassName("context-login-item")[loginIndex]; + + // Find the used login by it's username (Use only unique usernames in this test). + let { username, password } = getLoginFromUsername(loginItem.label); + + let data = { + username, + password, + usernameFieldId, + passwordFieldId, + formId, + unchangedSelector, + }; + let continuePromise = ContentTask.spawn(browser, data, async function (data) { + let { + username, + password, + usernameFieldId, + passwordFieldId, + formId, + unchangedSelector, + } = data; + let form = content.document.querySelector(`[description="${formId}"]`); + await ContentTaskUtils.waitForEvent( + form, + "input", + "Username input value changed" + ); + + if (usernameFieldId) { + let usernameField = content.document.getElementById(usernameFieldId); + + // If we have an username field, check if it's correctly filled + if (usernameField.getAttribute("expectedFail") == null) { + Assert.equal( + username, + usernameField.value, + "Username filled and correct." + ); + } + } + + if (passwordFieldId) { + let passwordField = content.document.getElementById(passwordFieldId); + + // If we have a password field, check if it's correctly filled + if (passwordField && passwordField.getAttribute("expectedFail") == null) { + Assert.equal( + password, + passwordField.value, + "Password filled and correct." + ); + } + } + + let unchangedFields = content.document.querySelectorAll(unchangedSelector); + + // Check that all fields that should not change have the same value as before. + if (unchangedFields.length) { + Assert.ok(() => { + for (let field of unchangedFields) { + if (field.value != field.getAttribute("original-value")) { + return false; + } + } + return true; + }, "Other fields were not changed."); + } + }); + + loginItem.doCommand(); + + return continuePromise; +} + +/** + * Check if every login that matches the page origin are available at the context menu. + * @param {Element} contextMenu + * @param {Number} expectedCount - Number of logins expected in the context menu. Used to ensure + * we continue testing something useful. + */ +function checkMenu(contextMenu, expectedCount) { + let logins = loginList().filter(login => { + return LoginHelper.isOriginMatching(login.origin, TEST_ORIGIN, { + schemeUpgrades: Services.prefs.getBoolPref("signon.schemeUpgrades"), + }); + }); + // Make an array of menuitems for easier comparison. + let menuitems = [ + ...CONTEXT_MENU.getElementsByClassName("context-login-item"), + ]; + Assert.equal( + menuitems.length, + expectedCount, + "Expected number of menu items" + ); + Assert.ok( + logins.every(l => menuitems.some(m => l.username == m.label)), + "Every login have an item at the menu." + ); +} + +/** + * Search for a login by it's username. + * + * Only unique login/origin combinations should be used at this test. + */ +function getLoginFromUsername(username) { + return loginList().find(login => login.username == username); +} + +/** + * List of logins used for the test. + * + * We should only use unique usernames in this test, + * because we need to search logins by username. There is one duplicate u+p combo + * in order to test de-duping in the menu. + */ +function loginList() { + return [ + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }), + // Same as above but HTTP in order to test de-duping. + LoginTestUtils.testData.formLogin({ + origin: "http://example.com", + formActionOrigin: "http://example.com", + username: "username", + password: "password", + }), + LoginTestUtils.testData.formLogin({ + origin: "http://example.com", + formActionOrigin: "http://example.com", + username: "username1", + password: "password1", + }), + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username2", + password: "password2", + }), + LoginTestUtils.testData.formLogin({ + origin: "http://example.org", + formActionOrigin: "http://example.org", + username: "username-cross-origin", + password: "password-cross-origin", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js new file mode 100644 index 0000000000..8ffe07a673 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_autocomplete_interaction.js @@ -0,0 +1,120 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* + * Test the password manager context menu interaction with autocomplete. + */ + +"use strict"; + +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; + +/** + * Initialize logins needed for the tests and disable autofill + * for login forms for easier testing of manual fill. + */ +add_task(async function test_initialize() { + let autocompletePopup = document.getElementById("PopupAutoComplete"); + Services.prefs.setBoolPref("signon.autofillForms", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.autofillForms"); + autocompletePopup.removeEventListener( + "popupshowing", + autocompleteUnexpectedPopupShowing + ); + }); + await Services.logins.addLogins(loginList()); + autocompletePopup.addEventListener( + "popupshowing", + autocompleteUnexpectedPopupShowing + ); +}); + +add_task(async function test_context_menu_username() { + let formFilled = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + BASIC_FORM_PAGE_PATH, + }, + async function (browser) { + await formFilled; + await openContextMenu(browser, "#form-basic-username"); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + Assert.equal(contextMenu.state, "open", "Context menu opened"); + contextMenu.hidePopup(); + } + ); +}); + +add_task(async function test_context_menu_password() { + let formFilled = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + BASIC_FORM_PAGE_PATH, + }, + async function (browser) { + await formFilled; + await openContextMenu(browser, "#form-basic-password"); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + Assert.equal(contextMenu.state, "open", "Context menu opened"); + contextMenu.hidePopup(); + } + ); +}); + +function autocompleteUnexpectedPopupShowing(event) { + Assert.ok(false, "Autocomplete shouldn't appear"); + event.target.hidePopup(); +} + +/** + * Synthesize mouse clicks to open the context menu popup + * for a target login input element. + */ +async function openContextMenu(browser, loginInput) { + // First synthesize a mousedown. We need this to get the focus event with the "contextmenu" event. + let eventDetails1 = { type: "mousedown", button: 2 }; + await BrowserTestUtils.synthesizeMouseAtCenter( + loginInput, + eventDetails1, + browser + ); + + // Then synthesize the contextmenu click over the input element. + let contextMenuShownPromise = BrowserTestUtils.waitForEvent( + window, + "popupshown" + ); + let eventDetails = { type: "contextmenu", button: 2 }; + await BrowserTestUtils.synthesizeMouseAtCenter( + loginInput, + eventDetails, + browser + ); + await contextMenuShownPromise; + + // Wait to see which popups are shown. + await new Promise(resolve => setTimeout(resolve, 1000)); +} + +function loginList() { + return [ + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }), + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username2", + password: "password2", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js new file mode 100644 index 0000000000..4e4edb7b14 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_generated_password.js @@ -0,0 +1,482 @@ +/** + * Test the password manager context menu item can fill password fields with a generated password. + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const FORM_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic_login.html"; +const CONTEXT_MENU = document.getElementById("contentAreaContextMenu"); + +const passwordInputSelector = "#form-basic-password"; + +registerCleanupFunction(async function cleanup_resetPrefs() { + await SpecialPowers.popPrefEnv(); +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.generation.available", true], + ["signon.generation.enabled", true], + ], + }); + // assert that there are no logins + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins"); +}); + +add_task(async function test_hidden_by_prefs() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.generation.available", true], + ["signon.generation.enabled", false], + ], + }); + + // test that the generated password option is not present when the feature is not enabled + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await openPasswordContextMenu(browser, passwordInputSelector); + let generatedPasswordItem = document.getElementById( + "fill-login-generated-password" + ); + Assert.ok( + !BrowserTestUtils.is_visible(generatedPasswordItem), + "generated password item is hidden" + ); + + CONTEXT_MENU.hidePopup(); + } + ); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_fill_hidden_by_login_saving_disabled() { + // test that the generated password option is not present when the user + // disabled password saving for the site. + Services.logins.setLoginSavingEnabled(TEST_ORIGIN, false); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await openPasswordContextMenu(browser, passwordInputSelector); + let generatedPasswordItem = document.getElementById( + "fill-login-generated-password" + ); + Assert.ok( + !BrowserTestUtils.is_visible(generatedPasswordItem), + "generated password item is hidden" + ); + + CONTEXT_MENU.hidePopup(); + } + ); + + Services.logins.setLoginSavingEnabled(TEST_ORIGIN, true); +}); + +add_task(async function test_fill_hidden_by_locked_primary_password() { + // test that the generated password option is not present when the user + // didn't unlock the primary password. + LoginTestUtils.primaryPassword.enable(); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await openPasswordContextMenu( + browser, + passwordInputSelector, + () => false + ); + let generatedPasswordItem = document.getElementById( + "fill-login-generated-password" + ); + Assert.ok( + BrowserTestUtils.is_visible(generatedPasswordItem), + "generated password item is visible" + ); + Assert.ok( + generatedPasswordItem.disabled, + "generated password item is disabled" + ); + + CONTEXT_MENU.hidePopup(); + } + ); + + LoginTestUtils.primaryPassword.disable(); +}); + +add_task(async function fill_generated_password_empty_field() { + // test that we can fill with generated password into an empty password field + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkInitialFieldValue(inputSelector) { + const input = content.document.querySelector(inputSelector); + Assert.equal(input.value.length, 0, "Password field is empty"); + Assert.ok( + !input.matches(":autofill"), + "Password field should not be highlighted" + ); + } + ); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFinalFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + Assert.ok( + input.matches(":autofill"), + "Password field should be highlighted" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + + info("cleaing the field"); + input.setUserInput(""); + } + ); + + let acPopup = document.getElementById("PopupAutoComplete"); + await openACPopup(acPopup, browser, passwordInputSelector); + + let pwgenItem = acPopup.querySelector( + `[originaltype="generatedPassword"]` + ); + Assert.ok( + !pwgenItem || EventUtils.isHidden(pwgenItem), + "pwgen item should no longer be shown" + ); + + await closePopup(acPopup); + } + ); +}); + +add_task(async function fill_generated_password_nonempty_field() { + // test that we can fill with generated password into an non-empty password field + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await changeContentFormValues(browser, { + [passwordInputSelector]: "aa", + }); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkInitialFieldValue(inputSelector) { + const input = content.document.querySelector(inputSelector); + Assert.ok( + !input.matches(":autofill"), + "Password field should not be highlighted" + ); + } + ); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFinalFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + Assert.ok( + input.matches(":autofill"), + "Password field should be highlighted" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + } + ); + } + ); + LoginTestUtils.clearData(); + LoginTestUtils.resetGeneratedPasswordsCache(); +}); + +add_task(async function fill_generated_password_with_matching_logins() { + // test that we can fill a generated password when there are matching logins + let login = LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "pass1", + }); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + await Services.logins.addLoginAsync(login); + await storageChangedPromised; + + let formFilled = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await formFilled; + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkInitialFieldValue(inputSelector) { + Assert.equal( + content.document.querySelector(inputSelector).value, + "pass1", + "Password field has initial value" + ); + } + ); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFinalFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + Assert.ok( + input.matches(":autofill"), + "Password field should be highlighted" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + } + ); + + await openPasswordContextMenu(browser, passwordInputSelector); + + // Execute the command of the first login menuitem found at the context menu. + let passwordChangedPromise = ContentTask.spawn( + browser, + null, + async function () { + let passwordInput = content.document.getElementById( + "form-basic-password" + ); + await ContentTaskUtils.waitForEvent(passwordInput, "input"); + } + ); + + let popupMenu = document.getElementById("fill-login-popup"); + let firstLoginItem = + popupMenu.getElementsByClassName("context-login-item")[0]; + firstLoginItem.doCommand(); + + await passwordChangedPromise; + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + + // Blur the field to trigger a 'change' event. + await BrowserTestUtils.synthesizeKey("KEY_Tab", undefined, browser); + await BrowserTestUtils.synthesizeKey( + "KEY_Tab", + { shiftKey: true }, + browser + ); + + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFieldNotGeneratedPassword(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value, + "pass1", + "Password field was filled with the saved password" + ); + LTU.loginField.checkPasswordMasked( + input, + true, + "after fill of a saved login" + ); + } + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Check 2 logins"); + isnot( + logins[0].password, + logins[1].password, + "Generated password shouldn't have changed to match the filled password" + ); + + Services.logins.removeAllUserFacingLogins(); + LoginTestUtils.resetGeneratedPasswordsCache(); +}); + +add_task(async function test_edited_generated_password_in_new_tab() { + // test that we can fill the generated password into an empty password field, + // edit it, and then fill the edited password. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkInitialFieldValue(inputSelector) { + const input = content.document.querySelector(inputSelector); + Assert.equal(input.value.length, 0, "Password field is empty"); + Assert.ok( + !input.matches(":autofill"), + "Password field should not be highlighted" + ); + } + ); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkAndEditFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + Assert.ok( + input.matches(":autofill"), + "Password field should be highlighted" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + } + ); + + await BrowserTestUtils.sendChar("!", browser); + await BrowserTestUtils.sendChar("@", browser); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await BrowserTestUtils.synthesizeKey("KEY_Tab", undefined, browser); + info("Waiting for storage update"); + await storageChangedPromised; + } + ); + + info("Now fill again in a new tab and ensure the edited password is used"); + + // Disable autofill in the new tab + await SpecialPowers.pushPrefEnv({ + set: [["signon.autofillForms", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + FORM_PAGE_PATH, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkAndEditFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + const input = content.document.querySelector(inputSelector); + Assert.equal( + input.value.length, + LTU.generation.LENGTH + 2, + "Password field was filled with edited generated password" + ); + LTU.loginField.checkPasswordMasked(input, false, "after fill"); + } + ); + } + ); + + LoginTestUtils.clearData(); + LoginTestUtils.resetGeneratedPasswordsCache(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js b/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js new file mode 100644 index 0000000000..2545cdfebe --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_context_menu_iframe.js @@ -0,0 +1,223 @@ +/* + * Test the password manager context menu. + */ + +"use strict"; + +const TEST_ORIGIN = "https://example.com"; + +// Test with a page that only has a form within an iframe, not in the top-level document +const IFRAME_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic_iframe.html"; + +/** + * Initialize logins needed for the tests and disable autofill + * for login forms for easier testing of manual fill. + */ +add_task(async function test_initialize() { + Services.prefs.setBoolPref("signon.autofillForms", false); + Services.prefs.setBoolPref("signon.schemeUpgrades", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.autofillForms"); + Services.prefs.clearUserPref("signon.schemeUpgrades"); + }); + await Services.logins.addLogins(loginList()); +}); + +/** + * Check if the password field is correctly filled when it's in an iframe. + */ +add_task(async function test_context_menu_iframe_fill() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + IFRAME_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu( + browser, + "#form-basic-password", + () => true, + browser.browsingContext.children[0], + true + ); + + let popupMenu = document.getElementById("fill-login-popup"); + + // Stores the original value of username + function promiseFrameInputValue(name) { + return SpecialPowers.spawn( + browser.browsingContext.children[0], + [name], + function (inputname) { + return content.document.getElementById(inputname).value; + } + ); + } + let usernameOriginalValue = await promiseFrameInputValue( + "form-basic-username" + ); + + // Execute the command of the first login menuitem found at the context menu. + let firstLoginItem = + popupMenu.getElementsByClassName("context-login-item")[0]; + Assert.ok(firstLoginItem, "Found the first login item"); + + await TestUtils.waitForTick(); + + Assert.ok( + BrowserTestUtils.is_visible(firstLoginItem), + "First login menuitem is visible" + ); + + info("Clicking on the firstLoginItem"); + // click on the login item to fill the password field, triggering an "input" event + popupMenu.activateItem(firstLoginItem); + + let passwordValue = await TestUtils.waitForCondition(async () => { + let value = await promiseFrameInputValue("form-basic-password"); + return value; + }); + + // Find the used login by it's username. + let login = getLoginFromUsername(firstLoginItem.label); + Assert.equal( + login.password, + passwordValue, + "Password filled and correct." + ); + + let usernameNewValue = await promiseFrameInputValue( + "form-basic-username" + ); + Assert.equal( + usernameOriginalValue, + usernameNewValue, + "Username value was not changed." + ); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + } + ); +}); + +/** + * Check that the login context menu items don't appear on an opaque origin. + */ +add_task(async function test_context_menu_iframe_sandbox() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + IFRAME_PAGE_PATH, + }, + async function (browser) { + info("Opening context menu for test_context_menu_iframe_sandbox"); + await openPasswordContextMenu( + browser, + "#form-basic-password", + function checkDisabled() { + info("checkDisabled for test_context_menu_iframe_sandbox"); + let popupHeader = document.getElementById("fill-login"); + Assert.ok( + popupHeader.hidden, + "Check that the Fill Login menu item is hidden" + ); + return false; + }, + browser.browsingContext.children[1] + ); + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + } + ); +}); + +/** + * Check that the login context menu item appears for sandbox="allow-same-origin" + */ +add_task(async function test_context_menu_iframe_sandbox_same_origin() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN + IFRAME_PAGE_PATH, + }, + async function (browser) { + await openPasswordContextMenu( + browser, + "#form-basic-password", + function checkDisabled() { + let popupHeader = document.getElementById("fill-login"); + Assert.ok( + !popupHeader.hidden, + "Check that the Fill Login menu item is visible" + ); + Assert.ok( + !popupHeader.disabled, + "Check that the Fill Login menu item is disabled" + ); + return false; + }, + browser.browsingContext.children[2] + ); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.hidePopup(); + } + ); +}); + +/** + * Search for a login by it's username. + * + * Only unique login/origin combinations should be used at this test. + */ +function getLoginFromUsername(username) { + return loginList().find(login => login.username == username); +} + +/** + * List of logins used for the test. + * + * We should only use unique usernames in this test, + * because we need to search logins by username. There is one duplicate u+p combo + * in order to test de-duping in the menu. + */ +function loginList() { + return [ + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username", + password: "password", + }), + // Same as above but HTTP in order to test de-duping. + LoginTestUtils.testData.formLogin({ + origin: "http://example.com", + formActionOrigin: "http://example.com", + username: "username", + password: "password", + }), + LoginTestUtils.testData.formLogin({ + origin: "http://example.com", + formActionOrigin: "http://example.com", + username: "username1", + password: "password1", + }), + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: "username2", + password: "password2", + }), + LoginTestUtils.testData.formLogin({ + origin: "http://example.org", + formActionOrigin: "http://example.org", + username: "username-cross-origin", + password: "password-cross-origin", + }), + ]; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_crossOriginSubmissionUsesCorrectOrigin.js b/toolkit/components/passwordmgr/test/browser/browser_crossOriginSubmissionUsesCorrectOrigin.js new file mode 100644 index 0000000000..0aecba63a6 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_crossOriginSubmissionUsesCorrectOrigin.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getDataFromNextSubmitMessage() { + return new Promise(resolve => { + LoginManagerParent.setListenerForTests((msg, data) => { + if (msg == "ShowDoorhanger") { + resolve(data); + } + }); + }); +} + +add_task(async function testCrossOriginFormUsesCorrectOrigin() { + let dataPromise = getDataFromNextSubmitMessage(); + + let url = + "https://example.com" + + DIRECTORY_PATH + + "form_cross_origin_secure_action.html"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + await SpecialPowers.spawn(browser.browsingContext, [], () => { + let doc = content.document; + doc.getElementById("form-basic-username").setUserInput("username"); + doc.getElementById("form-basic-password").setUserInput("password"); + doc.getElementById("form-basic").submit(); + info("Submitting form"); + }); + } + ); + + let data = await dataPromise; + info("Origin retrieved from message listener"); + + Assert.equal( + data.origin, + "https://example.com", + "Message origin should match form origin" + ); + isnot( + data.origin, + data.data.actionOrigin, + "If origin and actionOrigin match, this test will false positive" + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_deleteLoginsBackup.js b/toolkit/components/passwordmgr/test/browser/browser_deleteLoginsBackup.js new file mode 100644 index 0000000000..608329f482 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_deleteLoginsBackup.js @@ -0,0 +1,282 @@ +/** + * Test that logins backup is deleted as expected when logins are deleted. + */ + +XPCOMUtils.defineLazyModuleGetters(this, { + FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.js", + FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.js", +}); + +const nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); + +const login1 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +const login2 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "", + "notifyp1", + "", + "pass" +); + +const fxaKey = new nsLoginInfo( + FXA_PWDMGR_HOST, + null, + FXA_PWDMGR_REALM, + "foo@bar.com", + "pass2", + "", + "" +); + +const loginStorePath = PathUtils.join(PathUtils.profileDir, "logins.json"); +const loginBackupPath = PathUtils.join( + PathUtils.profileDir, + "logins-backup.json" +); + +async function waitForBackupUpdate() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, topic) { + Services.obs.removeObserver(observer, "logins-backup-updated"); + resolve(); + }, "logins-backup-updated"); + }); +} + +async function loginStoreExists() { + return TestUtils.waitForCondition(() => IOUtils.exists(loginStorePath)); +} + +async function loginBackupExists() { + return TestUtils.waitForCondition(() => IOUtils.exists(loginBackupPath)); +} + +async function loginBackupDeleted() { + return TestUtils.waitForCondition( + async () => !(await IOUtils.exists(loginBackupPath)) + ); +} + +// If a fxa key is stored as a login, test that logins backup is updated to only store +// the fxa key when the last user facing login is deleted. +add_task( + async function test_deleteLoginsBackup_removeAllUserFacingLogins_fxaKey() { + info( + "Testing removeAllUserFacingLogins() case when there is a saved fxa key" + ); + info("Adding two logins: fxa key and one user facing login"); + let storageUpdatePromise = TestUtils.topicObserved( + "password-storage-updated" + ); + await Services.logins.addLoginAsync(login1); + Assert.ok(true, "added login1"); + await loginStoreExists(); + await Services.logins.addLoginAsync(fxaKey); + Assert.ok(true, "added fxaKey"); + await loginBackupExists(); + Assert.ok(true, "logins-backup.json now exists"); + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing all user facing logins"); + Services.logins.removeAllUserFacingLogins(); + await storageUpdatePromise; + info("Writes to storage are complete after removeAllUserFacingLogins call"); + await waitForBackupUpdate(); + Assert.ok( + true, + "logins-backup.json was updated to only store the fxa key, as expected" + ); + + // Clean up. + // Since there is a fxa key left, we need to call removeAllLogins() or removeLogin(fxaKey) + // to remove the fxa key. Otherwise the test will fail in verify mode when trying to add login1 + Services.logins.removeAllLogins(); + await IOUtils.remove(loginStorePath); + } +); + +// Test that logins backup is deleted when Services.logins.removeAllUserFacingLogins() is called. +add_task(async function test_deleteLoginsBackup_removeAllUserFacingLogins() { + // Remove logins.json and logins-backup.json before starting. + info("Testing the removeAllUserFacingLogins() case"); + + await IOUtils.remove(loginStorePath, { ignoreAbsent: true }); + await IOUtils.remove(loginBackupPath, { ignoreAbsent: true }); + + let storageUpdatePromise = TestUtils.topicObserved( + "password-storage-updated" + ); + info("Add a login to create logins.json"); + await Services.logins.addLoginAsync(login1); + await loginStoreExists(); + Assert.ok(true, "logins.json now exists"); + + info("Add a second login to create logins-backup.json"); + await Services.logins.addLoginAsync(login2); + await loginBackupExists(); + info("logins-backup.json now exists"); + + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing all user facing logins"); + Services.logins.removeAllUserFacingLogins(); + + await storageUpdatePromise; + info( + "Writes to storage are complete when removeAllUserFacingLogins() is called" + ); + await loginBackupDeleted(); + info( + "logins-backup.json was deleted as expected when all logins were removed" + ); + + // Clean up. + await IOUtils.remove(loginStorePath); +}); + +// 1. Test that logins backup is deleted when Services.logins.removeAllLogins() is called +// 2. If a FxA key is stored as a login, test that logins backup is deleted when +// Services.logins.removeAllLogins() is called +add_task(async function test_deleteLoginsBackup_removeAllLogins() { + // Remove logins.json and logins-backup.json before starting. + info("Testing the removeAllLogins() case"); + + await IOUtils.remove(loginStorePath, { ignoreAbsent: true }); + await IOUtils.remove(loginBackupPath, { ignoreAbsent: true }); + + let storageUpdatePromise = TestUtils.topicObserved( + "password-storage-updated" + ); + info("Add a login to create logins.json"); + await Services.logins.addLoginAsync(login1); + Assert.ok(true, "added login1"); + await loginStoreExists(); + Assert.ok(true, "logins.json now exists"); + await Services.logins.addLoginAsync(login2); + Assert.ok(true, "added login2"); + await loginBackupExists(); + info("logins-backup.json now exists"); + + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing all logins"); + Services.logins.removeAllLogins(); + + await storageUpdatePromise; + info("Writes to storage are complete when removeAllLogins() is called"); + + await loginBackupDeleted(); + info( + "logins-backup.json was deleted as expected when all logins were removed" + ); + await IOUtils.remove(loginStorePath); + + info("Testing the removeAllLogins() case when FxA key is present"); + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + await Services.logins.addLoginAsync(login1); + await loginStoreExists(); + await Services.logins.addLoginAsync(fxaKey); + await loginBackupExists(); + info("logins-backup.json now exists"); + await storageUpdatePromise; + info("Write to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing all logins, including FxA key"); + Services.logins.removeAllLogins(); + await storageUpdatePromise; + info("Writes to storage are complete after the last removeAllLogins call"); + await loginBackupDeleted(); + info( + "logins-backup.json was deleted when the last logins were removed, as expected" + ); + + // Clean up. + await IOUtils.remove(loginStorePath); +}); + +// 1. Test that logins backup is deleted when the last saved login is removed using +// Services.logins.removeLogin() when no fxa key is saved. +// 2. Test that logins backup is updated when the last saved login is removed using +// Services.logins.removeLogin() when a fxa key is present. +add_task(async function test_deleteLoginsBackup_removeLogin() { + info("Testing the removeLogin() case when there is no saved fxa key"); + info("Adding two logins"); + let storageUpdatePromise = TestUtils.topicObserved( + "password-storage-updated" + ); + await Services.logins.addLoginAsync(login1); + await loginStoreExists(); + await Services.logins.addLoginAsync(login2); + await loginBackupExists(); + info("logins-backup.json now exists"); + + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing one login"); + Services.logins.removeLogin(login1); + await storageUpdatePromise; + info("Writes to storage are complete after one removeLogin call"); + await loginBackupExists(); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + info("Removing the last login"); + Services.logins.removeLogin(login2); + await storageUpdatePromise; + info("Writes to storage are complete after the last removeLogin call"); + await loginBackupDeleted(); + info( + "logins-backup.json was deleted as expected when the last saved login was removed" + ); + await IOUtils.remove(loginStorePath); + + info("Testing the removeLogin() case when there is a saved fxa key"); + info("Adding two logins: one user facing and the fxa key"); + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + await Services.logins.addLoginAsync(login1); + await loginStoreExists(); + await Services.logins.addLoginAsync(fxaKey); + await loginBackupExists(); + info("logins-backup.json now exists"); + + await storageUpdatePromise; + info("Writes to storage are complete for addLogin calls"); + + storageUpdatePromise = TestUtils.topicObserved("password-storage-updated"); + let backupUpdate = waitForBackupUpdate(); + Services.logins.removeLogin(login1); + await storageUpdatePromise; + info("Writes to storage are complete after one removeLogin call"); + await backupUpdate; + + await loginBackupExists(); + info("logins-backup.json was updated to contain only the fxa key"); + + // Clean up. + // Since there is a fxa key left, we need to call removeAllLogins() or removeLogin(fxaKey) + // to remove the fxa key. Otherwise the test will fail in verify mode when trying to add login1 + Services.logins.removeAllLogins(); + await IOUtils.remove(loginStorePath); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autocomplete_values.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autocomplete_values.js new file mode 100644 index 0000000000..3f8bfddaf4 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autocomplete_values.js @@ -0,0 +1,274 @@ +/** + * Modify page elements and verify that they are found as options in the save/update doorhanger. + */ + +const USERNAME_SELECTOR = "#form-expanded-username"; +const PASSWORD_SELECTOR = "#form-expanded-password"; +const SEARCH_SELECTOR = "#form-expanded-search"; +const CAPTCHA_SELECTOR = "#form-expanded-captcha"; +const NON_FORM_SELECTOR = "#form-expanded-non-form-input"; + +const AUTOCOMPLETE_POPUP_SELECTOR = "#PopupAutoComplete"; +const USERNAME_DROPMARKER_SELECTOR = + "#password-notification-username-dropmarker"; + +const TEST_CASES = [ + { + description: "a modified username should be included in the popup", + modifiedFields: [ + { [USERNAME_SELECTOR]: "new_username" }, + { [PASSWORD_SELECTOR]: "myPassword" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username"], + }, + { + description: + "if no non-password fields are modified, no popup should be available", + modifiedFields: [{ [PASSWORD_SELECTOR]: "myPassword" }], + expectUsernameDropmarker: false, + expectedValues: [], + }, + { + description: "all modified username fields should be included in the popup", + modifiedFields: [ + { [USERNAME_SELECTOR]: "new_username" }, + { [SEARCH_SELECTOR]: "unrelated search query" }, + { [CAPTCHA_SELECTOR]: "someCaptcha" }, + { [PASSWORD_SELECTOR]: "myPassword" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username", "unrelated search query", "someCaptcha"], + }, + { + description: + "any modified fields that don't look like usernames or passwords should not be included in the popup", + modifiedFields: [ + { [PASSWORD_SELECTOR]: "myPassword" }, + { [NON_FORM_SELECTOR]: "I dont even know what this one is" }, + ], + expectUsernameDropmarker: false, + expectedValues: [], + }, + { + description: + "when a field is modified multiple times, all CHANGE event values should be included in the popup", + modifiedFields: [ + { [USERNAME_SELECTOR]: "new_username1" }, + { [USERNAME_SELECTOR]: "new_username2" }, + { [USERNAME_SELECTOR]: "new_username3" }, + { [PASSWORD_SELECTOR]: "myPassword" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username1", "new_username2", "new_username3"], + }, + { + description: "empty strings should not be displayed in popup", + modifiedFields: [ + { [PASSWORD_SELECTOR]: "myPassword" }, + { [USERNAME_SELECTOR]: "new_username" }, + { [USERNAME_SELECTOR]: "" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username"], + }, + { + description: "saved logins should be displayed in popup", + modifiedFields: [ + { [USERNAME_SELECTOR]: "new_username" }, + { [PASSWORD_SELECTOR]: "myPassword" }, + ], + savedLogins: [ + { + username: "savedUn1", + password: "somePass", + }, + { + username: "savedUn2", + password: "otherPass", + }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username", "savedUn1", "savedUn2"], + }, + { + description: "duplicated page usernames should only be displayed once", + modifiedFields: [ + { [PASSWORD_SELECTOR]: "myPassword" }, + { [USERNAME_SELECTOR]: "new_username1" }, + { [USERNAME_SELECTOR]: "new_username2" }, + { [USERNAME_SELECTOR]: "new_username1" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username1", "new_username2"], + }, + { + description: "non-un/pw fields also prompt doorhanger updates", + modifiedFields: [ + { [PASSWORD_SELECTOR]: "myPassword" }, + { [USERNAME_SELECTOR]: "new_username1" }, + { [SEARCH_SELECTOR]: "search" }, + { [CAPTCHA_SELECTOR]: "captcha" }, + ], + expectUsernameDropmarker: true, + expectedValues: ["new_username1", "search", "captcha"], + }, + // { + // description: "duplicated saved/page usernames should TODO https://mozilla.invisionapp.com/share/XGXL6WZVKFJ#/screens/420547613/comments", + // }, +]; + +function _validateTestCase(tc) { + if (tc.expectUsernameDropmarker) { + Assert.ok( + !!tc.expectedValues.length, + "Validate test case. A visible dropmarker implies expected values" + ); + } else { + Assert.ok( + !tc.expectedValues.length, + "Validate test case. A hidden dropmarker implies no expected values" + ); + } +} + +async function _setPrefs() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.capture.inputChanges.enabled", true]], + }); +} + +async function _addSavedLogins(logins) { + let loginsData = logins.map(({ username }) => + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username, + password: "Saved login passwords not used in this test", + }) + ); + await Services.logins.addLogins(loginsData); +} + +async function _clickDropmarker(document, notificationElement) { + let acPopup = document.querySelector(AUTOCOMPLETE_POPUP_SELECTOR); + let acPopupShown = BrowserTestUtils.waitForEvent(acPopup, "popupshown"); + + notificationElement.querySelector(USERNAME_DROPMARKER_SELECTOR).click(); + await acPopupShown; +} + +function _getSuggestedValues(document) { + let suggestedValues = []; + let autocompletePopup = document.querySelector(AUTOCOMPLETE_POPUP_SELECTOR); + let numRows = autocompletePopup.view.matchCount; + for (let i = 0; i < numRows; i++) { + suggestedValues.push(autocompletePopup.view.getValueAt(i)); + } + return suggestedValues; +} + +add_task(async function test_edit_password() { + await _setPrefs(); + for (let testCase of TEST_CASES) { + info("Test case: " + JSON.stringify(testCase)); + _validateTestCase(testCase); + + // Clean state before the test case is executed. + await LoginTestUtils.clearData(); + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + Services.logins.removeAllUserFacingLogins(); + + // Create the pre-existing logins when needed. + if (testCase.savedLogins) { + info("Adding logins " + JSON.stringify(testCase.savedLogins)); + await _addSavedLogins(testCase.savedLogins); + } + + info("Opening tab"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_expanded.html", + }, + async function (browser) { + info("Editing the form"); + for (const change of testCase.modifiedFields) { + for (const selector in change) { + let newValue = change[selector]; + info(`Setting field '${selector}' to '${newValue}'`); + await changeContentFormValues(browser, change); + } + } + + let notif = getCaptureDoorhanger("any"); + + let { panel } = PopupNotifications; + + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + + await promiseShown; + + let notificationElement = panel.childNodes[0]; + + let usernameDropmarker = notificationElement.querySelector( + USERNAME_DROPMARKER_SELECTOR + ); + Assert.ok( + BrowserTestUtils.is_visible(usernameDropmarker) == + testCase.expectUsernameDropmarker, + "Confirm dropmarker visibility" + ); + + if (testCase.expectUsernameDropmarker) { + info("Opening autocomplete popup"); + await _clickDropmarker(document, notificationElement); + } + + let suggestedValues = _getSuggestedValues(document); + + let expectedNotFound = testCase.expectedValues.filter( + expected => !suggestedValues.includes(expected) + ); + let foundNotExpected = suggestedValues.filter( + actual => !testCase.expectedValues.includes(actual) + ); + + // Log expected/actual inconsistencies + Assert.ok( + !expectedNotFound.length, + `All expected values should be found\nCase: "${ + testCase.description + }"\nExpected: ${JSON.stringify( + testCase.expectedValues + )}\nActual: ${JSON.stringify( + suggestedValues + )}\nExpected not found: ${JSON.stringify(expectedNotFound)} + ` + ); + Assert.ok( + !foundNotExpected.length, + `All actual values should be expected\nCase: "${ + testCase.description + }"\nExpected: ${JSON.stringify( + testCase.expectedValues + )}\nActual: ${JSON.stringify( + suggestedValues + )}\nFound not expected: ${JSON.stringify(foundNotExpected)} + ` + ); + + // Clean up state + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + await clearMessageCache(browser); + Services.logins.removeAllUserFacingLogins(); + } + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autofill_then_save_password.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autofill_then_save_password.js new file mode 100644 index 0000000000..1e62dacc13 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_autofill_then_save_password.js @@ -0,0 +1,181 @@ +/** + * Test that after we autofill, the site makes changes to the login, and then the + * user modifies their login, a save/update doorhanger is shown. + * + * This is a regression test for Bug 1632405. + */ + +const testCases = [ + { + name: "autofill, then delete u/p, then fill new u/p should show 'save'", + oldUsername: "oldUsername", + oldPassword: "oldPassword", + actions: [ + { + setUsername: "newUsername", + }, + { + setPassword: "newPassword", + }, + ], + expectedNotification: "addLogin", + expectedDoorhanger: "password-save", + }, + { + name: "autofill, then delete password, then fill new password should show 'update'", + oldUsername: "oldUsername", + oldPassword: "oldPassword", + actions: [ + { + setPassword: "newPassword", + }, + ], + expectedNotification: "modifyLogin", + expectedDoorhanger: "password-change", + }, +]; + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + info("testing with: " + JSON.stringify(testData)); + await test_save_change(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function test_save_change({ + name, + oldUsername, + oldPassword, + actions, + expectedNotification, + expectedDoorhanger, +}) { + let originalPrefValue = await Services.prefs.getBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue" + ); + Services.prefs.setBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue", + false + ); + + info("Starting test: " + name); + + await LoginTestUtils.addLogin({ + username: oldUsername, + password: oldPassword, + origin: "https://example.com", + formActionOrigin: "https://example.com", + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await ContentTask.spawn( + browser, + { oldUsername, oldPassword }, + async function awaitAutofill({ oldUsername, oldPassword }) { + await ContentTaskUtils.waitForCondition( + () => + content.document.querySelector("#form-basic-username").value == + oldUsername && + content.document.querySelector("#form-basic-password").value == + oldPassword, + "Await and verify autofill" + ); + + info( + "Triggering a page navigation that is not initiated by the user" + ); + content.history.replaceState({}, "", ""); + } + ); + + Services.prefs.setBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue", + true + ); + + for (let action of actions) { + info(`As the user, update form with action: ${JSON.stringify(action)}`); + if (typeof action.setUsername !== "undefined") { + await changeContentFormValues(browser, { + "#form-basic-username": action.setUsername, + }); + } + if (typeof action.setPassword !== "undefined") { + await changeContentFormValues(browser, { + "#form-basic-password": action.setPassword, + }); + } + } + + let expectedUsername = + [...actions] + .reverse() + .map(action => action.setUsername) + .find(username => !!username) ?? oldUsername; + let expectedPassword = + [...actions] + .reverse() + .map(action => action.setPassword) + .find(username => !!username) ?? oldPassword; + + await ContentTask.spawn( + browser, + { expectedUsername, expectedPassword }, + async function awaitAutofill({ expectedUsername, expectedPassword }) { + info("Validating updated fields"); + Assert.equal( + expectedUsername, + content.document.querySelector("#form-basic-username").value, + "Verify username field updated" + ); + Assert.equal( + expectedPassword, + content.document.querySelector("#form-basic-password").value, + "Verify password field updated" + ); + } + ); + + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + await formSubmittedPromise; + + info("Waiting for doorhanger of type: " + expectedDoorhanger); + let notif = await waitForDoorhanger(browser, expectedDoorhanger); + + await checkDoorhangerUsernamePassword(expectedUsername, expectedPassword); + + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseLogin; + await cleanupDoorhanger(notif); // clean slate for the next test + + Services.prefs.setBoolPref( + "signon.testOnlyUserHasInteractedByPrefValue", + originalPrefValue + ); + } + ); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllUserFacingLogins(); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_crossframe.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_crossframe.js new file mode 100644 index 0000000000..8c4770d510 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_crossframe.js @@ -0,0 +1,236 @@ +const OUTER_URL = + "https://test1.example.com:443" + DIRECTORY_PATH + "form_crossframe.html"; + +requestLongerTimeout(2); + +async function acceptPasswordSave() { + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + let promiseNewSavedPassword = TestUtils.topicObserved( + "LoginStats:NewSavedPassword", + (subject, data) => subject == gBrowser.selectedBrowser + ); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseNewSavedPassword; +} + +function checkFormFields(browsingContext, prefix, username, password) { + return SpecialPowers.spawn( + browsingContext, + [prefix, username, password], + (formPrefix, expectedUsername, expectedPassword) => { + let doc = content.document; + Assert.equal( + doc.getElementById(formPrefix + "-username").value, + expectedUsername, + "username matches" + ); + Assert.equal( + doc.getElementById(formPrefix + "-password").value, + expectedPassword, + "password matches" + ); + } + ); +} + +function listenForNotifications(count, expectedFormOrigin) { + return new Promise(resolve => { + let notifications = []; + LoginManagerParent.setListenerForTests((msg, data) => { + if (msg == "FormProcessed") { + notifications.push("FormProcessed: " + data.browsingContext.id); + } else if (msg == "ShowDoorhanger") { + Assert.equal( + data.origin, + expectedFormOrigin, + "Message origin should match expected" + ); + notifications.push("FormSubmit: " + data.data.usernameField.name); + } + if (notifications.length == count) { + resolve(notifications); + } + }); + }); +} + +async function verifyNotifications(notifyPromise, expected) { + let actual = await notifyPromise; + + Assert.equal(actual.length, expected.length, "Extra notification(s) sent"); + let expectedItem; + while ((expectedItem = expected.pop())) { + let index = actual.indexOf(expectedItem); + if (index >= 0) { + actual.splice(index, 1); + } else { + Assert.ok(false, "Expected notification '" + expectedItem + "' not sent"); + } + } +} + +// Make sure there is an autocomplete result for the frame's saved login and select it. +async function autocompleteLoginInIFrame( + browser, + iframeBrowsingContext, + selector +) { + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + + await openACPopup(popup, browser, selector, iframeBrowsingContext); + + let autocompleteLoginResult = popup.querySelector( + `[originaltype="loginWithOrigin"]` + ); + Assert.ok(autocompleteLoginResult, "Got login richlistitem"); + + let promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await EventUtils.synthesizeKey("KEY_Enter"); + + await promiseHidden; +} + +/* + * In this test, a frame is loaded with a document that contains a username + * and password field. This frame also contains another child iframe that + * itself contains a username and password field. This inner frame is loaded + * from a different domain than the first. + * + * locationMode should be false to submit forms, or true to click a button + * which changes the location instead. The latter should still save the + * username and password. + */ +async function submitSomeCrossSiteFrames(locationMode) { + info("Check with location mode " + locationMode); + let notifyPromise = listenForNotifications(2); + + let firsttab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + OUTER_URL + ); + + let outerFrameBC = firsttab.linkedBrowser.browsingContext; + let innerFrameBC = outerFrameBC.children[0]; + + await verifyNotifications(notifyPromise, [ + "FormProcessed: " + outerFrameBC.id, + "FormProcessed: " + innerFrameBC.id, + ]); + + // Fill in the username and password for both the outer and inner frame + // and submit the inner frame. + notifyPromise = listenForNotifications(1, "https://test2.example.org"); + info("submit page after changing inner form"); + + await SpecialPowers.spawn(outerFrameBC, [], () => { + let doc = content.document; + doc.getElementById("outer-username").setUserInput("outer"); + doc.getElementById("outer-password").setUserInput("outerpass"); + }); + + await SpecialPowers.spawn(innerFrameBC, [locationMode], doClick => { + let doc = content.document; + doc.getElementById("inner-username").setUserInput("inner"); + doc.getElementById("inner-password").setUserInput("innerpass"); + if (doClick) { + doc.getElementById("inner-gobutton").click(); + } else { + doc.getElementById("inner-form").submit(); + } + }); + + await acceptPasswordSave(); + + await verifyNotifications(notifyPromise, ["FormSubmit: username"]); + + // Next, open a second tab with the same page in it to verify that the data gets filled properly. + notifyPromise = listenForNotifications(2); + let secondtab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + OUTER_URL + ); + + let outerFrameBC2 = secondtab.linkedBrowser.browsingContext; + let innerFrameBC2 = outerFrameBC2.children[0]; + await verifyNotifications(notifyPromise, [ + "FormProcessed: " + outerFrameBC2.id, + "FormProcessed: " + innerFrameBC2.id, + ]); + + // We don't expect the innerFrame to be autofilled with the saved login, since + // it is cross-origin with the top level frame, so we autocomplete instead. + info("Autocompleting saved login into inner form"); + await autocompleteLoginInIFrame( + secondtab.linkedBrowser, + innerFrameBC2, + "#inner-username" + ); + + await checkFormFields(outerFrameBC2, "outer", "", ""); + await checkFormFields(innerFrameBC2, "inner", "inner", "innerpass"); + + // Next, change the username and password fields in the outer frame and submit. + notifyPromise = listenForNotifications(1, "https://test1.example.com"); + info("submit page after changing outer form"); + + await SpecialPowers.spawn(outerFrameBC2, [locationMode], doClick => { + let doc = content.document; + doc.getElementById("outer-username").setUserInput("outer2"); + doc.getElementById("outer-password").setUserInput("outerpass2"); + if (doClick) { + doc.getElementById("outer-gobutton").click(); + } else { + doc.getElementById("outer-form").submit(); + } + + doc.getElementById("outer-form").submit(); + }); + + await acceptPasswordSave(); + await verifyNotifications(notifyPromise, ["FormSubmit: outer-username"]); + + // Finally, open a third tab with the same page in it to verify that the data gets filled properly. + notifyPromise = listenForNotifications(2); + let thirdtab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + OUTER_URL + ); + + let outerFrameBC3 = thirdtab.linkedBrowser.browsingContext; + let innerFrameBC3 = outerFrameBC3.children[0]; + await verifyNotifications(notifyPromise, [ + "FormProcessed: " + outerFrameBC3.id, + "FormProcessed: " + innerFrameBC3.id, + ]); + + // We don't expect the innerFrame to be autofilled with the saved login, since + // it is cross-origin with the top level frame, so we autocomplete instead. + info("Autocompleting saved login into inner form"); + await autocompleteLoginInIFrame( + thirdtab.linkedBrowser, + innerFrameBC3, + "#inner-username" + ); + + await checkFormFields(outerFrameBC3, "outer", "outer2", "outerpass2"); + await checkFormFields(innerFrameBC3, "inner", "inner", "innerpass"); + + LoginManagerParent.setListenerForTests(null); + + await BrowserTestUtils.removeTab(firsttab); + await BrowserTestUtils.removeTab(secondtab); + await BrowserTestUtils.removeTab(thirdtab); + + LoginTestUtils.clearData(); +} + +add_task(async function cross_site_frames_submit() { + await submitSomeCrossSiteFrames(false); +}); + +add_task(async function cross_site_frames_changelocation() { + await submitSomeCrossSiteFrames(true); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_dismissed_for_ccnumber.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_dismissed_for_ccnumber.js new file mode 100644 index 0000000000..eeaa211da8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_dismissed_for_ccnumber.js @@ -0,0 +1,202 @@ +"use strict"; + +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; + +add_task(async function test_doorhanger_dismissal_un() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function test_un_value_as_ccnumber(browser) { + // If the username field has a credit card number and if + // the password field is a three digit numberic value, + // we automatically dismiss the save logins prompt on submission. + + let passwordFilledPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + await changeContentFormValues(browser, { + "#form-basic-password": "123", + // We are interested in the state of the doorhanger created and don't want a + // false positive from the password-edited handling + "#form-basic-username": "4111111111111111", + }); + info("Waiting for passwordFilledPromise"); + await passwordFilledPromise; + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache so we can disambiguate between dismissed doorhanger from + // password edited vs form submitted w. cc number as username + await clearMessageCache(browser); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async () => { + content.document.getElementById("form-basic-submit").click(); + }); + info("Waiting for FormSubmit"); + await processedPromise; + + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok( + notif.dismissed, + "notification popup was automatically dismissed" + ); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_doorhanger_dismissal_pw() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function test_pw_value_as_ccnumber(browser) { + // If the password field has a credit card number and if + // the password field is also tagged autocomplete="cc-number", + // we automatically dismiss the save logins prompt on submission. + + let passwordFilledPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + await changeContentFormValues(browser, { + "#form-basic-password": "4111111111111111", + "#form-basic-username": "aaa", + }); + await SpecialPowers.spawn(browser, [], async () => { + content.document + .getElementById("form-basic-password") + .setAttribute("autocomplete", "cc-number"); + }); + await passwordFilledPromise; + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache so we can disambiguate between dismissed doorhanger from + // password edited vs form submitted w. cc number as password + await clearMessageCache(browser); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async () => { + content.document.getElementById("form-basic-submit").click(); + }); + await processedPromise; + + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok( + notif.dismissed, + "notification popup was automatically dismissed" + ); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_doorhanger_shown_on_un_with_invalid_ccnumber() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function test_un_with_invalid_cc_number(browser) { + // If the username field has a CC number that is invalid, + // we show the doorhanger to save logins like we usually do. + + let passwordFilledPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + await changeContentFormValues(browser, { + "#form-basic-password": "411", + "#form-basic-username": "1234123412341234", + }); + + await passwordFilledPromise; + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache so we can disambiguate between dismissed doorhanger from + // password edited vs form submitted w. cc number as password + await clearMessageCache(browser); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async () => { + content.document.getElementById("form-basic-submit").click(); + }); + await processedPromise; + + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok( + !notif.dismissed, + "notification popup was not automatically dismissed" + ); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_doorhanger_dismissal_on_change() { + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function test_change_in_pw(browser) { + let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + let login = new nsLoginInfo( + TEST_ORIGIN, + TEST_ORIGIN, + null, + "4111111111111111", + "111", // password looks like a card security code + "form-basic-username", + "form-basic-password" + ); + await Services.logins.addLoginAsync(login); + + let passwordFilledPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + + await changeContentFormValues(browser, { + "#form-basic-password": "222", // password looks like a card security code + "#form-basic-username": "4111111111111111", + }); + await passwordFilledPromise; + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache so we can disambiguate between dismissed doorhanger from + // password edited vs form submitted w. cc number as username + await clearMessageCache(browser); + + let processedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async () => { + content.document.getElementById("form-basic-submit").click(); + }); + await processedPromise; + + let notif = getCaptureDoorhanger("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok( + notif.dismissed, + "notification popup was automatically dismissed" + ); + await cleanupDoorhanger(notif); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_empty_password.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_empty_password.js new file mode 100644 index 0000000000..15c3d52263 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_empty_password.js @@ -0,0 +1,42 @@ +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons.visibilityToggle", true]], + }); +}); + +/** + * Test that the doorhanger main action button is disabled + * when the password field is empty. + * + * Also checks that submiting an empty password throws an error. + */ +add_task(async function test_empty_password() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com/browser/toolkit/components/passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + await SpecialPowers.spawn(browser, [], async function () { + let doc = content.document; + doc.getElementById("form-basic-username").setUserInput("username"); + doc.getElementById("form-basic-password").setUserInput("pw"); + doc.getElementById("form-basic").submit(); + }); + + await waitForDoorhanger(browser, "password-save"); + // Synthesize input to empty the field + await updateDoorhangerInputValues({ + password: "", + }); + + let notificationElement = PopupNotifications.panel.childNodes[0]; + let mainActionButton = notificationElement.button; + + Assert.ok(mainActionButton.disabled, "Main action button is disabled"); + await hideDoorhangerPopup(); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_form_password_edit.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_form_password_edit.js new file mode 100644 index 0000000000..75690a25f2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_form_password_edit.js @@ -0,0 +1,562 @@ +/** + * Test changed (not submitted) passwords produce the right doorhangers/notifications + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; +const passwordInputSelector = "#form-basic-password"; +const usernameInputSelector = "#form-basic-username"; + +let testCases = [ + { + name: "Enter password", + prefEnabled: true, + isLoggedIn: true, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "abcXYZ", + }, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "abcXYZ", + toggle: "visible", + }, + }, + }, + { + name: "Change password", + prefEnabled: true, + isLoggedIn: true, + logins: [], + formDefaults: { + [passwordInputSelector]: "pass1", + }, + formChanges: { + [passwordInputSelector]: "pass-changed", + }, + expected: { + initialForm: { + username: "", + password: "pass1", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "pass-changed", + toggle: "visible", + }, + }, + }, + { + name: "Change autofilled password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user1", password: "autopass" }], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "autopass-changed", + }, + expected: { + initialForm: { + username: "user1", + password: "autopass", + }, + doorhanger: { + type: "password-change", + dismissed: true, + anchorExtraAttr: "", + username: "user1", + password: "autopass-changed", + }, + }, + }, + { + name: "Change autofilled username and password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user1", password: "pass1" }], + formDefaults: {}, + formChanges: { + [usernameInputSelector]: "user2", + [passwordInputSelector]: "pass2", + }, + expected: { + initialForm: { + username: "user1", + password: "pass1", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "user2", + password: "pass2", + toggle: "visible", + }, + }, + }, + { + name: "Change password pref disabled", + prefEnabled: false, + isLoggedIn: true, + logins: [], + formDefaults: { + [passwordInputSelector]: "pass1", + }, + formChanges: { + [passwordInputSelector]: "pass-changed", + }, + expected: { + initialForm: { + username: "", + password: "pass1", + }, + doorhanger: null, + }, + }, + { + name: "Change to new username", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user1", password: "pass1" }], + formDefaults: {}, + formChanges: { + [usernameInputSelector]: "user2", + }, + expected: { + initialForm: { + username: "user1", + password: "pass1", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "user2", + password: "pass1", + toggle: "visible", + }, + }, + }, + { + name: "Change to existing username, different password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user-saved", password: "pass1" }], + formDefaults: { + [usernameInputSelector]: "user-prefilled", + [passwordInputSelector]: "pass2", + }, + formChanges: { + [usernameInputSelector]: "user-saved", + }, + expected: { + initialForm: { + username: "user-prefilled", + password: "pass2", + }, + doorhanger: { + type: "password-change", + dismissed: true, + anchorExtraAttr: "", + username: "user-saved", + password: "pass2", + toggle: "visible", + }, + }, + }, + { + name: "Add username to existing password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "", password: "pass1" }], + formDefaults: {}, + formChanges: { + [usernameInputSelector]: "user1", + }, + expected: { + initialForm: { + username: "", + password: "pass1", + }, + doorhanger: { + type: "password-change", + dismissed: true, + anchorExtraAttr: "", + username: "user1", + password: "pass1", + toggle: "visible", + }, + }, + }, + { + name: "Change to existing username, password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "user1", password: "pass1" }], + formDefaults: { + [usernameInputSelector]: "user", + [passwordInputSelector]: "pass", + }, + formChanges: { + [passwordInputSelector]: "pass1", + [usernameInputSelector]: "user1", + }, + expected: { + initialForm: { + username: "user", + password: "pass", + }, + doorhanger: null, + }, + }, + { + name: "Ensure a dismissed password-save doorhanger appears on an input event when editing an unsaved password", + prefEnabled: true, + isLoggedIn: true, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "a", + }, + shouldBlur: false, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "a", + toggle: "visible", + }, + }, + }, + { + name: "Ensure a dismissed password-save doorhanger appears with the latest input value upon editing an unsaved password", + prefEnabled: true, + isLoggedIn: true, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "a", + [passwordInputSelector]: "ab", + [passwordInputSelector]: "abc", + }, + shouldBlur: false, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: { + type: "password-save", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "abc", + toggle: "visible", + }, + }, + }, + { + name: "Ensure a dismissed password-change doorhanger appears on an input event when editing a saved password", + prefEnabled: true, + isLoggedIn: true, + logins: [{ username: "", password: "pass1" }], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pass", + }, + shouldBlur: false, + expected: { + initialForm: { + username: "", + password: "pass1", + }, + doorhanger: { + type: "password-change", + dismissed: true, + anchorExtraAttr: "", + username: "", + password: "pass", + toggle: "visible", + }, + }, + }, + { + name: "Ensure no dismissed doorhanger is shown on 'input' when Primary Password is locked", + prefEnabled: true, + isLoggedIn: false, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pass", + }, + shouldBlur: false, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: null, + }, + }, + { + name: "Ensure no dismissed doorhanger is shown on 'change' when Primary Password is locked", + prefEnabled: true, + isLoggedIn: false, + logins: [], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pass", + }, + shouldBlur: true, + expected: { + initialForm: { + username: "", + password: "", + }, + doorhanger: null, + }, + }, +]; + +requestLongerTimeout(2); +SimpleTest.requestCompleteLog(); + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.passwordEditCapture.enabled", testData.prefEnabled]], + }); + if (!testData.isLoggedIn) { + // Enable Primary Password + LoginTestUtils.primaryPassword.enable(); + } + for (let passwordFieldType of ["password", "text"]) { + info( + "testing with type=" + + passwordFieldType + + ": " + + JSON.stringify(testData) + ); + await testPasswordChange(testData, { passwordFieldType }); + } + if (!testData.isLoggedIn) { + LoginTestUtils.primaryPassword.disable(); + } + await SpecialPowers.popPrefEnv(); + }, + }; + add_task(tmp[testData.name]); +} + +async function testPasswordChange( + { + logins = [], + formDefaults = {}, + formChanges = {}, + expected, + isLoggedIn, + shouldBlur = true, + }, + { passwordFieldType } +) { + await LoginTestUtils.clearData(); + await cleanupDoorhanger(); + + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + for (let login of logins) { + await LoginTestUtils.addLogin(login); + } + + for (let login of Services.logins.getAllLogins()) { + info(`Saved login: ${login.username}, ${login.password}, ${login.origin}`); + } + + let formProcessedPromise = listenForTestNotification("FormProcessed"); + info("Opening tab with url: " + url); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + info(`Opened tab with url: ${url}, waiting for focus`); + await SimpleTest.promiseFocus(browser.ownerGlobal); + info("Waiting for form-processed message"); + await formProcessedPromise; + await initForm(browser, formDefaults, { passwordFieldType }); + await checkForm(browser, expected.initialForm); + info("form checked"); + + // A message is still sent to the parent process when Primary Password is enabled + let notificationMessage = + expected.doorhanger || !isLoggedIn + ? "PasswordEditedOrGenerated" + : "PasswordIgnoreEdit"; + let passwordTestNotification = + listenForTestNotification(notificationMessage); + + await changeContentFormValues(browser, formChanges, shouldBlur); + + info( + `form edited, waiting for test notification of ${notificationMessage}` + ); + + await passwordTestNotification; + info("Resolved passwordTestNotification promise"); + + if (!expected.doorhanger) { + let notif; + try { + await TestUtils.waitForCondition( + () => { + return (notif = PopupNotifications.getNotification( + "password", + browser + )); + }, + `Waiting to ensure no notification`, + undefined, + 25 + ); + } catch (ex) {} + Assert.ok(!notif, "No doorhanger expected"); + // the remainder of the test is for doorhanger-expected cases + return; + } + + let notificationType = expected.doorhanger.type; + Assert.ok( + /^password-save|password-change$/.test(notificationType), + "test provided an expected notification type: " + notificationType + ); + info("waiting for doorhanger"); + await waitForDoorhanger(browser, notificationType); + + info("verifying doorhanger"); + let notif = await openAndVerifyDoorhanger( + browser, + notificationType, + expected.doorhanger + ); + Assert.ok(notif, "Doorhanger was shown"); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + info("cleanup doorhanger"); + await cleanupDoorhanger(notif); + } + ); +} + +async function initForm(browser, formDefaults, passwordFieldType) { + await ContentTask.spawn( + browser, + { passwordInputSelector, passwordFieldType }, + async function ({ passwordInputSelector, passwordFieldType }) { + content.document.querySelector(passwordInputSelector).type = + passwordFieldType; + } + ); + await ContentTask.spawn( + browser, + formDefaults, + async function (selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + content.document.querySelector(sel).value = value; + } + } + ); +} + +async function checkForm(browser, expected) { + await ContentTask.spawn( + browser, + { + [passwordInputSelector]: expected.password, + [usernameInputSelector]: expected.username, + }, + async function contentCheckForm(selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + let field = content.document.querySelector(sel); + Assert.equal( + field.value, + value, + sel + " has the expected initial value" + ); + } + } + ); +} + +async function openAndVerifyDoorhanger(browser, type, expected) { + // check a dismissed prompt was shown with extraAttr attribute + let notif = getCaptureDoorhanger(type); + Assert.ok(notif, `${type} doorhanger was created`); + Assert.equal( + notif.dismissed, + expected.dismissed, + "Check notification dismissed property" + ); + Assert.equal( + notif.anchorElement.getAttribute("extraAttr"), + expected.anchorExtraAttr, + "Check icon extraAttr attribute" + ); + let { panel } = PopupNotifications; + // if the doorhanged is dimissed, we will open it to check panel contents + Assert.equal(panel.state, "closed", "Panel is initially closed"); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + // synthesize click on anchor as this also blurs the form field triggering + // a change event + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + await promiseShown; + await Promise.resolve(); + await checkDoorhangerUsernamePassword(expected.username, expected.password); + + let notificationElement = PopupNotifications.panel.childNodes[0]; + let checkbox = notificationElement.querySelector( + "#password-notification-visibilityToggle" + ); + + if (expected.toggle == "visible") { + // Bug 1692284 + // Assert.ok(BrowserTestUtils.is_visible(checkbox), "Toggle checkbox visible as expected"); + } else if (expected.toggle == "hidden") { + Assert.ok( + BrowserTestUtils.is_hidden(checkbox), + "Toggle checkbox hidden as expected" + ); + } else { + info("Not checking toggle checkbox visibility"); + } + return notif; +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_generated_password.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_generated_password.js new file mode 100644 index 0000000000..bbcab81854 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_generated_password.js @@ -0,0 +1,1845 @@ +/** + * Test using the generated passwords produces the right doorhangers/notifications + */ + +/* eslint no-shadow:"off" */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const FORM_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"; +const passwordInputSelector = "#form-basic-password"; +const usernameInputSelector = "#form-basic-username"; + +requestLongerTimeout(2); + +async function task_setup() { + Services.logins.removeAllUserFacingLogins(); + LoginTestUtils.resetGeneratedPasswordsCache(); + await cleanupPasswordNotifications(); + await LoginTestUtils.remoteSettings.setupImprovedPasswordRules(); +} + +async function setup_withOneLogin(username = "username", password = "pass1") { + // Reset to a single, known login + await task_setup(); + let login = await LoginTestUtils.addLogin({ username, password }); + return login; +} + +async function setup_withNoLogins() { + // Reset to a single, known login + await task_setup(); + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "0 logins at the start of the test" + ); +} + +async function fillGeneratedPasswordFromACPopup( + browser, + passwordInputSelector +) { + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + await openACPopup(popup, browser, passwordInputSelector); + await fillGeneratedPasswordFromOpenACPopup(browser, passwordInputSelector); +} + +async function checkPromptContents( + anchorElement, + browser, + expectedPasswordLength = 0 +) { + let { panel } = PopupNotifications; + Assert.ok(PopupNotifications.isPanelOpen, "Confirm popup is open"); + let notificationElement = panel.childNodes[0]; + if (expectedPasswordLength) { + info( + `Waiting for password value to be ${expectedPasswordLength} chars long` + ); + await BrowserTestUtils.waitForCondition(() => { + return ( + notificationElement.querySelector("#password-notification-password") + .value.length == expectedPasswordLength + ); + }, "Wait for nsLoginManagerPrompter writeDataToUI()"); + } + + return { + passwordValue: notificationElement.querySelector( + "#password-notification-password" + ).value, + usernameValue: notificationElement.querySelector( + "#password-notification-username" + ).value, + }; +} + +async function verifyGeneratedPasswordWasFilled( + browser, + passwordInputSelector +) { + await SpecialPowers.spawn( + browser, + [[passwordInputSelector]], + function checkFinalFieldValue(inputSelector) { + let { LoginTestUtils: LTU } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" + ); + let passwordInput = content.document.querySelector(inputSelector); + Assert.equal( + passwordInput.value.length, + LTU.generation.LENGTH, + "Password field was filled with generated password" + ); + } + ); +} + +async function openFormInNewTab(url, formValues, taskFn) { + let formFilled = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await formFilled; + + await SpecialPowers.spawn( + browser, + [formValues], + async function prepareAndCheckForm({ + password: passwordProps, + username: usernameProps, + }) { + let doc = content.document; + // give the form an action so we can know when submit is complete + doc.querySelector("form").action = "/"; + + let props = passwordProps; + if (props) { + // We'll reuse the form_basic.html, but ensure we'll get the generated password autocomplete option + let field = doc.querySelector(props.selector); + if (props.type) { + // Change the type from 'password' to something else. + field.type = props.type; + } + + field.setAttribute("autocomplete", "new-password"); + if (props.hasOwnProperty("expectedValue")) { + Assert.equal( + field.value, + props.expectedValue, + "Check autofilled password value" + ); + } + } + props = usernameProps; + if (props) { + let field = doc.querySelector(props.selector); + if (props.hasOwnProperty("expectedValue")) { + Assert.equal( + field.value, + props.expectedValue, + "Check autofilled username value" + ); + } + } + } + ); + + if (formValues.password && formValues.password.setValue !== undefined) { + info( + "Editing the password, expectedMessage? " + + formValues.password.expectedMessage + ); + let messagePromise = formValues.password.expectedMessage + ? listenForTestNotification(formValues.password.expectedMessage) + : Promise.resolve(); + await changeContentInputValue( + browser, + formValues.password.selector, + formValues.password.setValue + ); + await messagePromise; + info("messagePromise resolved"); + } + + if (formValues.username && formValues.username.setValue !== undefined) { + info( + "Editing the username, expectedMessage? " + + formValues.username.expectedMessage + ); + let messagePromise = formValues.username.expectedMessage + ? listenForTestNotification(formValues.username.expectedMessage) + : Promise.resolve(); + await changeContentInputValue( + browser, + formValues.username.selector, + formValues.username.setValue + ); + await messagePromise; + info("messagePromise resolved"); + } + + await taskFn(browser); + await closePopup( + browser.ownerDocument.getElementById("confirmation-hint") + ); + } + ); +} + +async function openAndVerifyDoorhanger(browser, type, expected) { + // check a dismissed prompt was shown with extraAttr attribute + let notif = getCaptureDoorhanger(type); + Assert.ok(notif, `${type} doorhanger was created`); + Assert.equal( + notif.dismissed, + expected.dismissed, + "Check notification dismissed property" + ); + Assert.equal( + notif.anchorElement.getAttribute("extraAttr"), + expected.anchorExtraAttr, + "Check icon extraAttr attribute" + ); + let { panel } = PopupNotifications; + // if the doorhanged is dimissed, we will open it to check panel contents + if (panel.state !== "open") { + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + if (panel.state !== "showing") { + // synthesize click on anchor as this also blurs the form field triggering + // a change event + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + } + await promiseShown; + } + let { passwordValue, usernameValue } = await checkPromptContents( + notif.anchorElement, + browser, + expected.passwordLength + ); + Assert.equal( + passwordValue.length, + expected.passwordLength || LoginTestUtils.generation.LENGTH, + "Doorhanger password field has generated 15-char value" + ); + Assert.equal( + usernameValue, + expected.usernameValue, + "Doorhanger username field was popuplated" + ); + return notif; +} + +async function appendContentInputvalue(browser, selector, str) { + await ContentTask.spawn( + browser, + { selector, str }, + async function ({ selector, str }) { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let input = content.document.querySelector(selector); + input.focus(); + input.select(); + await EventUtils.synthesizeKey("KEY_ArrowRight", {}, content); + let changedPromise = ContentTaskUtils.waitForEvent(input, "change"); + if (str) { + await EventUtils.sendString(str, content); + } + input.blur(); + await changedPromise; + } + ); + info("Input value changed"); + await TestUtils.waitForTick(); +} + +async function submitForm(browser) { + // Submit the form + info("Now submit the form"); + let correctPathNamePromise = BrowserTestUtils.browserLoaded(browser); + await SpecialPowers.spawn(browser, [], async function () { + content.document.querySelector("form").submit(); + }); + await correctPathNamePromise; + await SpecialPowers.spawn(browser, [], async () => { + let win = content; + await ContentTaskUtils.waitForCondition(() => { + return ( + win.location.pathname == "/" && win.document.readyState == "complete" + ); + }, "Wait for form submission load"); + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.generation.available", true], + ["signon.generation.enabled", true], + ], + }); + // assert that there are no logins + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins"); +}); + +add_task(async function autocomplete_generated_password_auto_saved() { + // confirm behavior when filling a generated password via autocomplete + // when there are no other logins + await setup_withNoLogins(); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { selector: passwordInputSelector, expectedValue: "" }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // Let the hint hide itself this first time + let forceClosePopup = false; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + let [{ username, password }] = await storageChangedPromise; + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + // Check properties of the newly auto-saved login + Assert.equal(username, "", "Saved login should have no username"); + Assert.equal( + password.length, + LoginTestUtils.generation.LENGTH, + "Saved login should have generated password" + ); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + // confirm the extraAttr attribute is removed after opening & dismissing the doorhanger + Assert.ok( + !notif.anchorElement.hasAttribute("extraAttr"), + "Check if the extraAttr attribute was removed" + ); + await cleanupDoorhanger(notif); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + let [autoSavedLogin] = Services.logins.getAllLogins(); + info("waiting for submitForm"); + await submitForm(browser); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: autoSavedLogin.timesUsed + 1, + username: "", + }, + ]); + } + ); +}); + +add_task( + async function autocomplete_generated_password_with_confirm_field_auto_saved() { + // confirm behavior when filling a generated password via autocomplete + // when there are no other logins and the form has a confirm password field + const FORM_WITH_CONFIRM_FIELD_PAGE_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_basic_with_confirm_field.html"; + const confirmPasswordInputSelector = "#form-basic-confirm-password"; + await setup_withNoLogins(); + await openFormInNewTab( + TEST_ORIGIN + FORM_WITH_CONFIRM_FIELD_PAGE_PATH, + { + password: { selector: passwordInputSelector, expectedValue: "" }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // Let the hint hide itself this first time + let forceClosePopup = false; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + let [{ username, password }] = await storageChangedPromise; + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + await verifyGeneratedPasswordWasFilled( + browser, + confirmPasswordInputSelector + ); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + // Check properties of the newly auto-saved login + Assert.equal(username, "", "Saved login should have no username"); + Assert.equal( + password.length, + LoginTestUtils.generation.LENGTH, + "Saved login should have generated password" + ); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + // confirm the extraAttr attribute is removed after opening & dismissing the doorhanger + Assert.ok( + !notif.anchorElement.hasAttribute("extraAttr"), + "Check if the extraAttr attribute was removed" + ); + await cleanupDoorhanger(notif); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + let [autoSavedLogin] = Services.logins.getAllLogins(); + info("waiting for submitForm"); + await submitForm(browser); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: autoSavedLogin.timesUsed + 1, + username: "", + }, + ]); + } + ); + } +); + +add_task(async function autocomplete_generated_password_saved_empty_username() { + // confirm behavior when filling a generated password via autocomplete + // when there is an existing saved login with a "" username + await setup_withOneLogin("", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let [savedLogin] = Services.logins.getAllLogins(); + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + await waitForDoorhanger(browser, "password-change"); + info("Waiting to openAndVerifyDoorhanger"); + await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + await hideDoorhangerPopup(); + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + info("waiting for submitForm"); + await submitForm(browser); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: savedLogin.timesUsed + 1, + username: "", + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); +}); + +add_task(async function autocomplete_generated_password_saved_username() { + // confirm behavior when filling a generated password via autocomplete + // into a form with username matching an existing saved login + await setup_withOneLogin("user1", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "user1", + }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + // Check properties of the newly auto-saved login + let [user1LoginSnapshot, autoSavedLogin] = verifyLogins([ + { + username: "user1", + password: "xyzpassword", // user1 is unchanged + }, + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "user1", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + // confirm the extraAttr attribute is removed after opening & dismissing the doorhanger + Assert.ok( + !notif.anchorElement.hasAttribute("extraAttr"), + "Check if the extraAttr attribute was removed" + ); + await cleanupDoorhanger(notif); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + info("waiting for submitForm"); + await submitForm(browser); + promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + await storageChangedPromise; + verifyLogins([ + { + timesUsed: user1LoginSnapshot.timesUsed + 1, + username: "user1", + password: autoSavedLogin.password, + }, + ]); + } + ); +}); + +add_task(async function ac_gen_pw_saved_empty_un_stored_non_empty_un_in_form() { + // confirm behavior when when the form's username field has a non-empty value + // and there is an existing saved login with a "" username + await setup_withOneLogin("", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "", + setValue: "myusername", + // with an empty password value, no message is sent for a username change + expectedMessage: "", + }, + }, + async function taskFn(browser) { + let [savedLogin] = Services.logins.getAllLogins(); + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + await waitForDoorhanger(browser, "password-save"); + info("Waiting to openAndVerifyDoorhanger"); + await openAndVerifyDoorhanger(browser, "password-save", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "myusername", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + await hideDoorhangerPopup(); + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + info("waiting for submitForm"); + await submitForm(browser); + let notif = await openAndVerifyDoorhanger(browser, "password-save", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "myusername", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseHidden; + + info("Waiting for addLogin"); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: savedLogin.timesUsed, + username: "", + password: "xyzpassword", + }, + { + timesUsed: 1, + username: "myusername", + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); +}); + +add_task(async function contextfill_generated_password_saved_empty_username() { + // confirm behavior when filling a generated password via context menu + // when there is an existing saved login with a "" username + await setup_withOneLogin("", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let [savedLogin] = Services.logins.getAllLogins(); + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + await waitForDoorhanger(browser, "password-change"); + info("Waiting to openAndVerifyDoorhanger"); + await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + await hideDoorhangerPopup(); + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + info("waiting for submitForm"); + await submitForm(browser); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: savedLogin.timesUsed + 1, + username: "", + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); +}); + +async function autocomplete_generated_password_edited_no_auto_save( + passwordType = "password" +) { + // confirm behavior when filling a generated password via autocomplete + // when there is an existing saved login with a "" username and then editing + // the password and autocompleting again. + await setup_withOneLogin("", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + type: passwordType, + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { selector: usernameInputSelector, expectedValue: "" }, + }, + async function taskFn(browser) { + let [savedLogin] = Services.logins.getAllLogins(); + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + info( + "Filled generated password, waiting for dismissed password-change doorhanger" + ); + await waitForDoorhanger(browser, "password-change"); + info("Waiting to openAndVerifyDoorhanger"); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + await BrowserTestUtils.sendChar("!", browser); + await BrowserTestUtils.sendChar("@", browser); + await BrowserTestUtils.synthesizeKey("KEY_Tab", undefined, browser); + + await waitForDoorhanger(browser, "password-change"); + info("Waiting to openAndVerifyDoorhanger"); + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH + 2, + }); + + promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + await promiseHidden; + + verifyLogins([ + { + timesUsed: savedLogin.timesUsed, + username: "", + password: "xyzpassword", + }, + ]); + + info("waiting for submitForm"); + await submitForm(browser); + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH + 2, + }); + + promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + { + timesUsed: savedLogin.timesUsed + 1, + username: "", + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); + + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear(); +} + +add_task(autocomplete_generated_password_edited_no_auto_save); + +add_task( + async function autocomplete_generated_password_edited_no_auto_save_type_text() { + await autocomplete_generated_password_edited_no_auto_save("text"); + } +); + +add_task(async function contextmenu_fill_generated_password_and_set_username() { + // test when filling with a generated password and editing the username in the form + // * the prompt should display the form's username + // * the auto-saved login should have "" for username + // * confirming the prompt should edit the "" login and add the username + await setup_withOneLogin("olduser", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "olduser", + setValue: "differentuser", + // with an empty password value, no message is sent for a username change + expectedMessage: "", + }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + await SpecialPowers.spawn( + browser, + [[passwordInputSelector, usernameInputSelector]], + function checkEmptyPasswordField([passwordSelector, usernameSelector]) { + Assert.equal( + content.document.querySelector(passwordSelector).value, + "", + "Password field is empty" + ); + } + ); + + // Let the hint hide itself this first time + let forceClosePopup = false; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("waiting to fill generated password using context menu"); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + info("waiting for addLogin"); + await storageChangedPromise; + + // Check properties of the newly auto-saved login + verifyLogins([ + null, // ignore the first one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + + info("Waiting to openAndVerifyDoorhanger"); + await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "differentuser", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + await hideDoorhangerPopup(); + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + info("waiting for submitForm"); + await submitForm(browser); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "differentuser", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + null, + { + username: "differentuser", + passwordLength: LoginTestUtils.generation.LENGTH, + timesUsed: 2, + }, + ]); + await cleanupDoorhanger(notif); // cleanup the doorhanger for next test + } + ); +}); + +add_task(async function contextmenu_password_change_form_without_username() { + // test doorhanger behavior when a generated password is filled into a change-password + // form with no username + await setup_withOneLogin("user1", "xyzpassword"); + await LoginTestUtils.addLogin({ username: "username2", password: "pass2" }); + const passwordInputSelector = "#newpass"; + + const CHANGE_FORM_PATH = + "/browser/toolkit/components/passwordmgr/test/browser/form_password_change.html"; + await openFormInNewTab( + TEST_ORIGIN + CHANGE_FORM_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "", + }, + }, + async function taskFn(browser) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + // Make the 2nd field use a generated password + info("Using contextmenu to fill with a generated password"); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + // Check properties of the newly auto-saved login + verifyLogins([ + null, // ignore the first one + null, // ignore the 2nd one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + + info("Waiting to openAndVerifyDoorhanger"); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }); + // remove notification so we can unambiguously check no new notification gets created later + await cleanupDoorhanger(notif); + + info("Waiting to verifyGeneratedPasswordWasFilled"); + await verifyGeneratedPasswordWasFilled(browser, passwordInputSelector); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + let { timeLastUsed } = Services.logins.getAllLogins()[2]; + + info("waiting for submitForm"); + await submitForm(browser); + + info("Waiting for modifyLogin"); + await storageChangedPromise; + verifyLogins([ + null, // ignore the first one + null, // ignore the 2nd one + { + timesUsed: 2, + usedSince: timeLastUsed, + }, + ]); + // Check no new doorhanger was shown + notif = getCaptureDoorhanger("password-change"); + Assert.ok(!notif, "No new doorhanger should be shown"); + await cleanupDoorhanger(); // cleanup for next test + } + ); +}); + +add_task( + async function autosaved_login_updated_to_existing_login_via_doorhanger() { + // test when filling with a generated password and editing the username in the + // doorhanger to match an existing login: + // * the matching login should be updated + // * the auto-saved login should be deleted + // * the metadata for the matching login should be updated + // * the by-origin cache for the password should point at the updated login + await setup_withOneLogin("user1", "xyzpassword"); + await LoginTestUtils.addLogin({ + username: "user2", + password: "abcpassword", + }); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "", + }, + username: { + selector: usernameInputSelector, + expectedValue: "", + }, + }, + async function taskFn(browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("waiting to fill generated password using context menu"); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + info("addLogin promise resolved"); + // Check properties of the newly auto-saved login + let [user1LoginSnapshot, unused, autoSavedLogin] = verifyLogins([ + null, // ignore the first one + null, // ignore the 2nd one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + info("user1LoginSnapshot, guid: " + user1LoginSnapshot.guid); + info("unused, guid: " + unused.guid); + info("autoSavedLogin, guid: " + autoSavedLogin.guid); + + info("verifyLogins ok"); + let passwordCacheEntry = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ); + + Assert.ok( + passwordCacheEntry, + "Got the cached generated password entry for https://example.com" + ); + Assert.equal( + passwordCacheEntry.value, + autoSavedLogin.password, + "Cached password matches the auto-saved login password" + ); + Assert.equal( + passwordCacheEntry.storageGUID, + autoSavedLogin.guid, + "Cached password guid matches the auto-saved login guid" + ); + + info("Waiting to openAndVerifyDoorhanger"); + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + password: autoSavedLogin.password, + }); + Assert.ok(notif, "Got password-change notification"); + + info("Calling updateDoorhangerInputValues"); + await updateDoorhangerInputValues({ + username: "user1", + }); + info("doorhanger inputs updated"); + + let loginModifiedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (subject, data) => { + if (data == "modifyLogin") { + info("passwordmgr-storage-changed, action: " + data); + info("subject: " + JSON.stringify(subject)); + return true; + } + return false; + } + ); + let loginRemovedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (subject, data) => { + if (data == "removeLogin") { + info("passwordmgr-storage-changed, action: " + data); + info("subject: " + JSON.stringify(subject)); + return true; + } + return false; + } + ); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + info("clicking change button"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin promise"); + await loginModifiedPromise; + + info("Waiting for removeLogin promise"); + await loginRemovedPromise; + + info("storage-change promises resolved"); + // Check the auto-saved login was removed and the original login updated + verifyLogins([ + { + username: "user1", + password: autoSavedLogin.password, + timeCreated: user1LoginSnapshot.timeCreated, + timeLastUsed: user1LoginSnapshot.timeLastUsed, + passwordChangedSince: autoSavedLogin.timePasswordChanged, + }, + null, // ignore user2 + ]); + + // Check we have no notifications at this point + Assert.ok(!PopupNotifications.isPanelOpen, "No doorhanger is open"); + Assert.ok( + !PopupNotifications.getNotification("password", browser), + "No notifications" + ); + + // make sure the cache entry is unchanged with the removal of the auto-saved login + Assert.equal( + autoSavedLogin.password, + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ).value, + "Generated password cache entry has the expected password value" + ); + } + ); + } +); + +add_task(async function autosaved_login_updated_to_existing_login_onsubmit() { + // test when selecting auto-saved generated password in a form filled with an + // existing login and submitting the form: + // * the matching login should be updated + // * the auto-saved login should be deleted + // * the metadata for the matching login should be updated + // * the by-origin cache for the password should point at the updated login + + // clear both fields which should be autofilled with our single login + await setup_withOneLogin("user1", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "user1", + setValue: "", + // with an empty password value, no message is sent for a username change + expectedMessage: "", + }, + }, + async function taskFn(browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + // first, create an auto-saved login with generated password + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("waiting to fill generated password using context menu"); + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + info("addLogin promise resolved"); + // Check properties of the newly auto-saved login + let [user1LoginSnapshot, autoSavedLogin] = verifyLogins([ + null, // ignore the first one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + info("user1LoginSnapshot, guid: " + user1LoginSnapshot.guid); + info("autoSavedLogin, guid: " + autoSavedLogin.guid); + + info("verifyLogins ok"); + let passwordCacheEntry = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ); + + Assert.ok( + passwordCacheEntry, + "Got the cached generated password entry for https://example.com" + ); + Assert.equal( + passwordCacheEntry.value, + autoSavedLogin.password, + "Cached password matches the auto-saved login password" + ); + Assert.equal( + passwordCacheEntry.storageGUID, + autoSavedLogin.guid, + "Cached password guid matches the auto-saved login guid" + ); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + password: autoSavedLogin.password, + }); + await cleanupDoorhanger(notif); + + // now update and submit the form with the user1 username and the generated password + info(`submitting form`); + let submitResults = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#form-basic-username": "user1", + } + ); + Assert.equal( + submitResults.username, + "user1", + "Form submitted with expected username" + ); + Assert.equal( + submitResults.password, + autoSavedLogin.password, + "Form submitted with expected password" + ); + info( + `form was submitted, got username/password ${submitResults.username}/${submitResults.password}` + ); + + await waitForDoorhanger(browser, "password-change"); + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "user1", + password: autoSavedLogin.password, + }); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + let loginModifiedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => { + if (data == "modifyLogin") { + info("passwordmgr-storage-changed, action: " + data); + info("subject: " + JSON.stringify(_)); + return true; + } + return false; + } + ); + let loginRemovedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => { + if (data == "removeLogin") { + info("passwordmgr-storage-changed, action: " + data); + info("subject: " + JSON.stringify(_)); + return true; + } + return false; + } + ); + + info("clicking change button"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + + info("Waiting for modifyLogin promise"); + await loginModifiedPromise; + + info("Waiting for removeLogin promise"); + await loginRemovedPromise; + + info("storage-change promises resolved"); + // Check the auto-saved login was removed and the original login updated + verifyLogins([ + { + username: "user1", + password: autoSavedLogin.password, + timeCreated: user1LoginSnapshot.timeCreated, + timeLastUsed: user1LoginSnapshot.timeLastUsed, + passwordChangedSince: autoSavedLogin.timePasswordChanged, + }, + ]); + + // Check we have no notifications at this point + Assert.ok(!PopupNotifications.isPanelOpen, "No doorhanger is open"); + Assert.ok( + !PopupNotifications.getNotification("password", browser), + "No notifications" + ); + + // make sure the cache entry is unchanged with the removal of the auto-saved login + Assert.equal( + autoSavedLogin.password, + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ).value, + "Generated password cache entry has the expected password value" + ); + } + ); +}); + +add_task(async function form_change_from_autosaved_login_to_existing_login() { + // test when changing from a generated password in a form to an existing saved login + // * the auto-saved login should not be deleted + // * the metadata for the matching login should be updated + // * the by-origin cache for the password should point at the autosaved login + + await setup_withOneLogin("user1", "xyzpassword"); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + { + password: { + selector: passwordInputSelector, + expectedValue: "xyzpassword", + setValue: "", + expectedMessage: "PasswordEditedOrGenerated", + }, + username: { + selector: usernameInputSelector, + expectedValue: "user1", + setValue: "", + // with an empty password value, no message is sent for a username change + expectedMessage: "", + }, + }, + async function taskFn(browser) { + await SimpleTest.promiseFocus(browser); + + // first, create an auto-saved login with generated password + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("Filling generated password from AC menu"); + await fillGeneratedPasswordFromACPopup(browser, passwordInputSelector); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + info("addLogin promise resolved"); + // Check properties of the newly auto-saved login + let [user1LoginSnapshot, autoSavedLogin] = verifyLogins([ + null, // ignore the first one + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + info("user1LoginSnapshot, guid: " + user1LoginSnapshot.guid); + info("autoSavedLogin, guid: " + autoSavedLogin.guid); + + info("verifyLogins ok"); + let passwordCacheEntry = + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ); + + Assert.ok( + passwordCacheEntry, + "Got the cached generated password entry for https://example.com" + ); + Assert.equal( + passwordCacheEntry.value, + autoSavedLogin.password, + "Cached password matches the auto-saved login password" + ); + Assert.equal( + passwordCacheEntry.storageGUID, + autoSavedLogin.guid, + "Cached password guid matches the auto-saved login guid" + ); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + password: autoSavedLogin.password, + }); + + // close but don't remove the doorhanger, we want to ensure it is updated/replaced on further form edits + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + let PN = notif.owner; + PN.panel.hidePopup(); + await promiseHidden; + await TestUtils.waitForTick(); + + // now update the form with the user1 username and password + info(`updating form`); + let passwordEditedMessages = listenForTestNotification( + "PasswordEditedOrGenerated", + 2 + ); + let passwordChangeDoorhangerPromise = waitForDoorhanger( + browser, + "password-change" + ); + let hintDidShow = false; + let hintPromiseShown = BrowserTestUtils.waitForPopupEvent( + document.getElementById("confirmation-hint"), + "shown" + ); + hintPromiseShown.then(() => (hintDidShow = true)); + + info("Entering username and password for the previously saved login"); + + await changeContentFormValues(browser, { + [passwordInputSelector]: user1LoginSnapshot.password, + [usernameInputSelector]: user1LoginSnapshot.username, + }); + info( + "form edited, waiting for test notification of PasswordEditedOrGenerated" + ); + + await passwordEditedMessages; + info("Resolved listenForTestNotification promise"); + + await passwordChangeDoorhangerPromise; + // wait to ensure there's no confirmation hint + try { + await TestUtils.waitForCondition( + () => { + return hintDidShow; + }, + `Waiting for confirmationHint popup`, + undefined, + 25 + ); + } catch (ex) { + info("Got expected timeout from the waitForCondition: ", ex); + } finally { + Assert.ok(!hintDidShow, "No confirmation hint shown"); + } + + // the previous doorhanger would have old values, verify it was updated/replaced with new values from the form + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "", + usernameValue: user1LoginSnapshot.username, + passwordLength: user1LoginSnapshot.password.length, + }); + await cleanupDoorhanger(notif); + + storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ); + + // submit the form to ensure the correct updates are made + await submitForm(browser); + info("form submitted, waiting for storage changed"); + await storageChangedPromise; + + // Check the auto-saved login has not changed and only metadata on the original login updated + verifyLogins([ + { + username: "user1", + password: "xyzpassword", + timeCreated: user1LoginSnapshot.timeCreated, + usedSince: user1LoginSnapshot.timeLastUsed, + }, + { + username: "", + password: autoSavedLogin.password, + timeCreated: autoSavedLogin.timeCreated, + timeLastUsed: autoSavedLogin.timeLastUsed, + }, + ]); + + // Check we have no notifications at this point + Assert.ok(!PopupNotifications.isPanelOpen, "No doorhanger is open"); + Assert.ok( + !PopupNotifications.getNotification("password", browser), + "No notifications" + ); + + // make sure the cache entry is unchanged with the removal of the auto-saved login + Assert.equal( + autoSavedLogin.password, + LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().get( + "https://example.com" + ).value, + "Generated password cache entry has the expected password value" + ); + } + ); +}); + +add_task(async function form_edit_username_and_password_of_generated_login() { + // test when changing the username and then the password in a form with a generated password (bug 1625242) + // * the toast is not shown for the username change as the auto-saved login is not modified + // * the dismissed doorhanger for the username change has the correct username and password + // * the toast is shown for the change to the generated password + // * the dismissed doorhanger for the password change has the correct username and password + + await setup_withNoLogins(); + await openFormInNewTab( + TEST_ORIGIN + FORM_PAGE_PATH, + {}, + async function taskFn(browser) { + await SimpleTest.promiseFocus(browser); + + // first, create an auto-saved login with generated password + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + // We don't need to wait to confirm the hint hides itelf every time + let forceClosePopup = true; + let hintShownAndVerified = verifyConfirmationHint( + browser, + forceClosePopup + ); + + info("Filling generated password from context menu"); + // there's no new-password field in this form so we'll use the context menu + await doFillGeneratedPasswordContextMenuItem( + browser, + passwordInputSelector + ); + + info("waiting for dismissed password-change notification"); + await waitForDoorhanger(browser, "password-change"); + + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await hintShownAndVerified; + + info("waiting for addLogin"); + await storageChangedPromise; + info("addLogin promise resolved"); + // Check properties of the newly auto-saved login + let [autoSavedLoginSnapshot] = verifyLogins([ + { + timesUsed: 1, + username: "", + passwordLength: LoginTestUtils.generation.LENGTH, + }, + ]); + + let notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: "attention", + usernameValue: "", + password: autoSavedLoginSnapshot.password, + }); + + // close but don't remove the doorhanger, we want to ensure it is updated/replaced on further form edits + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + let PN = notif.owner; + PN.panel.hidePopup(); + await promiseHidden; + await TestUtils.waitForTick(); + + // change the username then the password in the form + for (let { + fieldSelector, + fieldValue, + expectedConfirmation, + expectedDoorhangerUsername, + expectedDoorhangerPassword, + expectedDoorhangerType, + } of [ + { + fieldSelector: usernameInputSelector, + fieldValue: "someuser", + expectedConfirmation: false, + expectedDoorhangerUsername: "someuser", + expectedDoorhangerPassword: autoSavedLoginSnapshot.password, + expectedDoorhangerType: "password-change", + }, + { + fieldSelector: passwordInputSelector, + fieldValue: "!!", + expectedConfirmation: true, + expectedDoorhangerUsername: "someuser", + expectedDoorhangerPassword: autoSavedLoginSnapshot.password + "!!", + expectedDoorhangerType: "password-change", + }, + ]) { + let loginModifiedPromise = expectedConfirmation + ? TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "modifyLogin" + ) + : Promise.resolve(); + + // now edit the field value + let passwordEditedMessage = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + let passwordChangeDoorhangerPromise = waitForDoorhanger( + browser, + expectedDoorhangerType + ); + let hintDidShow = false; + let hintPromiseShown = BrowserTestUtils.waitForPopupEvent( + document.getElementById("confirmation-hint"), + "shown" + ); + hintPromiseShown.then(() => (hintDidShow = true)); + + info(`updating form: ${fieldSelector}: ${fieldValue}`); + await appendContentInputvalue(browser, fieldSelector, fieldValue); + info( + "form edited, waiting for test notification of PasswordEditedOrGenerated" + ); + await passwordEditedMessage; + info( + "Resolved listenForTestNotification promise, waiting for doorhanger" + ); + await passwordChangeDoorhangerPromise; + // wait for possible confirmation hint + try { + info("Waiting for hintDidShow"); + await TestUtils.waitForCondition( + () => hintDidShow, + `Waiting for confirmationHint popup`, + undefined, + 25 + ); + } catch (ex) { + info("Got expected timeout from the waitForCondition: " + ex); + } finally { + info("confirmationHint check done, assert on hintDidShow"); + Assert.equal( + hintDidShow, + expectedConfirmation, + "Confirmation hint shown" + ); + } + info( + "Waiting for loginModifiedPromise, expectedConfirmation? " + + expectedConfirmation + ); + await loginModifiedPromise; + + // the previous doorhanger would have old values, verify it was updated/replaced with new values from the form + info("Verifying the doorhanger"); + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: true, + anchorExtraAttr: expectedConfirmation ? "attention" : "", + usernameValue: expectedDoorhangerUsername, + passwordLength: expectedDoorhangerPassword.length, + }); + await cleanupDoorhanger(notif); + } + + // submit the form to verify we still get the right doorhanger values + let passwordChangeDoorhangerPromise = waitForDoorhanger( + browser, + "password-change" + ); + await submitForm(browser); + info("form submitted, waiting for doorhanger"); + await passwordChangeDoorhangerPromise; + notif = await openAndVerifyDoorhanger(browser, "password-change", { + dismissed: false, + anchorExtraAttr: "", + usernameValue: "someuser", + passwordLength: LoginTestUtils.generation.LENGTH + 2, + }); + await cleanupDoorhanger(notif); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js new file mode 100644 index 0000000000..acf3b64e08 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_httpsUpgrade.js @@ -0,0 +1,303 @@ +/* + * Test capture popup notifications with HTTPS upgrades + */ + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login1 = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +let login1HTTPS = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); + +add_task(async function test_httpsUpgradeCaptureFields_noChange() { + info( + "Check that we don't prompt to remember when capturing an upgraded login with no change" + ); + await Services.logins.addLoginAsync(login1); + // Sanity check the HTTP login exists. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should have the HTTP login"); + + await testSubmittingLoginForm( + "subtst_notifications_1.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + }, + "https://example.com" + ); // This is HTTPS whereas the saved login is HTTP + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login still"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.origin, + "http://example.com", + "Check the origin is unchanged" + ); + Assert.equal(login.username, "notifyu1", "Check the username is unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password is unchanged"); + Assert.equal(login.timesUsed, 2, "Check times used increased"); + + Services.logins.removeLogin(login1); +}); + +add_task(async function test_httpsUpgradeCaptureFields_changePW() { + info( + "Check that we prompt to change when capturing an upgraded login with a new PW" + ); + await Services.logins.addLoginAsync(login1); + // Sanity check the HTTP login exists. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should have the HTTP login"); + + await testSubmittingLoginForm( + "subtst_notifications_8.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "checking for a change popup"); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + }, + "https://example.com" + ); // This is HTTPS whereas the saved login is HTTP + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login still"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.origin, + "https://example.com", + "Check the origin is upgraded" + ); + Assert.equal( + login.formActionOrigin, + "https://example.com", + "Check the formActionOrigin is upgraded" + ); + Assert.equal(login.username, "notifyu1", "Check the username is unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used increased"); + + Services.logins.removeAllUserFacingLogins(); +}); + +add_task( + async function test_httpsUpgradeCaptureFields_changePWWithBothSchemesSaved() { + info( + "Check that we prompt to change and properly save when capturing an upgraded login with a new PW when an http login also exists for that username" + ); + await Services.logins.addLogins([login1, login1HTTPS]); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have both HTTP and HTTPS logins"); + + await testSubmittingLoginForm( + "subtst_notifications_8.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "checking for a change popup"); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + }, + "https://example.com" + ); + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have 2 logins still"); + let loginHTTP = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + let loginHTTPS = logins[1].QueryInterface(Ci.nsILoginMetaInfo); + Assert.ok( + LoginHelper.doLoginsMatch(login1, loginHTTP, { ignorePassword: true }), + "Check HTTP login is equal" + ); + Assert.equal(loginHTTP.timesUsed, 1, "Check times used stayed the same"); + Assert.equal( + loginHTTP.timeCreated, + loginHTTP.timePasswordChanged, + "login.timeCreated == login.timePasswordChanged" + ); + Assert.equal( + loginHTTP.timeLastUsed, + loginHTTP.timePasswordChanged, + "timeLastUsed == timePasswordChanged" + ); + + Assert.ok( + LoginHelper.doLoginsMatch(login1HTTPS, loginHTTPS, { + ignorePassword: true, + }), + "Check HTTPS login is equal" + ); + Assert.equal( + loginHTTPS.username, + "notifyu1", + "Check the username is unchanged" + ); + Assert.equal(loginHTTPS.password, "pass2", "Check the password changed"); + Assert.equal(loginHTTPS.timesUsed, 2, "Check times used increased"); + Assert.ok( + loginHTTPS.timeCreated < loginHTTPS.timePasswordChanged, + "login.timeCreated < login.timePasswordChanged" + ); + Assert.equal( + loginHTTPS.timeLastUsed, + loginHTTPS.timePasswordChanged, + "timeLastUsed == timePasswordChanged" + ); + + Services.logins.removeAllUserFacingLogins(); + } +); + +add_task(async function test_httpsUpgradeCaptureFields_captureMatchingHTTP() { + info("Capture a new HTTP login which matches a stored HTTPS one."); + await Services.logins.addLoginAsync(login1HTTPS); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + + Assert.equal( + Services.logins.getAllLogins().length, + 1, + "Should only have the HTTPS login" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have both HTTP and HTTPS logins"); + for (let login of logins) { + login = login.QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu1", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp1", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on entry"); + } + + info( + "Make sure Remember took effect and we don't prompt for an existing HTTP login" + ); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have both HTTP and HTTPS still"); + + let httpsLogins = LoginHelper.searchLoginsWithObject({ + origin: "https://example.com", + }); + Assert.equal(httpsLogins.length, 1, "Check https logins count"); + let httpsLogin = httpsLogins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.ok(httpsLogin.equals(login1HTTPS), "Check HTTPS login didn't change"); + Assert.equal(httpsLogin.timesUsed, 1, "Check times used"); + + let httpLogins = LoginHelper.searchLoginsWithObject({ + origin: "http://example.com", + }); + Assert.equal(httpLogins.length, 1, "Check http logins count"); + let httpLogin = httpLogins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.ok(httpLogin.equals(login1), "Check HTTP login is as expected"); + Assert.equal(httpLogin.timesUsed, 2, "Check times used increased"); + + Services.logins.removeLogin(login1); + Services.logins.removeLogin(login1HTTPS); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_multipage_form.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_multipage_form.js new file mode 100644 index 0000000000..ff7bffcb44 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_multipage_form.js @@ -0,0 +1,182 @@ +/** + * Test that the doorhanger notification for password saving is populated with + * the correct values in various password capture cases (multipage login form). + */ + +const testCases = [ + { + name: "No saved logins, username and password", + username: "username", + password: "password", + expectOutcome: [ + { + username: "username", + password: "password", + }, + ], + }, + { + name: "No saved logins, password with empty username", + username: "", + password: "password", + expectOutcome: [ + { + username: "", + password: "password", + }, + ], + }, + { + name: "Saved login with username, update password", + username: "username", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "username", + password: "newPassword", + }, + ], + }, + { + name: "Saved login with username, add username", + oldUsername: "username", + username: "newUsername", + password: "password", + expectOutcome: [ + { + username: "newUsername", + password: "password", + }, + ], + }, + { + name: "Saved login with no username, add username and different password", + oldUsername: "", + username: "username", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "", + password: "password", + }, + { + username: "username", + password: "newPassword", + }, + ], + }, +]; + +add_task(async function test_initialize() { + Services.prefs.setBoolPref("signon.usernameOnlyForm.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.usernameOnlyForm.enabled"); + }); +}); + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + info("testing with: " + JSON.stringify(testData)); + await test_save_change(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function test_save_change(testData) { + let { oldUsername, username, oldPassword, password, expectOutcome } = + testData; + // Add a login for the origin of the form if testing a change notification. + if (oldPassword) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: typeof oldUsername !== "undefined" ? oldUsername : username, + password: oldPassword, + }) + ); + } + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_multipage.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + // Update the username filed from the test case. + info(`update form with username: ${username}`); + await changeContentFormValues(browser, { + "#form-basic-username": username, + }); + + // Submit the username-only form, which then advance to the password-only + // form. + info(`submit the username-only form`); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic-submit").click(); + await ContentTaskUtils.waitForCondition(() => { + return doc.getElementById("form-basic-password"); + }, "Wait for the username field"); + }); + + // Update the password filed from the test case. + info(`update form with password: ${password}`); + await changeContentFormValues(browser, { + "#form-basic-password": password, + }); + + // Submit the form. + info(`submit the password-only form`); + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic-submit").click(); + }); + await formSubmittedPromise; + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification, expectedDoorhanger; + if (oldPassword !== undefined && oldUsername !== undefined) { + expectedNotification = "addLogin"; + expectedDoorhanger = "password-save"; + } else if (oldPassword !== undefined) { + expectedNotification = "modifyLogin"; + expectedDoorhanger = "password-change"; + } else { + expectedNotification = "addLogin"; + expectedDoorhanger = "password-save"; + } + + info("Waiting for doorhanger of type: " + expectedDoorhanger); + let notif = await waitForDoorhanger(browser, expectedDoorhanger); + + // Check the actual content of the popup notification. + await checkDoorhangerUsernamePassword(username, password); + + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseLogin; + await cleanupDoorhanger(notif); // clean slate for the next test + + // Check that the values in the database match the expected values. + verifyLogins(expectOutcome); + } + ); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllUserFacingLogins(); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_password_edits.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_password_edits.js new file mode 100644 index 0000000000..804612cf53 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_password_edits.js @@ -0,0 +1,220 @@ +/** + * Test changing the password inside the doorhanger notification for passwords. + * + * We check the following cases: + * - Editing the password of a new login. + * - Editing the password of an existing login. + * - Changing both username and password to an existing login. + * - Changing the username to an existing login. + * - Editing username to an empty one and a new password. + * + * If both the username and password matches an already existing login, we should not + * update it's password, but only it's usage timestamp and count. + */ +add_task(async function test_edit_password() { + let testCases = [ + { + description: "No saved logins, update password in doorhanger", + usernameInPage: "username", + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 1, + }, + { + description: "Login is saved, update password in doorhanger", + usernameInPage: "username", + usernameInPageExists: true, + passwordInPage: "password", + passwordInStorage: "oldPassword", + passwordChangedTo: "newPassword", + timesUsed: 2, + }, + { + description: + "Change username in doorhanger to match saved login, update password in doorhanger", + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 2, + }, + { + description: + "Change username in doorhanger to match saved login, dont update password in doorhanger", + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "password", + timesUsed: 2, + checkPasswordNotUpdated: true, + }, + { + description: + "Change username and password in doorhanger to match saved empty-username login", + usernameInPage: "newUsername", + usernameChangedTo: "", + usernameChangedToExists: true, + passwordInPage: "password", + passwordChangedTo: "newPassword", + timesUsed: 2, + }, + ]; + + for (let testCase of testCases) { + info("Test case: " + JSON.stringify(testCase)); + // Clean state before the test case is executed. + await LoginTestUtils.clearData(); + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + + // Create the pre-existing logins when needed. + if (testCase.usernameInPageExists) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: testCase.usernameInPage, + password: testCase.passwordInStorage, + }) + ); + } + + if (testCase.usernameChangedToExists) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: testCase.usernameChangedTo, + password: testCase.passwordChangedTo, + }) + ); + } + + let formFilledPromise = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await formFilledPromise; + + // Set the form to a known state so we can expect a single PasswordEditedOrGenerated message + await initForm(browser, { + "#form-basic-username": testCase.usernameInPage, + "#form-basic-password": "", + }); + + let passwordEditedPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + info("Editing the form"); + await changeContentFormValues(browser, { + "#form-basic-password": testCase.passwordInPage, + }); + info("Waiting for passwordEditedPromise"); + await passwordEditedPromise; + + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache, we're only interested in the submit outcome + await clearMessageCache(browser); + + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + info("Submitting the form"); + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown", + event => event.target == PopupNotifications.panel + ); + await SpecialPowers.spawn(browser, [], function () { + content.document.getElementById("form-basic").submit(); + }); + await formSubmittedPromise; + + let notif = await waitForDoorhanger(browser, "any"); + Assert.ok(!notif.dismissed, "Doorhanger is not dismissed"); + await promiseShown; + + // Modify the username & password in the dialog if requested. + await updateDoorhangerInputValues({ + username: testCase.usernameChangedTo, + password: testCase.passwordChangedTo, + }); + + // We expect a modifyLogin notification if the final username used by the + // dialog exists in the logins database, otherwise an addLogin one. + let expectModifyLogin = + typeof testCase.usernameChangedTo !== "undefined" + ? testCase.usernameChangedToExists + : testCase.usernameInPageExists; + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification = expectModifyLogin + ? "modifyLogin" + : "addLogin"; + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + info("Waiting for storage changed"); + let [result] = await promiseLogin; + + // Check that the values in the database match the expected values. + let login = expectModifyLogin + ? result + .QueryInterface(Ci.nsIArray) + .queryElementAt(1, Ci.nsILoginInfo) + : result.QueryInterface(Ci.nsILoginInfo); + let meta = login.QueryInterface(Ci.nsILoginMetaInfo); + + let expectedLogin = { + username: + "usernameChangedTo" in testCase + ? testCase.usernameChangedTo + : testCase.usernameInPage, + password: + "passwordChangedTo" in testCase + ? testCase.passwordChangedTo + : testCase.passwordInPage, + timesUsed: testCase.timesUsed, + }; + // Check that the password was not updated if the user is empty + if (testCase.checkPasswordNotUpdated) { + expectedLogin.usedSince = meta.timeCreated; + expectedLogin.timeCreated = meta.timePasswordChanged; + } + verifyLogins([expectedLogin]); + } + ); + } +}); + +async function initForm(browser, formDefaults = {}) { + await ContentTask.spawn( + browser, + formDefaults, + async function (selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + content.document.querySelector(sel).value = value; + } + } + ); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_promptToChangePassword.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_promptToChangePassword.js new file mode 100644 index 0000000000..84c241e020 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_promptToChangePassword.js @@ -0,0 +1,685 @@ +/** + * Test result of different input to the promptToChangePassword doorhanger + */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const passwordInputSelector = "#form-basic-password"; +const usernameInputSelector = "#form-basic-username"; + +const availLoginsByValue = new Map(); +let savedLoginsByName; +const finalLoginsByGuid = new Map(); +let finalLogins; + +const availLogins = { + emptyXYZ: LoginTestUtils.testData.formLogin({ + username: "", + password: "xyz", + }), + bobXYZ: LoginTestUtils.testData.formLogin({ + username: "bob", + password: "xyz", + }), + bobABC: LoginTestUtils.testData.formLogin({ + username: "bob", + password: "abc", + }), +}; +availLoginsByValue.set(availLogins.emptyXYZ, "emptyXYZ"); +availLoginsByValue.set(availLogins.bobXYZ, "bobXYZ"); +availLoginsByValue.set(availLogins.bobABC, "bobABC"); + +async function showChangePasswordDoorhanger( + browser, + oldLogin, + formLogin, + { notificationType = "password-change", autoSavedLoginGuid = "" } = {} +) { + let windowGlobal = browser.browsingContext.currentWindowGlobal; + let loginManagerActor = windowGlobal.getActor("LoginManager"); + let prompter = loginManagerActor._getPrompter(browser, null); + Assert.ok( + !PopupNotifications.isPanelOpen, + "Check the doorhanger isn't already open" + ); + + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + prompter.promptToChangePassword( + browser, + oldLogin, + formLogin, + false, // dimissed prompt + false, // notifySaved + autoSavedLoginGuid + ); + await promiseShown; + + let notif = getCaptureDoorhanger(notificationType); + Assert.ok(notif, `${notificationType} notification exists`); + + let { panel } = PopupNotifications; + let notificationElement = panel.childNodes[0]; + await BrowserTestUtils.waitForCondition(() => { + return ( + notificationElement.querySelector("#password-notification-password") + .value == formLogin.password && + notificationElement.querySelector("#password-notification-username") + .value == formLogin.username + ); + }, "Wait for the notification panel to be populated"); + return notif; +} + +async function setupLogins(...logins) { + Services.logins.removeAllUserFacingLogins(); + let savedLogins = {}; + let timesCreated = new Set(); + for (let login of logins) { + let loginName = availLoginsByValue.get(login); + let savedLogin = await LoginTestUtils.addLogin(login); + // we rely on sorting by timeCreated so ensure none are identical + Assert.ok( + !timesCreated.has(savedLogin.timeCreated), + "Each login has a different timeCreated" + ); + timesCreated.add(savedLogin.timeCreated); + savedLogins[loginName || savedLogin.guid] = savedLogin.clone(); + } + return savedLogins; +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["signon.autofillForms", false]], + }); + Assert.ok(!PopupNotifications.isPanelOpen, "No notifications panel open"); +}); + +async function promptToChangePasswordTest(testData) { + info("Starting: " + testData.name); + savedLoginsByName = await setupLogins(...testData.initialSavedLogins); + await SimpleTest.promiseFocus(); + info("got focus"); + + let oldLogin = savedLoginsByName[testData.promptArgs.oldLogin]; + let changeLogin = LoginTestUtils.testData.formLogin( + testData.promptArgs.changeLogin + ); + let options; + if (testData.autoSavedLoginName) { + options = { + autoSavedLoginGuid: savedLoginsByName[testData.autoSavedLoginName].guid, + }; + } + info( + "Waiting for showChangePasswordDoorhanger, username: " + + changeLogin.username + ); + await BrowserTestUtils.withNewTab( + { + gBrowser, + TEST_ORIGIN, + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + let notif = await showChangePasswordDoorhanger( + browser, + oldLogin, + changeLogin, + options + ); + + await updateDoorhangerInputValues(testData.promptTextboxValues); + + let mainActionButton = getDoorhangerButton(notif, CHANGE_BUTTON); + Assert.equal( + mainActionButton.label, + testData.expectedButtonLabel, + "Check button label" + ); + + let { panel } = PopupNotifications; + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + let storagePromise; + if (testData.expectedStorageChange) { + storagePromise = TestUtils.topicObserved("passwordmgr-storage-changed"); + } + + info("Clicking mainActionButton"); + mainActionButton.doCommand(); + info("Waiting for promiseHidden"); + await promiseHidden; + info("Waiting for storagePromise"); + await storagePromise; + + // ensure the notification was removed to keep clean state for next run + await cleanupDoorhanger(notif); + + info(testData.resultDescription); + + finalLoginsByGuid.clear(); + finalLogins = Services.logins.getAllLogins(); + finalLogins.sort((a, b) => a.timeCreated > b.timeCreated); + + for (let l of finalLogins) { + info(`saved login: ${l.guid}: ${l.username}/${l.password}`); + finalLoginsByGuid.set(l.guid, l); + } + info("verifyLogins next"); + verifyLogins(testData.expectedResultLogins); + if (testData.resultCheck) { + testData.resultCheck(); + } + } + ); +} + +let tests = [ + { + name: "Add username to sole login", + initialSavedLogins: [availLogins.emptyXYZ], + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "zaphod", + password: "xyz", + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: "The existing login just gets a new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "zaphod", + password: "xyz", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + }, + }, + { + name: "Change password of the sole login", + initialSavedLogins: [availLogins.bobXYZ], + promptArgs: { + oldLogin: "bobXYZ", + changeLogin: { + username: "bob", + password: "&*$", + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: "The existing login just gets a new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "&*$", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobXYZ.guid, + "Check guid" + ); + }, + }, + { + name: "Change password of the sole empty-username login", + initialSavedLogins: [availLogins.emptyXYZ], + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "", + password: "&*$", + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: "The existing login just gets a new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "&*$", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + }, + }, + { + name: "Add different username to empty-usernamed login", + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "alice", + password: "xyz", + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: "The existing login just gets a new username", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "alice", + password: "xyz", + }, + { + username: "bob", + password: "abc", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.emptyXYZ.timeLastUsed, + "Check timeLastUsed of 0th login" + ); + }, + }, + { + name: "Add username to autosaved login to match an existing usernamed login", + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + autoSavedLoginName: "emptyXYZ", + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "bob", + password: availLogins.emptyXYZ.password, + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + resultDescription: + "Empty-username login is removed, other login gets the empty-login's password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "xyz", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed changed" + ); + }, + }, + { + name: "Add username to non-autosaved login to match an existing usernamed login", + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + autoSavedLoginName: "", + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "bob", + password: availLogins.emptyXYZ.password, + }, + }, + promptTextboxValues: {}, + expectedButtonLabel: "Update", + // We can't end up with duplicates (bob:xyz and bob:ABC) so the following seems reasonable. + // We could delete the emptyXYZ but we would want to intelligently merge metadata. + resultDescription: + "Multiple login matches but user indicated they want bob:xyz in the prompt so modify bob to give that", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "xyz", + }, + { + username: "bob", + password: "xyz", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + Assert.equal( + finalLogins[0].timeLastUsed, + savedLoginsByName.emptyXYZ.timeLastUsed, + "Check timeLastUsed didn't change" + ); + Assert.equal( + finalLogins[0].timePasswordChanged, + savedLoginsByName.emptyXYZ.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + + Assert.equal( + finalLogins[1].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[1].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + Assert.ok( + finalLogins[1].timePasswordChanged > + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged did change" + ); + }, + }, + { + name: "Username & password changes to an auto-saved login apply to matching usernamed-login", + // when we update an auto-saved login - changing both username & password, is + // the matching login updated and empty-username login removed? + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + autoSavedLoginName: "emptyXYZ", + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "bob", + password: "xyz", + }, + }, + promptTextboxValues: { + // type a new password in the doorhanger + password: "newpassword", + }, + expectedButtonLabel: "Update", + resultDescription: + "The empty-username login is removed, other login gets the new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "newpassword", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + }, + }, + { + name: "Username & password changes to a non-auto-saved login matching usernamed-login", + // when we update a non-auto-saved login - changing both username & password, is + // the matching login updated and empty-username login unchanged? + initialSavedLogins: [availLogins.emptyXYZ, availLogins.bobABC], + autoSavedLoginName: "", // no auto-saved logins for this session + promptArgs: { + oldLogin: "emptyXYZ", + changeLogin: { + username: "bob", + password: "xyz", + }, + }, + promptTextboxValues: { + // type a new password in the doorhanger + password: "newpassword", + }, + expectedButtonLabel: "Update", + resultDescription: + "The empty-username login is not changed, other login gets the new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "xyz", + }, + { + username: "bob", + password: "newpassword", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.emptyXYZ.guid, + "Check guid" + ); + Assert.equal( + finalLogins[0].timeLastUsed, + savedLoginsByName.emptyXYZ.timeLastUsed, + "Check timeLastUsed didn't change" + ); + Assert.equal( + finalLogins[0].timePasswordChanged, + savedLoginsByName.emptyXYZ.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + Assert.equal( + finalLogins[1].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[1].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + Assert.ok( + finalLogins[1].timePasswordChanged > + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged did change" + ); + }, + }, + { + name: "Remove the username and change password of autosaved login", + initialSavedLogins: [availLogins.bobABC], + autoSavedLoginName: "bobABC", + promptArgs: { + oldLogin: "bobABC", + changeLogin: { + username: "bob", + password: "abc!", // trigger change prompt with a password change + }, + }, + promptTextboxValues: { + username: "", + }, + expectedButtonLabel: "Update", + resultDescription: + "The auto-saved login is updated with new empty-username login and new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "abc!", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + Assert.ok( + finalLogins[0].timePasswordChanged > + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged did change" + ); + }, + }, + { + name: "Remove the username and change password of non-autosaved login", + initialSavedLogins: [availLogins.bobABC], + // no autosaved guid + promptArgs: { + oldLogin: "bobABC", + changeLogin: { + username: "bob", + password: "abc!", // trigger change prompt with a password change + }, + }, + promptTextboxValues: { + username: "", + }, + expectedButtonLabel: "Save", + resultDescription: + "A new empty-username login is created with the new password", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "abc", + }, + { + username: "", + password: "abc!", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.equal( + finalLogins[0].timeLastUsed, + savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed didn't change" + ); + Assert.equal( + finalLogins[0].timePasswordChanged, + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + }, + }, + { + name: "Remove username from the auto-saved sole login", + initialSavedLogins: [availLogins.bobABC], + autoSavedLoginName: "bobABC", + promptArgs: { + oldLogin: "bobABC", + changeLogin: { + username: "bob", + password: "abc!", // trigger change prompt with a password change + }, + }, + promptTextboxValues: { + username: "", + password: "abc", // put password back to what it was + }, + expectedButtonLabel: "Update", + resultDescription: "The existing login is updated", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "", + password: "abc", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.ok( + finalLogins[0].timeLastUsed > savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed did change" + ); + todo_is( + finalLogins[0].timePasswordChanged, + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + }, + }, + { + name: "Remove username from the non-auto-saved sole login", + initialSavedLogins: [availLogins.bobABC], + // no autoSavedLoginGuid + promptArgs: { + oldLogin: "bobABC", + changeLogin: { + username: "bob", + password: "abc!", // trigger change prompt with a password change + }, + }, + promptTextboxValues: { + username: "", + password: "abc", // put password back to what it was + }, + expectedButtonLabel: "Save", + resultDescription: "A new empty-username login is created", + expectedStorageChange: true, + expectedResultLogins: [ + { + username: "bob", + password: "abc", + }, + { + username: "", + password: "abc", + }, + ], + resultCheck() { + Assert.equal( + finalLogins[0].guid, + savedLoginsByName.bobABC.guid, + "Check guid" + ); + Assert.equal( + finalLogins[0].timeLastUsed, + savedLoginsByName.bobABC.timeLastUsed, + "Check timeLastUsed didn't change" + ); + Assert.equal( + finalLogins[0].timePasswordChanged, + savedLoginsByName.bobABC.timePasswordChanged, + "Check timePasswordChanged didn't change" + ); + }, + }, +]; + +for (let testData of tests) { + let tmp = { + async [testData.name]() { + await promptToChangePasswordTest(testData); + }, + }; + add_task(tmp[testData.name]); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js new file mode 100644 index 0000000000..bc0b245e4d --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_remembering.js @@ -0,0 +1,1275 @@ +/* + * Test capture popup notifications + */ + +const BRAND_BUNDLE = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" +); +const BRAND_SHORT_NAME = BRAND_BUNDLE.GetStringFromName("brandShortName"); + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login1 = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +let login2 = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "", + "notifyp1", + "", + "pass" +); +let login1B = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "notifyu1B", + "notifyp1B", + "user", + "pass" +); +let login2B = new nsLoginInfo( + "http://example.com", + "http://example.com", + null, + "", + "notifyp1B", + "", + "pass" +); + +requestLongerTimeout(2); + +add_setup(async function () { + // Load recipes for this test. + let recipeParent = await LoginManagerParent.recipeParentPromise; + await recipeParent.load({ + siteRecipes: [ + { + hosts: ["example.org"], + usernameSelector: "#user", + passwordSelector: "#pass", + }, + ], + }); +}); + +add_task(async function test_remember_opens() { + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_clickNever() { + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.ok(notif, "got notification popup"); + Assert.equal( + true, + Services.logins.getLoginSavingEnabled("http://example.com"), + "Checking for login saving enabled" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, NEVER_MENUITEM); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); + + info("Make sure Never took effect"); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + Assert.equal( + false, + Services.logins.getLoginSavingEnabled("http://example.com"), + "Checking for login saving disabled" + ); + Services.logins.setLoginSavingEnabled("http://example.com", true); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_clickRemember() { + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + let promiseNewSavedPassword = TestUtils.topicObserved( + "LoginStats:NewSavedPassword", + (subject, data) => subject == gBrowser.selectedBrowser + ); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseNewSavedPassword; + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu1", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp1", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); + + info( + "Make sure Remember took effect and we don't prompt for an existing login" + ); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + function (fieldValues) { + // form login matches a saved login, we don't expect a notification on change or submit + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username used"); + Assert.equal(login.password, "notifyp1", "Check the password used"); + Assert.equal(login.timesUsed, 2, "Check times used incremented"); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + + // remove that login + Services.logins.removeLogin(login1); + await cleanupDoorhanger(); +}); + +/* signons.rememberSignons pref tests... */ + +add_task(async function test_rememberSignonsFalse() { + info("Make sure we don't prompt with rememberSignons=false"); + Services.prefs.setBoolPref("signon.rememberSignons", false); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_rememberSignonsTrue() { + info("Make sure we prompt with rememberSignons=true"); + Services.prefs.setBoolPref("signon.rememberSignons", true); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +/* autocomplete=off tests... */ + +add_task(async function test_autocompleteOffUsername() { + info( + "Check for notification popup when autocomplete=off present on username" + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_autocompleteOffPassword() { + info( + "Check for notification popup when autocomplete=off present on password" + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_3.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_autocompleteOffForm() { + info("Check for notification popup when autocomplete=off present on form"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_4.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_noPasswordField() { + info("Check for no notification popup when no password field present"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_5.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal(fieldValues.password, "null", "Checking submitted password"); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_pwOnlyNewLoginMatchesUPForm() { + info("Check for update popup when new existing pw-only login matches form."); + await Services.logins.addLoginAsync(login2); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Add username to saved password?", + "Check message" + ); + + let { panel } = PopupNotifications; + let passwordVisiblityToggle = panel.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.ok( + !passwordVisiblityToggle.hidden, + "Toggle visible for a recently saved pw" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username"); + Assert.equal(login.password, "notifyp1", "Check the password"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + Services.logins.removeLogin(login); +}); + +add_task(async function test_pwOnlyOldLoginMatchesUPForm() { + info("Check for update popup when old existing pw-only login matches form."); + await Services.logins.addLoginAsync(login2); + + // Change the timePasswordChanged to be old so that the password won't be + // revealed in the doorhanger. + let oldTimeMS = new Date("2009-11-15").getTime(); + Services.logins.modifyLogin( + login2, + LoginHelper.newPropertyBag({ + timeCreated: oldTimeMS, + timeLastUsed: oldTimeMS + 1, + timePasswordChanged: oldTimeMS, + }) + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "checking for notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Add username to saved password?", + "Check message" + ); + + let { panel } = PopupNotifications; + let passwordVisiblityToggle = panel.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.ok( + passwordVisiblityToggle.hidden, + "Toggle hidden for an old saved pw" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username"); + Assert.equal(login.password, "notifyp1", "Check the password"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + Services.logins.removeLogin(login); +}); + +add_task(async function test_pwOnlyFormMatchesLogin() { + info( + "Check for no notification popup when pw-only form matches existing login." + ); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_6.html", + function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username"); + Assert.equal(login.password, "notifyp1", "Check the password"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + Services.logins.removeLogin(login1); +}); + +add_task(async function test_pwOnlyFormDoesntMatchExisting() { + info( + "Check for notification popup when pw-only form doesn't match existing login." + ); + await Services.logins.addLoginAsync(login1B); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_6.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1B", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1B); +}); + +add_task(async function test_changeUPLoginOnUPForm_dont() { + info("Check for change-password popup, u+p login on u+p form. (not changed)"); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_8.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update login for example.com?", + "Check message" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, DONT_CHANGE_BUTTON); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1); +}); + +add_task(async function test_changeUPLoginOnUPForm_remove() { + info("Check for change-password popup, u+p login on u+p form. (remove)"); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_8.html", + async function (fieldValues, browser) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update login for example.com?", + "Check message" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, REMOVE_LOGIN_MENUITEM); + + // Let the hint hide itself + const forceClosePopup = false; + // Make sure confirmation hint was shown + info("waiting for verifyConfirmationHint"); + await verifyConfirmationHint(browser, forceClosePopup, "identity-icon"); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "Should have 0 logins"); +}); + +add_task(async function test_changeUPLoginOnUPForm_change() { + info("Check for change-password popup, u+p login on u+p form."); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_8.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update login for example.com?", + "Check message" + ); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + let promiseLoginUpdateSaved = TestUtils.topicObserved( + "LoginStats:LoginUpdateSaved", + (subject, data) => subject == gBrowser.selectedBrowser + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseLoginUpdateSaved; + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(async function test_changePLoginOnUPForm() { + info("Check for change-password popup, p-only login on u+p form (empty u)."); + await Services.logins.addLoginAsync(login2); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_9.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update password for example.com?", + "Check msg" + ); + + await checkDoorhangerUsernamePassword("", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "", "Check the username unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + // no cleanup -- saved password to be used in the next test. +}); + +add_task(async function test_changePLoginOnPForm() { + info("Check for change-password popup, p-only login on p-only form."); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_10.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + notif.message, + "Update password for example.com?", + "Check msg" + ); + + await checkDoorhangerUsernamePassword("", "notifyp1"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password changed"); + Assert.equal(login.timesUsed, 3, "Check times used"); + + Services.logins.removeLogin(login2); +}); + +add_task(async function test_checkUPSaveText() { + info("Check text on a user+pass notification popup"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.ok(notif, "got notification popup"); + // Check the text, which comes from the localized saveLoginMsg string. + let notificationText = notif.message; + let expectedText = "Save login for example.com?"; + Assert.equal( + notificationText, + expectedText, + "Checking text: " + notificationText + ); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_checkPSaveText() { + info("Check text on a pass-only notification popup"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_6.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.ok(notif, "got notification popup"); + // Check the text, which comes from the localized saveLoginMsgNoUser string. + let notificationText = notif.message; + let expectedText = "Save password for example.com?"; + Assert.equal( + notificationText, + expectedText, + "Checking text: " + notificationText + ); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_capture2pw0un() { + info( + "Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there are no saved logins." + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_0un.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.ok(notif, "got notification popup"); + await cleanupDoorhanger(notif); + } + ); + + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); +}); + +add_task(async function test_change2pw0unExistingDifferentUP() { + info( + "Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with a username and different password." + ); + + await Services.logins.addLoginAsync(login1B); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_0un.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1B", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login1B); +}); + +add_task(async function test_change2pw0unExistingDifferentP() { + info( + "Check for notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with no username and different password." + ); + + await Services.logins.addLoginAsync(login2B); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_0un.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + await cleanupDoorhanger(notif); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + Services.logins.removeLogin(login2B); +}); + +add_task(async function test_change2pw0unExistingWithSameP() { + info( + "Check for no notification popup when a form with 2 password fields (no username) " + + "is submitted and there is a saved login with a username and the same password." + ); + + await Services.logins.addLoginAsync(login2); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_0un.html", + function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-change"); + Assert.ok(!notif, "checking for no notification popup"); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 2, "Check times used incremented"); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + + Services.logins.removeLogin(login2); +}); + +add_task(async function test_changeUPLoginOnPUpdateForm() { + info("Check for change-password popup, u+p login on password update form."); + await Services.logins.addLoginAsync(login1); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_change_p.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(notif, CHANGE_BUTTON); + + Assert.ok( + !getCaptureDoorhanger("password-change"), + "popup should be gone" + ); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + checkOnlyLoginWasUsedTwice({ justChanged: true }); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(async function test_recipeCaptureFields_NewLogin() { + info( + "Check that we capture the proper fields when a field recipe is in use." + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_1un_1text.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + + // Sanity check, no logins should exist yet. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "Should not have any logins yet"); + + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + clickDoorhangerButton(notif, REMEMBER_BUTTON); + }, + "http://example.org" + ); // The recipe is for example.org + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); +}); + +add_task(async function test_recipeCaptureFields_ExistingLogin() { + info( + "Check that we capture the proper fields when a field recipe is in use " + + "and there is a matching login" + ); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_2pw_1un_1text.html", + function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger("password-save"); + Assert.ok(!notif, "checking for no notification popup"); + }, + "http://example.org" + ); + + checkOnlyLoginWasUsedTwice({ justChanged: false }); + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 2, "Check times used incremented"); + + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_saveUsingEnter() { + async function testWithTextboxSelector(fieldSelector) { + let storageChangedPromise = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == "addLogin" + ); + + info("Waiting for form submit and doorhanger interaction"); + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(notif, "got notification popup"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "Should not have any logins yet" + ); + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + let notificationElement = PopupNotifications.panel.childNodes[0]; + let textbox = notificationElement.querySelector(fieldSelector); + textbox.focus(); + await EventUtils.synthesizeKey("KEY_Enter"); + } + ); + await storageChangedPromise; + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu1", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp1", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); + + Services.logins.removeAllUserFacingLogins(); + } + + await testWithTextboxSelector("#password-notification-password"); + await testWithTextboxSelector("#password-notification-username"); +}); + +add_task(async function test_noShowPasswordOnDismissal() { + info("Check for no Show Password field when the doorhanger is dismissed"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + info("Opening popup"); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + let { panel } = PopupNotifications; + + info("Hiding popup."); + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + await promiseHidden; + + info("Clicking on anchor to reshow popup."); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + await promiseShown; + + let passwordVisiblityToggle = panel.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.equal( + passwordVisiblityToggle.hidden, + true, + "Check that the Show Password field is Hidden" + ); + await cleanupDoorhanger(notif); + } + ); +}); + +add_task(async function test_showPasswordOn1stOpenOfDismissedByDefault() { + info("Show Password toggle when the doorhanger is dismissed by default"); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async function (fieldValues) { + info("Opening popup"); + let notif = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(!notif.dismissed, "doorhanger is not dismissed"); + let { panel } = PopupNotifications; + + info("Hiding popup."); + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + await promiseHidden; + + info("Clicking on anchor to reshow popup."); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + await promiseShown; + + let passwordVisiblityToggle = panel.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.equal( + passwordVisiblityToggle.hidden, + true, + "Check that the Show Password field is Hidden" + ); + await cleanupDoorhanger(notif); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_replace_dismissed_with_visible_while_opening.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_replace_dismissed_with_visible_while_opening.js new file mode 100644 index 0000000000..401e0add1b --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_replace_dismissed_with_visible_while_opening.js @@ -0,0 +1,65 @@ +/** + * Replacing a dismissed doorhanger with a visible one while it's opening. + * + * There are various races between popup notification callbacks to catch with this. + * This can happen in the real world by blurring an edited login field by clicking on the login doorhanger. + */ + +XPCOMUtils.defineLazyServiceGetter( + this, + "prompterSvc", + "@mozilla.org/login-manager/prompter;1", + Ci.nsILoginManagerPrompter +); + +add_task(async function test_replaceDismissedWithVisibleWhileOpening() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com", + }, + async function load(browser) { + info("Show a dismissed save doorhanger"); + prompterSvc.promptToSavePassword( + browser, + LoginTestUtils.testData.formLogin({}), + true, + false, + null + ); + let doorhanger = await waitForDoorhanger(browser, "password-save"); + Assert.ok(doorhanger, "Got doorhanger"); + EventUtils.synthesizeMouseAtCenter(doorhanger.anchorElement, {}); + await BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshowing" + ); + await checkDoorhangerUsernamePassword("the username", "the password"); + info( + "Replace the doorhanger with a non-dismissed one immediately after clicking to open" + ); + prompterSvc.promptToSavePassword( + browser, + LoginTestUtils.testData.formLogin({}), + true, + false, + null + ); + await Promise.race([ + BrowserTestUtils.waitForCondition(() => { + if ( + document.getElementById("password-notification-username").value != + "the username" || + document.getElementById("password-notification-password").value != + "the password" + ) { + return Promise.reject("Field changed to incorrect values"); + } + return false; + }, "See if username/password values change to incorrect values"), + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + new Promise(resolve => setTimeout(resolve, 1000)), + ]); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_save_password.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_save_password.js new file mode 100644 index 0000000000..5401ff31af --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_save_password.js @@ -0,0 +1,159 @@ +/** + * Test that the doorhanger notification for password saving is populated with + * the correct values in various password capture cases. + */ + +const testCases = [ + { + name: "No saved logins, username and password", + username: "username", + password: "password", + expectOutcome: [ + { + username: "username", + password: "password", + }, + ], + }, + { + name: "No saved logins, password with empty username", + username: "", + password: "password", + expectOutcome: [ + { + username: "", + password: "password", + }, + ], + }, + { + name: "Saved login with username, update password", + username: "username", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "username", + password: "newPassword", + }, + ], + }, + { + name: "Saved login with no username, update password", + username: "", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "", + password: "newPassword", + }, + ], + }, + { + name: "Saved login with no username, add username and different password", + oldUsername: "", + username: "username", + oldPassword: "password", + password: "newPassword", + expectOutcome: [ + { + username: "", + password: "password", + }, + { + username: "username", + password: "newPassword", + }, + ], + }, +]; + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + info("testing with: " + JSON.stringify(testData)); + await test_save_change(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function test_save_change(testData) { + let { oldUsername, username, oldPassword, password, expectOutcome } = + testData; + // Add a login for the origin of the form if testing a change notification. + if (oldPassword) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: typeof oldUsername !== "undefined" ? oldUsername : username, + password: oldPassword, + }) + ); + } + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + // Update the form with credentials from the test case. + info(`update form with username: ${username}, password: ${password}`); + await changeContentFormValues(browser, { + "#form-basic-username": username, + "#form-basic-password": password, + }); + + // Submit the form with the new credentials. This will cause the doorhanger + // notification to be displayed. + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + await formSubmittedPromise; + + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification, expectedDoorhanger; + if (oldPassword !== undefined && oldUsername !== undefined) { + expectedNotification = "addLogin"; + expectedDoorhanger = "password-save"; + } else if (oldPassword !== undefined) { + expectedNotification = "modifyLogin"; + expectedDoorhanger = "password-change"; + } else { + expectedNotification = "addLogin"; + expectedDoorhanger = "password-save"; + } + + info("Waiting for doorhanger of type: " + expectedDoorhanger); + let notif = await waitForDoorhanger(browser, expectedDoorhanger); + + // Check the actual content of the popup notification. + await checkDoorhangerUsernamePassword(username, password); + + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + await promiseLogin; + await cleanupDoorhanger(notif); // clean slate for the next test + + // Check that the values in the database match the expected values. + verifyLogins(expectOutcome); + } + ); + + // Clean up the database before the next test case is executed. + Services.logins.removeAllUserFacingLogins(); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_submit_telemetry.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_submit_telemetry.js new file mode 100644 index 0000000000..97353007ac --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_submit_telemetry.js @@ -0,0 +1,387 @@ +/** + * Test that doorhanger submit telemetry is sent when the user saves/updates. + */ + +add_setup(function () { + // This test used to rely on the initial timer of + // TestUtils.waitForCondition. See bug 1695395. + // The test is perma-fail on Linux asan opt without this. + let originalWaitForCondition = TestUtils.waitForCondition; + TestUtils.waitForCondition = async function ( + condition, + msg, + interval = 100, + maxTries = 50 + ) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 100)); + + return originalWaitForCondition(condition, msg, interval, maxTries); + }; + registerCleanupFunction(function () { + TestUtils.waitForCondition = originalWaitForCondition; + }); +}); + +const PAGE_USERNAME_SELECTOR = "#form-basic-username"; +const PAGE_PASSWORD_SELECTOR = "#form-basic-password"; + +const TEST_CASES = [ + { + description: + "Saving a new login from page values without modification sends a 'no modification' event", + savedLogin: undefined, + userActions: [ + { + pageChanges: { + username: "pageUn", + password: "pagePw", + }, + doorhangerChanges: [], + }, + ], + expectedEvents: [ + { + type: "save", + ping: { + did_edit_un: "false", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: "Saving two logins sends two events", + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + typedUsername: "doorhangerUn", + }, + ], + }, + { + pageChanges: { password: "pagePw2" }, + doorhangerChanges: [ + { + typedPassword: "doorhangerPw", + }, + ], + }, + ], + expectedEvents: [ + { + type: "save", + ping: { + did_edit_un: "true", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + { + type: "update", + ping: { + did_edit_un: "false", + did_select_un: "false", + did_edit_pw: "true", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: "Updating a doorhanger password sends a 'pw updated' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + typedPassword: "doorhangerPw", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "false", + did_select_un: "false", + did_edit_pw: "true", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: + "Saving a new username with an existing password sends a 'un updated' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + typedUsername: "doorhangerUn", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "true", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + /////////////// + { + description: "selecting a saved username sends a 'not edited' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + selectUsername: "savedUn", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "false", + did_select_un: "true", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: + "typing a new username then selecting a saved username sends a 'not edited' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + typedUsername: "doorhangerTypedUn", + }, + { + selectUsername: "savedUn", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "false", + did_select_un: "true", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// + { + description: + "selecting a saved username then typing a new username sends an 'edited' event", + savedLogin: { + username: "savedUn", + password: "savedPw", + }, + userActions: [ + { + pageChanges: { password: "pagePw" }, + doorhangerChanges: [ + { + selectUsername: "savedUn", + }, + { + typedUsername: "doorhangerTypedUn", + }, + ], + }, + ], + expectedEvents: [ + { + type: "update", + ping: { + did_edit_un: "true", + did_select_un: "false", + did_edit_pw: "false", + did_select_pw: "false", + }, + }, + ], + }, + ///////////////// +]; + +for (let testData of TEST_CASES) { + let tmp = { + async [testData.description]() { + info("testing with: " + JSON.stringify(testData)); + await test_submit_telemetry(testData); + }, + }; + add_task(tmp[testData.description]); +} + +function _validateTestCase(tc) { + for (let event of tc.expectedEvents) { + Assert.ok( + !(event.ping.did_edit_un && event.ping.did_select_un), + "'did_edit_un' and 'did_select_un' can never be true at the same time" + ); + Assert.ok( + !(event.ping.did_edit_pw && event.ping.did_select_pw), + "'did_edit_pw' and 'did_select_pw' can never be true at the same time" + ); + } +} + +async function test_submit_telemetry(tc) { + if (tc.savedLogin) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: tc.savedLogin.username, + password: tc.savedLogin.password, + }) + ); + } + + let notif; + for (let userAction of tc.userActions) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + if (userAction.pageChanges) { + info( + `Update form with changes: ${JSON.stringify( + userAction.pageChanges + )}` + ); + let changeTo = {}; + if (userAction.pageChanges.username) { + changeTo[PAGE_USERNAME_SELECTOR] = userAction.pageChanges.username; + } + if (userAction.pageChanges.password) { + changeTo[PAGE_PASSWORD_SELECTOR] = userAction.pageChanges.password; + } + + await changeContentFormValues(browser, changeTo); + } + + info("Submitting form"); + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + await formSubmittedPromise; + + let saveDoorhanger = waitForDoorhanger(browser, "password-save"); + let updateDoorhanger = waitForDoorhanger(browser, "password-change"); + notif = await Promise.race([saveDoorhanger, updateDoorhanger]); + + if (PopupNotifications.panel.state !== "open") { + await BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + } + + if (userAction.doorhangerChanges) { + for (let doorhangerChange of userAction.doorhangerChanges) { + if ( + doorhangerChange.typedUsername || + doorhangerChange.typedPassword + ) { + await updateDoorhangerInputValues({ + username: doorhangerChange.typedUsername, + password: doorhangerChange.typedPassword, + }); + } + + if (doorhangerChange.selectUsername) { + await selectDoorhangerUsername(doorhangerChange.selectUsername); + } + if (doorhangerChange.selectPassword) { + await selectDoorhangerPassword(doorhangerChange.selectPassword); + } + } + } + + info("Waiting for doorhanger"); + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + } + ); + } + + let expectedEvents = tc.expectedEvents.map(expectedEvent => [ + "pwmgr", + "doorhanger_submitted", + expectedEvent.type, + null, + expectedEvent.ping, + ]); + + await LoginTestUtils.telemetry.waitForEventCount( + expectedEvents.length, + "parent", + "pwmgr", + "doorhanger_submitted" + ); + TelemetryTestUtils.assertEvents( + expectedEvents, + { category: "pwmgr", method: "doorhanger_submitted" }, + { clear: true } + ); + + // Clean up the database before the next test case is executed. + await cleanupDoorhanger(notif); + Services.logins.removeAllUserFacingLogins(); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_target_blank.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_target_blank.js new file mode 100644 index 0000000000..63ba1fa2a7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_target_blank.js @@ -0,0 +1,94 @@ +/** + * Test capture popup notifications when the login form uses target="_blank" + */ + +add_setup(async function () { + await SimpleTest.promiseFocus(window); +}); + +add_task(async function test_saveTargetBlank() { + // This test submits the form to a new tab using target="_blank". + let url = "subtst_notifications_12_target_blank.html?notifyu3|notifyp3||"; + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + + let submissionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => { + info(url); + return url.includes("formsubmit.sjs"); + }, + false, + true + ); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://mochi.test:8888" + DIRECTORY_PATH + url, + }, + async function () { + // For now the doorhanger appears in the previous tab but it should maybe + // appear in the new tab from target="_blank"? + BrowserTestUtils.removeTab(await submissionTabPromise); + + let notif = await TestUtils.waitForCondition( + () => + getCaptureDoorhangerThatMayOpen( + "password-save", + PopupNotifications, + gBrowser.selectedBrowser + ), + "Waiting for doorhanger" + ); + Assert.ok(notif, "got notification popup"); + + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + await notifShownPromise; + await checkDoorhangerUsernamePassword("notifyu3", "notifyp3"); + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (subject, data) => data != "removeLogin" + ); + + clickDoorhangerButton(notif, REMEMBER_BUTTON); + await storageChangedPromised; + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + ); + + // Check result of clicking Remember + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login now"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu3", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp3", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); + + // Check for stale values in the doorhanger <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..f529369522 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_toggles.js @@ -0,0 +1,478 @@ +/* eslint no-shadow:"off" */ + +const passwordInputSelector = "#form-basic-password"; +const usernameInputSelector = "#form-basic-username"; +const FORM_URL = + "https://example.com/browser/toolkit/components/passwordmgr/test/browser/form_basic.html"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons.visibilityToggle", true]], + }); +}); + +let testCases = [ + { + /* Test that the doorhanger password field shows plain or * text + * when the checkbox is checked. + */ + name: "test_toggle_password", + logins: [], + enabledPrimaryPassword: false, + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pw", + [usernameInputSelector]: "username", + }, + expected: { + initialForm: { + username: "", + password: "", + }, + passwordChangedDoorhanger: null, + submitDoorhanger: { + type: "password-save", + dismissed: false, + username: "username", + password: "pw", + toggleVisible: true, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + afterToggleClick0: { + inputType: "text", + toggleChecked: true, + }, + afterToggleClick1: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, + { + /* Test that the doorhanger password toggle checkbox is disabled + * when the primary password is set. + */ + name: "test_checkbox_disabled_if_has_primary_password", + logins: [], + enabledPrimaryPassword: true, + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "pass", + [usernameInputSelector]: "username", + }, + expected: { + initialForm: { + username: "", + password: "", + }, + passwordChangedDoorhanger: null, + submitDoorhanger: { + type: "password-save", + dismissed: false, + username: "username", + password: "pass", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, + { + /* Test that the reveal password checkbox is hidden when editing the + * password of an autofilled login + */ + name: "test_edit_autofilled_password", + logins: [{ username: "username1", password: "password" }], + formDefaults: {}, + formChanges: { + [passwordInputSelector]: "password!", + }, + expected: { + initialForm: { + username: "username1", + password: "password", + }, + passwordChangedDoorhanger: { + type: "password-change", + dismissed: true, + username: "username1", + password: "password!", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + submitDoorhanger: { + type: "password-change", + dismissed: false, + username: "username1", + password: "password!", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, + { + /* Test that the reveal password checkbox is shown when editing the + * password of a login that has been autofilled and then deleted + */ + name: "test_autofilled_cleared_then_updated_password", + logins: [{ username: "username1", password: "password" }], + formDefaults: {}, + formChanges: [ + { + [passwordInputSelector]: "", + }, + { + [passwordInputSelector]: "password!", + }, + ], + expected: { + initialForm: { + username: "username1", + password: "password", + }, + passwordChangedDoorhanger: { + type: "password-change", + dismissed: true, + username: "username1", + password: "password!", + toggleVisible: true, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + submitDoorhanger: { + type: "password-change", + dismissed: false, + username: "username1", + password: "password!", + toggleVisible: true, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, + { + /* Test that the reveal password checkbox is hidden when editing the + * username of an autofilled login + */ + name: "test_edit_autofilled_username", + logins: [{ username: "username1", password: "password" }], + formDefaults: {}, + formChanges: { + [usernameInputSelector]: "username2", + }, + expected: { + initialForm: { + username: "username1", + password: "password", + }, + passwordChangedDoorhanger: { + type: "password-save", + dismissed: true, + username: "username2", + password: "password", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + submitDoorhanger: { + type: "password-save", + dismissed: false, + username: "username2", + password: "password", + toggleVisible: false, + initialToggleState: { + inputType: "password", + toggleChecked: false, + }, + }, + }, + }, +]; + +for (let testData of testCases) { + if (testData.skip) { + info("Skipping test:", testData.name); + continue; + } + let tmp = { + async [testData.name]() { + await testDoorhangerToggles(testData); + }, + }; + add_task(tmp[testData.name]); +} + +/** + * Set initial test conditions, + * Load and populate the form, + * Submit it and verify doorhanger toggle behavior + */ +async function testDoorhangerToggles({ + logins = [], + formDefaults = {}, + formChanges = {}, + expected, + enabledPrimaryPassword, +}) { + formChanges = Array.isArray(formChanges) ? formChanges : [formChanges]; + + for (let login of logins) { + await LoginTestUtils.addLogin(login); + } + if (enabledPrimaryPassword) { + LoginTestUtils.primaryPassword.enable(); + } + let formProcessedPromise = listenForTestNotification("FormProcessed"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: FORM_URL, + }, + async function (browser) { + info(`Opened tab with url: ${FORM_URL}, waiting for focus`); + await SimpleTest.promiseFocus(browser.ownerGlobal); + info("Waiting for form-processed message"); + await formProcessedPromise; + await initForm(browser, formDefaults); + await checkForm(browser, expected.initialForm); + info("form checked"); + + // some tests check the dismissed doorhanger from editing the password + let formChanged = expected.passwordChangedDoorhanger + ? listenForTestNotification("PasswordEditedOrGenerated") + : Promise.resolve(); + for (let change of formChanges) { + await changeContentFormValues(browser, change, { + method: "paste_text", + }); + } + + await formChanged; + + if (expected.passwordChangedDoorhanger) { + let expectedDoorhanger = expected.passwordChangedDoorhanger; + info("Verifying dismissed doorhanger from password change"); + let notif = await waitForDoorhanger(browser, expectedDoorhanger.type); + Assert.ok(notif, "got notification popup"); + Assert.equal( + notif.dismissed, + expectedDoorhanger.dismissed, + "Check notification dismissed property" + ); + let { panel } = browser.ownerGlobal.PopupNotifications; + // we will open dismissed doorhanger to check panel contents + Assert.equal(panel.state, "closed", "Panel is initially closed"); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + info("Opening the doorhanger popup"); + // synthesize click on anchor as this also blurs the form field triggering + // a change event + EventUtils.synthesizeMouseAtCenter(notif.anchorElement, {}); + await promiseShown; + await TestUtils.waitForTick(); + Assert.ok( + panel.children.length, + `Check the popup has at least one notification (${panel.children.length})` + ); + + // Check the password-changed-capture doorhanger contents & behaviour + info("Verifying the doorhanger"); + await verifyDoorhangerToggles(browser, notif, expectedDoorhanger); + await hideDoorhangerPopup(notif); + } + + if (expected.submitDoorhanger) { + let expectedDoorhanger = expected.submitDoorhanger; + let { panel } = browser.ownerGlobal.PopupNotifications; + // submit the form and wait for the doorhanger + info("Submitting the form"); + let submittedPromise = listenForTestNotification("ShowDoorhanger"); + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + await submitForm(browser, "/"); + await submittedPromise; + info("Waiting for doorhanger popup to open"); + await promiseShown; + let notif = await getCaptureDoorhanger(expectedDoorhanger.type); + Assert.ok(notif, "got notification popup"); + Assert.equal( + notif.dismissed, + expectedDoorhanger.dismissed, + "Check notification dismissed property" + ); + Assert.ok( + panel.children.length, + `Check the popup has at least one notification (${panel.children.length})` + ); + + // Check the submit-capture doorhanger contents & behaviour + info("Verifying the submit doorhanger"); + await verifyDoorhangerToggles(browser, notif, expectedDoorhanger); + await cleanupDoorhanger(notif); + } + } + ); + await LoginTestUtils.clearData(); + if (enabledPrimaryPassword) { + LoginTestUtils.primaryPassword.disable(); + } + await cleanupPasswordNotifications(); +} + +// -------------------------------------------------------------------- +// Helpers + +async function verifyDoorhangerToggles(browser, notif, expected) { + let { initialToggleState, afterToggleClick0, afterToggleClick1 } = expected; + + let { panel } = browser.ownerGlobal.PopupNotifications; + let notificationElement = panel.childNodes[0]; + let passwordTextbox = notificationElement.querySelector( + "#password-notification-password" + ); + let toggleCheckbox = notificationElement.querySelector( + "#password-notification-visibilityToggle" + ); + Assert.equal(panel.state, "open", "Panel is open"); + Assert.ok( + BrowserTestUtils.is_visible(passwordTextbox), + "The doorhanger password field is visible" + ); + + await checkDoorhangerUsernamePassword(expected.username, expected.password); + if (expected.toggleVisible) { + Assert.ok( + BrowserTestUtils.is_visible(toggleCheckbox), + "The visibility checkbox is shown" + ); + } else { + Assert.ok( + BrowserTestUtils.is_hidden(toggleCheckbox), + "The visibility checkbox is hidden" + ); + } + + if (initialToggleState) { + Assert.equal( + toggleCheckbox.checked, + initialToggleState.toggleChecked, + `Initially, toggle is ${ + initialToggleState.toggleChecked ? "checked" : "unchecked" + }` + ); + Assert.equal( + passwordTextbox.type, + initialToggleState.inputType, + `Initially, password input has type: ${initialToggleState.inputType}` + ); + } + if (afterToggleClick0) { + Assert.ok( + !toggleCheckbox.hidden, + "The checkbox shouldnt be hidden when clicking on it" + ); + info("Clicking on the visibility toggle"); + await EventUtils.synthesizeMouseAtCenter(toggleCheckbox, {}); + await TestUtils.waitForTick(); + Assert.equal( + toggleCheckbox.checked, + afterToggleClick0.toggleChecked, + `After 1st click, expect toggle to be checked? ${afterToggleClick0.toggleChecked}, actual: ${toggleCheckbox.checked}` + ); + Assert.equal( + passwordTextbox.type, + afterToggleClick0.inputType, + `After 1st click, expect password input to have type: ${afterToggleClick0.inputType}` + ); + } + if (afterToggleClick1) { + Assert.ok( + !toggleCheckbox.hidden, + "The checkbox shouldnt be hidden when clicking on it" + ); + info("Clicking on the visibility toggle again"); + await EventUtils.synthesizeMouseAtCenter(toggleCheckbox, {}); + await TestUtils.waitForTick(); + Assert.equal( + toggleCheckbox.checked, + afterToggleClick1.toggleChecked, + `After 2nd click, expect toggle to be checked? ${afterToggleClick0.toggleChecked}, actual: ${toggleCheckbox.checked}` + ); + Assert.equal( + passwordTextbox.type, + afterToggleClick1.inputType, + `After 2nd click, expect password input to have type: ${afterToggleClick1.inputType}` + ); + } +} + +async function initForm(browser, formDefaults) { + await ContentTask.spawn( + browser, + formDefaults, + async function (selectorValues = {}) { + for (let [sel, value] of Object.entries(selectorValues)) { + content.document.querySelector(sel).value = value; + } + } + ); +} + +async function checkForm(browser, expected) { + await ContentTask.spawn( + browser, + { + [passwordInputSelector]: expected.password, + [usernameInputSelector]: expected.username, + }, + async function contentCheckForm(selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + let field = content.document.querySelector(sel); + Assert.equal( + field.value, + value, + sel + " has the expected initial value" + ); + } + } + ); +} + +async function submitForm(browser, action = "") { + // Submit the form + let correctPathNamePromise = BrowserTestUtils.browserLoaded(browser); + await SpecialPowers.spawn(browser, [action], async function (actionPathname) { + let form = content.document.querySelector("form"); + if (actionPathname) { + form.action = actionPathname; + } + info("Submitting form to:" + form.action); + form.submit(); + info("Submitted the form"); + }); + await correctPathNamePromise; + await SpecialPowers.spawn(browser, [action], async actionPathname => { + let win = content; + await ContentTaskUtils.waitForCondition(() => { + return ( + win.location.pathname == actionPathname && + win.document.readyState == "complete" + ); + }, "Wait for form submission load"); + }); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_username_edits.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_username_edits.js new file mode 100644 index 0000000000..79dd92db12 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_username_edits.js @@ -0,0 +1,192 @@ +/** + * Test changing the username inside the doorhanger notification for passwords. + * + * We have to test combination of existing and non-existing logins both for + * the original one from the webpage and the final one used by the dialog. + * + * We also check switching to and from empty usernames. + */ +add_task(async function test_edit_username() { + let testCases = [ + { + usernameInPage: "username", + usernameChangedTo: "newUsername", + }, + { + usernameInPage: "username", + usernameInPageExists: true, + usernameChangedTo: "newUsername", + }, + { + usernameInPage: "username", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, + { + usernameInPage: "username", + usernameInPageExists: true, + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, + { + usernameInPage: "", + usernameChangedTo: "newUsername", + }, + { + usernameInPage: "newUsername", + usernameChangedTo: "", + }, + { + usernameInPage: "", + usernameChangedTo: "newUsername", + usernameChangedToExists: true, + }, + { + usernameInPage: "newUsername", + usernameChangedTo: "", + usernameChangedToExists: true, + }, + ]; + + for (let testCase of testCases) { + info("Test case: " + JSON.stringify(testCase)); + // Clean state before the test case is executed. + await LoginTestUtils.clearData(); + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + + // Create the pre-existing logins when needed. + if (testCase.usernameInPageExists) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: testCase.usernameInPage, + password: "old password", + }) + ); + } + + if (testCase.usernameChangedToExists) { + await Services.logins.addLoginAsync( + LoginTestUtils.testData.formLogin({ + origin: "https://example.com", + formActionOrigin: "https://example.com", + username: testCase.usernameChangedTo, + password: "old password", + }) + ); + } + + let formFilledPromise = listenForTestNotification("FormProcessed"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await formFilledPromise; + await initForm(browser, { + "#form-basic-username": testCase.usernameInPage, + }); + + let passwordEditedPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + info("Editing the form"); + await changeContentFormValues(browser, { + "#form-basic-password": "password", + }); + info("Waiting for passwordEditedPromise"); + await passwordEditedPromise; + + // reset doorhanger/notifications, we're only interested in the submit outcome + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + // reset message cache, we're only interested in the submit outcome + await clearMessageCache(browser); + + // Submit the form in the content page with the credentials from the test + // case. This will cause the doorhanger notification to be displayed. + info("Submitting the form"); + let formSubmittedPromise = listenForTestNotification("ShowDoorhanger"); + let promiseShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown", + event => event.target == PopupNotifications.panel + ); + await SpecialPowers.spawn(browser, [], async function () { + content.document.getElementById("form-basic").submit(); + }); + info("Waiting for the submit message"); + await formSubmittedPromise; + + info("Waiting for the doorhanger"); + let notif = await waitForDoorhanger(browser, "any"); + Assert.ok(!notif.dismissed, "Doorhanger is not dismissed"); + await promiseShown; + + // Modify the username in the dialog if requested. + if (testCase.usernameChangedTo !== undefined) { + await updateDoorhangerInputValues({ + username: testCase.usernameChangedTo, + }); + } + + // We expect a modifyLogin notification if the final username used by the + // dialog exists in the logins database, otherwise an addLogin one. + let expectModifyLogin = + testCase.usernameChangedTo !== undefined + ? testCase.usernameChangedToExists + : testCase.usernameInPageExists; + // Simulate the action on the notification to request the login to be + // saved, and wait for the data to be updated or saved based on the type + // of operation we expect. + let expectedNotification = expectModifyLogin + ? "modifyLogin" + : "addLogin"; + let promiseLogin = TestUtils.topicObserved( + "passwordmgr-storage-changed", + (_, data) => data == expectedNotification + ); + let promiseHidden = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popuphidden" + ); + clickDoorhangerButton(notif, CHANGE_BUTTON); + await promiseHidden; + info("Waiting for storage changed"); + let [result] = await promiseLogin; + + // Check that the values in the database match the expected values. + let login = expectModifyLogin + ? result + .QueryInterface(Ci.nsIArray) + .queryElementAt(1, Ci.nsILoginInfo) + : result.QueryInterface(Ci.nsILoginInfo); + Assert.equal( + login.username, + testCase.usernameChangedTo !== undefined + ? testCase.usernameChangedTo + : testCase.usernameInPage + ); + Assert.equal(login.password, "password"); + } + ); + } +}); + +async function initForm(browser, formDefaults = {}) { + await ContentTask.spawn( + browser, + formDefaults, + async function (selectorValues) { + for (let [sel, value] of Object.entries(selectorValues)) { + content.document.querySelector(sel).value = value; + } + } + ); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_doorhanger_window_open.js b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_window_open.js new file mode 100644 index 0000000000..2375a3c53a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_doorhanger_window_open.js @@ -0,0 +1,201 @@ +/* + * Test capture popup notifications in content opened by window.open + */ + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login1 = new nsLoginInfo( + "http://mochi.test:8888", + "http://mochi.test:8888", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +let login2 = new nsLoginInfo( + "http://mochi.test:8888", + "http://mochi.test:8888", + null, + "notifyu2", + "notifyp2", + "user", + "pass" +); + +function withTestTabUntilStorageChange(aPageFile, aTaskFn) { + function storageChangedObserved(subject, data) { + // Watch for actions triggered from a doorhanger (not cleanup tasks with removeLogin) + if (data == "removeLogin") { + return false; + } + return true; + } + + let storageChangedPromised = TestUtils.topicObserved( + "passwordmgr-storage-changed", + storageChangedObserved + ); + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://mochi.test:8888" + DIRECTORY_PATH + aPageFile, + }, + async function (browser) { + Assert.ok(true, "loaded " + aPageFile); + info("running test case task"); + await aTaskFn(); + info("waiting for storage change"); + await storageChangedPromised; + } + ); +} + +add_setup(async function () { + await SimpleTest.promiseFocus(window); +}); + +add_task(async function test_saveChromeHiddenAutoClose() { + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + // query arguments are: username, password, features, auto-close (delimited by '|') + let url = + "subtst_notifications_11.html?notifyu1|notifyp1|" + + "menubar=no,toolbar=no,location=no|autoclose"; + await withTestTabUntilStorageChange(url, async function () { + info("waiting for popupshown"); + await notifShownPromise; + // the popup closes and the doorhanger should appear in the opener + let popup = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(popup, "got notification popup"); + await checkDoorhangerUsernamePassword("notifyu1", "notifyp1"); + // Sanity check, no logins should exist yet. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "Should not have any logins yet"); + + clickDoorhangerButton(popup, REMEMBER_BUTTON); + }); + // Check result of clicking Remember + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); + Assert.equal( + login.username, + "notifyu1", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp1", + "Check the password used on the new entry" + ); +}); + +add_task(async function test_changeChromeHiddenAutoClose() { + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + let url = + "subtst_notifications_11.html?notifyu1|pass2|menubar=no,toolbar=no,location=no|autoclose"; + await withTestTabUntilStorageChange(url, async function () { + info("waiting for popupshown"); + await notifShownPromise; + let popup = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(popup, "got notification popup"); + await checkDoorhangerUsernamePassword("notifyu1", "pass2"); + clickDoorhangerButton(popup, CHANGE_BUTTON); + }); + + // Check to make sure we updated the password, timestamps and use count for + // the login being changed with this form. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username"); + Assert.equal(login.password, "pass2", "Check password changed"); + Assert.equal(login.timesUsed, 2, "check .timesUsed incremented on change"); + Assert.ok(login.timeCreated < login.timeLastUsed, "timeLastUsed bumped"); + Assert.ok( + login.timeLastUsed == login.timePasswordChanged, + "timeUsed == timeChanged" + ); + + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; +}); + +add_task(async function test_saveChromeVisibleSameWindow() { + // This test actually opens a new tab in the same window with default browser settings. + let url = "subtst_notifications_11.html?notifyu2|notifyp2||"; + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await withTestTabUntilStorageChange(url, async function () { + await notifShownPromise; + let popup = await getCaptureDoorhangerThatMayOpen("password-save"); + Assert.ok(popup, "got notification popup"); + await checkDoorhangerUsernamePassword("notifyu2", "notifyp2"); + clickDoorhangerButton(popup, REMEMBER_BUTTON); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); + + // Check result of clicking Remember + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login now"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal( + login.username, + "notifyu2", + "Check the username used on the new entry" + ); + Assert.equal( + login.password, + "notifyp2", + "Check the password used on the new entry" + ); + Assert.equal(login.timesUsed, 1, "Check times used on new entry"); +}); + +add_task(async function test_changeChromeVisibleSameWindow() { + let url = "subtst_notifications_11.html?notifyu2|pass2||"; + let notifShownPromise = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await withTestTabUntilStorageChange(url, async function () { + await notifShownPromise; + let popup = await getCaptureDoorhangerThatMayOpen("password-change"); + Assert.ok(popup, "got notification popup"); + await checkDoorhangerUsernamePassword("notifyu2", "pass2"); + clickDoorhangerButton(popup, CHANGE_BUTTON); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); + + // Check to make sure we updated the password, timestamps and use count for + // the login being changed with this form. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should have 1 login"); + let login = logins[0].QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu2", "Check the username"); + Assert.equal(login.password, "pass2", "Check password changed"); + Assert.equal(login.timesUsed, 2, "check .timesUsed incremented on change"); + Assert.ok(login.timeCreated < login.timeLastUsed, "timeLastUsed bumped"); + Assert.ok( + login.timeLastUsed == login.timePasswordChanged, + "timeUsed == timeChanged" + ); + + // cleanup + login2.password = "pass2"; + Services.logins.removeLogin(login2); + login2.password = "notifyp2"; +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_entry_point_telemetry.js b/toolkit/components/passwordmgr/test/browser/browser_entry_point_telemetry.js new file mode 100644 index 0000000000..aabf2cf63a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_entry_point_telemetry.js @@ -0,0 +1,103 @@ +const TEST_ORIGIN = "https://example.com"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons.visibilityToggle", true]], + }); + Services.telemetry.clearEvents(); +}); + +add_task(async function mainMenu_entryPoint() { + await SimpleTest.promiseFocus(); + info("mainMenu_entryPoint, got focus"); + + let mainMenu = document.getElementById("appMenu-popup"); + let target = document.getElementById("PanelUI-menu-button"); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(target), + "Main menu button should be visible." + ); + info("mainMenu_entryPoint, Main menu button is visible"); + Assert.equal( + mainMenu.state, + "closed", + `Menu panel (${mainMenu.id}) is initally closed.` + ); + + info("mainMenu_entryPoint, clicking target and waiting for popup"); + let popupshown = BrowserTestUtils.waitForEvent(mainMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(target, {}); + await popupshown; + + info("mainMenu_entryPoint, main menu popup is shown"); + Assert.equal(mainMenu.state, "open", `Menu panel (${mainMenu.id}) is open.`); + + let loginsButtonID = "appMenu-passwords-button"; + + let item = document.getElementById(loginsButtonID); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(item), + "Logins and passwords button is visible." + ); + + info("mainMenu_entryPoint, clicking on Logins and passwords button"); + let openingFunc = () => EventUtils.synthesizeMouseAtCenter(item, {}); + let passwordManager = await openPasswordManager(openingFunc); + info("mainMenu_entryPoint, password manager dialog shown"); + + await LoginTestUtils.telemetry.waitForEventCount(1); + TelemetryTestUtils.assertEvents( + [["pwmgr", "open_management", "mainmenu"]], + { + category: "pwmgr", + method: "open_management", + }, + { clear: true, process: "content" } + ); + + info("mainMenu_entryPoint, close dialog and main menu"); + await passwordManager.close(); + mainMenu.hidePopup(); +}); + +add_task(async function pageInfo_entryPoint() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ORIGIN, + }, + async function (browser) { + info("pageInfo_entryPoint, opening pageinfo"); + let pageInfo = BrowserPageInfo(TEST_ORIGIN, "securityTab", {}); + await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init"); + info( + "pageInfo_entryPoint, got pageinfo, wait until password button is visible" + ); + let passwordsButton = pageInfo.document.getElementById( + "security-view-password" + ); + + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(passwordsButton), + "Show passwords button should be visible." + ); + info("pageInfo_entryPoint, clicking the show passwords button..."); + await SimpleTest.promiseFocus(pageInfo); + let openingFunc = () => + EventUtils.synthesizeMouseAtCenter(passwordsButton, {}, pageInfo); + + info("pageInfo_entryPoint, waiting for the passwords manager dialog"); + let passwordManager = await openPasswordManager(openingFunc); + + TelemetryTestUtils.assertEvents( + [["pwmgr", "open_management", "pageinfo"]], + { category: "pwmgr", method: "open_management" }, + { clear: true, process: "content" } + ); + + info("pageInfo_entryPoint, close dialog and pageInfo"); + await passwordManager.close(); + pageInfo.close(); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js b/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js new file mode 100644 index 0000000000..854f0656b8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_exceptions_dialog.js @@ -0,0 +1,141 @@ +"use strict"; + +const LOGIN_HOST = "https://example.com"; + +function openExceptionsDialog() { + return window.openDialog( + "chrome://browser/content/preferences/dialogs/permissions.xhtml", + "Toolkit:PasswordManagerExceptions", + "", + { + blockVisible: true, + sessionVisible: false, + allowVisible: false, + hideStatusColumn: true, + prefilledHost: "", + permissionType: "login-saving", + } + ); +} + +function countDisabledHosts(dialog) { + return dialog.document.getElementById("permissionsBox").itemCount; +} + +function promiseStorageChanged(expectedData) { + function observer(subject, data) { + return ( + data == expectedData && + subject.QueryInterface(Ci.nsISupportsString).data == LOGIN_HOST + ); + } + + return TestUtils.topicObserved("passwordmgr-storage-changed", observer); +} + +add_task(async function test_disable() { + let dialog = openExceptionsDialog(); + let promiseChanged = promiseStorageChanged("hostSavingDisabled"); + + await BrowserTestUtils.waitForEvent(dialog, "load"); + await new Promise(resolve => { + waitForFocus(resolve, dialog); + }); + Services.logins.setLoginSavingEnabled(LOGIN_HOST, false); + await promiseChanged; + Assert.equal(countDisabledHosts(dialog), 1, "Verify disabled host added"); + await BrowserTestUtils.closeWindow(dialog); +}); + +add_task(async function test_enable() { + let dialog = openExceptionsDialog(); + let promiseChanged = promiseStorageChanged("hostSavingEnabled"); + + await BrowserTestUtils.waitForEvent(dialog, "load"); + await new Promise(resolve => { + waitForFocus(resolve, dialog); + }); + Services.logins.setLoginSavingEnabled(LOGIN_HOST, true); + await promiseChanged; + Assert.equal(countDisabledHosts(dialog), 0, "Verify disabled host removed"); + await BrowserTestUtils.closeWindow(dialog); +}); + +add_task(async function test_block_button_with_enter_key() { + // Test ensures that the Enter/Return key does not activate the "Allow" button + // in the "Saved Logins" exceptions dialog + + let dialog = openExceptionsDialog(); + + await BrowserTestUtils.waitForEvent(dialog, "load"); + await new Promise(resolve => waitForFocus(resolve, dialog)); + let btnBlock = dialog.document.getElementById("btnBlock"); + let btnCookieSession = dialog.document.getElementById("btnCookieSession"); + let btnHttpsOnlyOff = dialog.document.getElementById("btnHttpsOnlyOff"); + let btnHttpsOnlyOffTmp = dialog.document.getElementById("btnHttpsOnlyOffTmp"); + let btnAllow = dialog.document.getElementById("btnAllow"); + + Assert.ok(!btnBlock.hidden, "Block button is visible"); + Assert.ok(btnCookieSession.hidden, "Cookie session button is not visible"); + Assert.ok(btnAllow.hidden, "Allow button is not visible"); + Assert.ok(btnHttpsOnlyOff.hidden, "HTTPS-Only session button is not visible"); + Assert.ok( + btnHttpsOnlyOffTmp.hidden, + "HTTPS-Only session button is not visible" + ); + Assert.ok(btnBlock.disabled, "Block button is initially disabled"); + Assert.ok( + btnCookieSession.disabled, + "Cookie session button is initially disabled" + ); + Assert.ok(btnAllow.disabled, "Allow button is initially disabled"); + Assert.ok( + btnHttpsOnlyOff.disabled, + "HTTPS-Only off-button is initially disabled" + ); + Assert.ok( + btnHttpsOnlyOffTmp.disabled, + "HTTPS-Only temporary off-button is initially disabled" + ); + + EventUtils.sendString(LOGIN_HOST, dialog); + + Assert.ok( + !btnBlock.disabled, + "Block button is enabled after entering text in the URL input" + ); + Assert.ok( + btnCookieSession.disabled, + "Cookie session button is still disabled after entering text in the URL input" + ); + Assert.ok( + btnAllow.disabled, + "Allow button is still disabled after entering text in the URL input" + ); + Assert.ok( + btnHttpsOnlyOff.disabled, + "HTTPS-Only off-button is still disabled after entering text in the URL input" + ); + Assert.ok( + btnHttpsOnlyOffTmp.disabled, + "HTTPS-Only session off-button is still disabled after entering text in the URL input" + ); + + Assert.equal( + countDisabledHosts(dialog), + 0, + "No blocked hosts should be present before hitting the Enter/Return key" + ); + EventUtils.sendKey("return", dialog); + + Assert.equal( + countDisabledHosts(dialog), + 1, + "Verify the blocked host was added" + ); + Assert.ok( + btnBlock.disabled, + "Block button is disabled after submitting to the list" + ); + await BrowserTestUtils.closeWindow(dialog); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_fileURIOrigin.js b/toolkit/components/passwordmgr/test/browser/browser_fileURIOrigin.js new file mode 100644 index 0000000000..ae8c860792 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_fileURIOrigin.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getDataFromNextSubmitMessage() { + return new Promise(resolve => { + LoginManagerParent.setListenerForTests((msg, data) => { + if (msg == "ShowDoorhanger") { + resolve(data); + } + }); + }); +} + +add_task(async function testCrossOriginFormUsesCorrectOrigin() { + const registry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( + Ci.nsIChromeRegistry + ); + + let dataPromise = getDataFromNextSubmitMessage(); + + let url = + registry.convertChromeURL(Services.io.newURI(getRootDirectory(gTestPath))) + .asciiSpec + "form_basic.html"; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + await SpecialPowers.spawn(browser.browsingContext, [], () => { + let doc = content.document; + doc.getElementById("form-basic-username").setUserInput("username"); + doc.getElementById("form-basic-password").setUserInput("password"); + doc.getElementById("form-basic").submit(); + info("Submitting form"); + }); + } + ); + + let data = await dataPromise; + info("Origin retrieved from message listener"); + + Assert.equal( + data.origin, + "file://", + "Message origin should match form origin" + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_focus_before_first_DOMContentLoaded.js b/toolkit/components/passwordmgr/test/browser/browser_focus_before_first_DOMContentLoaded.js new file mode 100644 index 0000000000..783f8ea3d8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_focus_before_first_DOMContentLoaded.js @@ -0,0 +1,103 @@ +/** + * Test that autocomplete is properly attached to a username field which gets + * focused before DOMContentLoaded in a new browser and process. + */ + +"use strict"; + +add_setup(async () => { + let nsLoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); + Assert.ok(nsLoginInfo != null, "nsLoginInfo constructor"); + + info("Adding two logins to get autocomplete instead of autofill"); + let login1 = new nsLoginInfo( + "https://example.com", + "https://autocomplete:8888", + null, + "tempuser1", + "temppass1" + ); + + let login2 = new nsLoginInfo( + "https://example.com", + "https://autocomplete:8888", + null, + "testuser2", + "testpass2" + ); + + await Services.logins.addLogins([login1, login2]); +}); + +add_task(async function test_autocompleteFromUsername() { + let autocompletePopup = document.getElementById("PopupAutoComplete"); + let autocompletePopupShown = BrowserTestUtils.waitForEvent( + autocompletePopup, + "popupshown" + ); + + const URL = `https://example.com${DIRECTORY_PATH}file_focus_before_DOMContentLoaded.sjs`; + + let newTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: URL, + forceNewProcess: true, + }); + + await SpecialPowers.spawn( + newTab.linkedBrowser, + [], + function checkInitialValues() { + let doc = content.document; + let uname = doc.querySelector("#uname"); + let pword = doc.querySelector("#pword"); + + Assert.ok(uname, "Username field found"); + Assert.ok(pword, "Password field found"); + + Assert.equal( + doc.activeElement, + uname, + "#uname element should be focused" + ); + Assert.equal(uname.value, "", "Checking username is empty"); + Assert.equal(pword.value, "", "Checking password is empty"); + } + ); + + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, newTab.linkedBrowser); + await autocompletePopupShown; + + let richlistbox = autocompletePopup.richlistbox; + Assert.equal( + richlistbox.localName, + "richlistbox", + "The richlistbox should be the first anonymous node" + ); + for (let i = 0; i < autocompletePopup.view.matchCount; i++) { + if ( + richlistbox.selectedItem && + richlistbox.selectedItem.textContent.includes("tempuser1") + ) { + break; + } + await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, newTab.linkedBrowser); + } + + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, newTab.linkedBrowser); + + await SpecialPowers.spawn(newTab.linkedBrowser, [], function checkFill() { + let doc = content.document; + let uname = doc.querySelector("#uname"); + let pword = doc.querySelector("#pword"); + + Assert.equal(uname.value, "tempuser1", "Checking username is filled"); + Assert.equal(pword.value, "temppass1", "Checking password is filled"); + }); + + BrowserTestUtils.removeTab(newTab); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js b/toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js new file mode 100644 index 0000000000..ad76e5479c --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_form_history_fallback.js @@ -0,0 +1,65 @@ +const { FormHistoryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FormHistoryTestUtils.sys.mjs" +); + +const usernameFieldName = "user"; + +async function cleanup() { + Services.prefs.clearUserPref("signon.rememberSignons"); + Services.logins.removeAllLogins(); + await FormHistoryTestUtils.clear(usernameFieldName); +} + +add_setup(async function () { + await cleanup(); +}); + +add_task( + async function test_username_not_saved_in_form_history_when_password_manager_enabled() { + Services.prefs.setBoolPref("signon.rememberSignons", true); + + await testSubmittingLoginFormHTTP( + "subtst_notifications_1.html", + async () => { + const notif = await getCaptureDoorhangerThatMayOpen("password-save"); + await clickDoorhangerButton(notif, REMEMBER_BUTTON); + } + ); + + const loginEntries = Services.logins.getAllLogins().length; + const historyEntries = await FormHistoryTestUtils.count(usernameFieldName); + + Assert.equal( + loginEntries, + 1, + "Username should be saved in password manager" + ); + + Assert.equal( + historyEntries, + 0, + "Username should not be saved in form history" + ); + await cleanup(); + } +); + +add_task( + async function test_username_saved_in_form_history_when_password_manager_disabled() { + Services.prefs.setBoolPref("signon.rememberSignons", false); + + await testSubmittingLoginFormHTTP("subtst_notifications_1.html"); + + const loginEntries = Services.logins.getAllLogins().length; + const historyEntries = await FormHistoryTestUtils.count(usernameFieldName); + + Assert.equal( + loginEntries, + 0, + "Username should not be saved in password manager" + ); + + Assert.equal(historyEntries, 1, "Username should be saved in form history"); + await cleanup(); + } +); diff --git a/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js b/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js new file mode 100644 index 0000000000..212b5f79d8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_formless_submit_chrome.js @@ -0,0 +1,161 @@ +/* + * Test that browser chrome UI interactions don't trigger a capture doorhanger. + */ + +"use strict"; + +async function fillTestPage( + aBrowser, + username = "my_username", + password = "my_password" +) { + let notif = getCaptureDoorhanger("any", undefined, aBrowser); + Assert.ok(!notif, "No doorhangers should be present before filling the form"); + + await changeContentFormValues(aBrowser, { + "#form-basic-username": username, + "#form-basic-password": password, + }); + if (LoginHelper.passwordEditCaptureEnabled) { + // Filling the password will generate a dismissed doorhanger. + // Check and remove that before running the rest of the task + notif = await waitForDoorhanger(aBrowser, "any"); + Assert.ok(notif.dismissed, "Only a dismissed doorhanger should be present"); + await cleanupDoorhanger(notif); + } +} + +function withTestPage(aTaskFn) { + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: "https://example.com" + DIRECTORY_PATH + "formless_basic.html", + }, + async function (aBrowser) { + info("tab opened"); + await fillTestPage(aBrowser); + await aTaskFn(aBrowser); + + // Give a chance for the doorhanger to appear + await new Promise(resolve => SimpleTest.executeSoon(resolve)); + let notif = getCaptureDoorhanger("any"); + Assert.ok(!notif, "No doorhanger should be present"); + await cleanupDoorhanger(notif); + } + ); +} + +add_setup(async function () { + await SimpleTest.promiseFocus(window); +}); + +add_task(async function test_urlbar_new_URL() { + await withTestPage(async function (aBrowser) { + gURLBar.value = ""; + let focusPromise = BrowserTestUtils.waitForEvent(gURLBar, "focus"); + gURLBar.focus(); + await focusPromise; + info("focused"); + EventUtils.sendString("http://mochi.test:8888/"); + EventUtils.synthesizeKey("KEY_Enter"); + await BrowserTestUtils.browserLoaded( + aBrowser, + false, + "http://mochi.test:8888/" + ); + }); +}); + +add_task(async function test_urlbar_fragment_enter() { + await withTestPage(function (aBrowser) { + gURLBar.focus(); + gURLBar.select(); + EventUtils.synthesizeKey("KEY_ArrowRight"); + EventUtils.sendString("#fragment"); + EventUtils.synthesizeKey("KEY_Enter"); + }); +}); + +add_task(async function test_backButton_forwardButton() { + await withTestPage(async function (aBrowser) { + info("Loading formless_basic.html?second"); + // Load a new page in the tab so we can test going back + BrowserTestUtils.loadURIString( + aBrowser, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html?second" + ); + await BrowserTestUtils.browserLoaded( + aBrowser, + false, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html?second" + ); + info("Loaded formless_basic.html?second"); + await fillTestPage(aBrowser, "my_username", "password_2"); + + info("formless_basic.html?second form is filled, clicking back"); + let backPromise = BrowserTestUtils.browserStopped(aBrowser); + EventUtils.synthesizeMouseAtCenter( + document.getElementById("back-button"), + {} + ); + await backPromise; + + // Give a chance for the doorhanger to appear + await new Promise(resolve => SimpleTest.executeSoon(resolve)); + Assert.ok(!getCaptureDoorhanger("any"), "No doorhanger should be present"); + + // Now go forward again after filling + await fillTestPage(aBrowser, "my_username", "password_3"); + + let forwardButton = document.getElementById("forward-button"); + await BrowserTestUtils.waitForCondition(() => { + return !forwardButton.disabled; + }); + let forwardPromise = BrowserTestUtils.browserStopped(aBrowser); + info("click the forward button"); + EventUtils.synthesizeMouseAtCenter(forwardButton, {}); + await forwardPromise; + info("done"); + }); +}); + +add_task(async function test_reloadButton() { + await withTestPage(async function (aBrowser) { + let reloadButton = document.getElementById("reload-button"); + let loadPromise = BrowserTestUtils.browserLoaded( + aBrowser, + false, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html" + ); + + await BrowserTestUtils.waitForCondition(() => { + return !reloadButton.disabled; + }); + EventUtils.synthesizeMouseAtCenter(reloadButton, {}); + await loadPromise; + }); +}); + +add_task(async function test_back_keyboard_shortcut() { + await withTestPage(async function (aBrowser) { + // Load a new page in the tab so we can test going back + BrowserTestUtils.loadURIString( + aBrowser, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html?second" + ); + await BrowserTestUtils.browserLoaded( + aBrowser, + false, + "https://example.com" + DIRECTORY_PATH + "formless_basic.html?second" + ); + await fillTestPage(aBrowser); + + let backPromise = BrowserTestUtils.browserStopped(aBrowser); + + const goBackKeyModifier = + AppConstants.platform == "macosx" ? { metaKey: true } : { altKey: true }; + EventUtils.synthesizeKey("KEY_ArrowLeft", goBackKeyModifier); + + await backPromise; + }); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js b/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js new file mode 100644 index 0000000000..68d5663258 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_insecurePasswordConsoleWarning.js @@ -0,0 +1,131 @@ +"use strict"; + +const WARNING_PATTERN = [ + { + key: "INSECURE_FORM_ACTION", + msg: 'JavaScript Warning: "Password fields present in a form with an insecure (http://) form action. This is a security risk that allows user login credentials to be stolen."', + }, + { + key: "INSECURE_PAGE", + msg: 'JavaScript Warning: "Password fields present on an insecure (http://) page. This is a security risk that allows user login credentials to be stolen."', + }, +]; + +add_task(async function testInsecurePasswordWarning() { + // By default, proxies don't apply to 127.0.0.1. We need them to for this test, though: + await SpecialPowers.pushPrefEnv({ + set: [["network.proxy.allow_hijacking_localhost", true]], + }); + let warningPatternHandler; + + function messageHandler(msgObj) { + function findWarningPattern(msg) { + return WARNING_PATTERN.find(patternPair => { + return msg.includes(patternPair.msg); + }); + } + + let warning = findWarningPattern(msgObj.message); + + // Only handle the insecure password related warning messages. + if (warning) { + // Prevent any unexpected or redundant matched warning message coming after + // the test case is ended. + Assert.ok( + warningPatternHandler, + "Invoke a valid warning message handler" + ); + warningPatternHandler(warning, msgObj.message); + } + } + Services.console.registerListener(messageHandler); + registerCleanupFunction(function () { + Services.console.unregisterListener(messageHandler); + }); + + for (let [origin, testFile, expectWarnings] of [ + ["http://127.0.0.1", "form_basic.html", []], + ["http://127.0.0.1", "formless_basic.html", []], + ["http://example.com", "form_basic.html", ["INSECURE_PAGE"]], + ["http://example.com", "formless_basic.html", ["INSECURE_PAGE"]], + ["https://example.com", "form_basic.html", []], + ["https://example.com", "formless_basic.html", []], + + // For a form with customized action link in the same origin. + ["http://127.0.0.1", "form_same_origin_action.html", []], + ["http://example.com", "form_same_origin_action.html", ["INSECURE_PAGE"]], + ["https://example.com", "form_same_origin_action.html", []], + + // For a form with an insecure (http) customized action link. + [ + "http://127.0.0.1", + "form_cross_origin_insecure_action.html", + ["INSECURE_FORM_ACTION"], + ], + [ + "http://example.com", + "form_cross_origin_insecure_action.html", + ["INSECURE_PAGE"], + ], + [ + "https://example.com", + "form_cross_origin_insecure_action.html", + ["INSECURE_FORM_ACTION"], + ], + + // For a form with a secure (https) customized action link. + ["http://127.0.0.1", "form_cross_origin_secure_action.html", []], + [ + "http://example.com", + "form_cross_origin_secure_action.html", + ["INSECURE_PAGE"], + ], + ["https://example.com", "form_cross_origin_secure_action.html", []], + ]) { + let testURL = origin + DIRECTORY_PATH + testFile; + let promiseConsoleMessages = new Promise(resolve => { + warningPatternHandler = function (warning, originMessage) { + Assert.ok(warning, "Handling a warning pattern"); + let fullMessage = `[${warning.msg} {file: "${testURL}" line: 0 column: 0 source: "0"}]`; + Assert.equal( + originMessage, + fullMessage, + "Message full matched:" + originMessage + ); + + let index = expectWarnings.indexOf(warning.key); + isnot( + index, + -1, + "Found warning: " + warning.key + " for URL:" + testURL + ); + if (index !== -1) { + // Remove the shown message. + expectWarnings.splice(index, 1); + } + if (expectWarnings.length === 0) { + info("All warnings are shown for URL:" + testURL); + resolve(); + } + }; + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: testURL, + }, + function () { + if (expectWarnings.length === 0) { + info("All warnings are shown for URL:" + testURL); + return Promise.resolve(); + } + return promiseConsoleMessages; + } + ); + + // Remove warningPatternHandler to stop handling the matched warning pattern + // and the task should not get any warning anymore. + warningPatternHandler = null; + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js b/toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js new file mode 100644 index 0000000000..0f256d74a1 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_isProbablyASignUpForm.js @@ -0,0 +1,42 @@ +"use strict"; + +const TEST_URL = `https://example.com${DIRECTORY_PATH}form_signup_detection.html`; + +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + await SpecialPowers.spawn(browser, [], async () => { + const doc = content.document; + const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" + ); + const loginManagerChild = new LoginManagerChild(); + const docState = loginManagerChild.stateForDocument(doc); + let isSignUpForm; + + info("Test case: Obvious signup form is detected as sign up form"); + const signUpForm = doc.getElementById("obvious-signup-form"); + isSignUpForm = docState.isProbablyASignUpForm(signUpForm); + Assert.equal(isSignUpForm, true); + + info( + "Test case: Obvious non signup form is detected as non sign up form" + ); + const loginForm = doc.getElementById("obvious-login-form"); + isSignUpForm = docState.isProbablyASignUpForm(loginForm); + Assert.equal(isSignUpForm, false); + + info( + "Test case: An <input> HTML element is detected as non sign up form" + ); + const inputField = doc.getElementById("obvious-signup-username"); + isSignUpForm = docState.isProbablyASignUpForm(inputField); + Assert.equal(isSignUpForm, false); + }); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_localip_frame.js b/toolkit/components/passwordmgr/test/browser/browser_localip_frame.js new file mode 100644 index 0000000000..33ae0e6c0b --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_localip_frame.js @@ -0,0 +1,86 @@ +"use strict"; + +add_setup(async () => { + const login1 = LoginTestUtils.testData.formLogin({ + origin: "http://10.0.0.0", + formActionOrigin: "https://example.org", + username: "username1", + password: "password1", + }); + const login2 = LoginTestUtils.testData.formLogin({ + origin: "https://example.org", + formActionOrigin: "https://example.org", + username: "username2", + password: "password2", + }); + await Services.logins.addLogins([login1, login2]); +}); + +add_task(async function test_warningForLocalIP() { + let tests = [ + /* when the url of top-level and iframe are both ip address, do not show insecure warning */ + { + top: "http://192.168.0.0", + iframe: "http://10.0.0.0", + expected: `[originaltype="loginWithOrigin"]`, + }, + { + top: "http://192.168.0.0", + iframe: "https://example.org", + expected: `[type="insecureWarning"]`, + }, + { + top: "http://example.com", + iframe: "http://10.0.0.0", + expected: `[type="insecureWarning"]`, + }, + { + top: "http://example.com", + iframe: "http://example.org", + expected: `[type="insecureWarning"]`, + }, + ]; + + for (let test of tests) { + let urlTop = test.top + DIRECTORY_PATH + "empty.html"; + let urlIframe = + test.iframe + DIRECTORY_PATH + "insecure_test_subframe.html"; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, urlTop); + let browser = tab.linkedBrowser; + + await SpecialPowers.spawn(browser, [urlIframe], async url => { + await new content.Promise(resolve => { + let ifr = content.document.createElement("iframe"); + ifr.onload = resolve; + ifr.src = url; + content.document.body.appendChild(ifr); + }); + }); + + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + + let ifr = browser.browsingContext.children[0]; + Assert.ok(ifr, "Got iframe"); + + let popupShown = openACPopup( + popup, + tab.linkedBrowser, + "#form-basic-username", + ifr + ); + await popupShown; + + let item = popup.querySelector(test.expected); + Assert.ok(item, "Got expected richlistitem"); + + await BrowserTestUtils.waitForCondition( + () => !item.collapsed, + "Wait for autocomplete to show" + ); + + await closePopup(popup); + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_message_onFormSubmit.js b/toolkit/components/passwordmgr/test/browser/browser_message_onFormSubmit.js new file mode 100644 index 0000000000..5e611a7384 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_message_onFormSubmit.js @@ -0,0 +1,82 @@ +/** + * Test "passwordmgr-form-submission-detected" should be notified + * regardless of whehter the password saving is enabled. + */ + +async function waitForFormSubmissionDetected() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subject, topic) { + Services.obs.removeObserver( + observer, + "passwordmgr-form-submission-detected" + ); + resolve(); + }, "passwordmgr-form-submission-detected"); + }); +} + +add_task(async function test_login_save_disable() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons", false]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + await changeContentFormValues(browser, { + "#form-basic-username": "username", + "#form-basic-password": "password", + }); + + let promise = waitForFormSubmissionDetected(); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + + await promise; + Assert.ok(true, "Test completed"); + } + ); +}); + +add_task(async function test_login_save_enable() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rememberSignons", true]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: + "https://example.com/browser/toolkit/components/" + + "passwordmgr/test/browser/form_basic.html", + }, + async function (browser) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + + await changeContentFormValues(browser, { + "#form-basic-username": "username", + "#form-basic-password": "password", + }); + + // When login saving is enabled, we should receive both FormSubmit + // event and "passwordmgr-form-submission-detected" event + let p1 = waitForFormSubmissionDetected(); + let p2 = listenForTestNotification("ShowDoorhanger"); + await SpecialPowers.spawn(browser, [], async function () { + let doc = this.content.document; + doc.getElementById("form-basic").submit(); + }); + + await Promise.all([p1, p2]); + Assert.ok(true, "Test completed"); + } + ); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_openPasswordManager.js b/toolkit/components/passwordmgr/test/browser/browser_openPasswordManager.js new file mode 100644 index 0000000000..0bb50f81c8 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_openPasswordManager.js @@ -0,0 +1,161 @@ +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +add_task(async function test_noFilter() { + let openingFunc = () => + LoginHelper.openPasswordManager(window, { entryPoint: "mainmenu" }); + let passwordManager = await openPasswordManager(openingFunc); + + Assert.ok(passwordManager, "Login dialog was opened"); + await passwordManager.close(); + await TestUtils.waitForCondition(() => { + return Services.wm.getMostRecentWindow("Toolkit:PasswordManager") === null; + }, "Waiting for the password manager dialog to close"); +}); + +add_task(async function test_filter() { + // Greek IDN for example.test + let domain = "παράδειγμα.δοκιμή"; + let openingFunc = () => + LoginHelper.openPasswordManager(window, { + filterString: domain, + entryPoint: "mainmenu", + }); + let passwordManager = await openPasswordManager(openingFunc, true); + Assert.equal( + passwordManager.filterValue, + domain, + "search string to filter logins should match expectation" + ); + await passwordManager.close(); + await TestUtils.waitForCondition(() => { + return Services.wm.getMostRecentWindow("Toolkit:PasswordManager") === null; + }, "Waiting for the password manager dialog to close"); +}); + +add_task(async function test_management_noFilter() { + let tabOpenPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:logins"); + LoginHelper.openPasswordManager(window, { entryPoint: "mainmenu" }); + let tab = await tabOpenPromise; + Assert.ok(tab, "Got the new tab"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_management_filter() { + let tabOpenPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:logins?filter=%CF%80%CE%B1%CF%81%CE%AC%CE%B4%CE%B5%CE%B9%CE%B3%CE%BC%CE%B1.%CE%B4%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE" + ); + // Greek IDN for example.test + LoginHelper.openPasswordManager(window, { + filterString: "παράδειγμα.δοκιμή", + entryPoint: "mainmenu", + }); + let tab = await tabOpenPromise; + Assert.ok(tab, "Got the new tab with a domain filter"); + BrowserTestUtils.removeTab(tab); +}); + +add_task( + async function test_url_when_opening_password_manager_without_a_filterString() { + sinon.spy(window, "openTrustedLinkIn"); + const openingFunc = () => + LoginHelper.openPasswordManager(window, { + filterString: "", + entryPoint: "mainmenu", + }); + const passwordManager = await openPasswordManager(openingFunc); + + const url = window.openTrustedLinkIn.lastCall.args[0]; + + Assert.ok( + !url.includes("filter"), + "LoginHelper.openPasswordManager call without a filterString navigated to a URL with a filter query param" + ); + Assert.equal( + 0, + url.split("").filter(char => char === "&").length, + "LoginHelper.openPasswordManager call without a filterString navigated to a URL with an &" + ); + Assert.equal( + url, + "about:logins?entryPoint=mainmenu", + "LoginHelper.openPasswordManager call without a filterString navigated to an unexpected URL" + ); + + Assert.ok(passwordManager, "Login dialog was opened"); + await passwordManager.close(); + window.openTrustedLinkIn.restore(); + } +); + +add_task( + async function test_url_when_opening_password_manager_with_a_filterString() { + sinon.spy(window, "openTrustedLinkIn"); + const openingFunc = () => + LoginHelper.openPasswordManager(window, { + filterString: "testFilter", + entryPoint: "mainmenu", + }); + const passwordManager = await openPasswordManager(openingFunc); + + const url = window.openTrustedLinkIn.lastCall.args[0]; + + Assert.ok( + url.includes("filter"), + "LoginHelper.openPasswordManager call with a filterString navigated to a URL without a filter query param" + ); + Assert.equal( + 1, + url.split("").filter(char => char === "&").length, + "LoginHelper.openPasswordManager call with a filterString navigated to a URL without the correct number of '&'s" + ); + Assert.equal( + url, + "about:logins?filter=testFilter&entryPoint=mainmenu", + "LoginHelper.openPasswordManager call with a filterString navigated to an unexpected URL" + ); + + Assert.ok(passwordManager, "Login dialog was opened"); + await passwordManager.close(); + window.openTrustedLinkIn.restore(); + } +); + +add_task( + async function test_url_when_opening_password_manager_without_filterString_or_entryPoint() { + sinon.spy(window, "openTrustedLinkIn"); + const openingFunc = () => + LoginHelper.openPasswordManager(window, { + filterString: "", + entryPoint: "", + }); + const passwordManager = await openPasswordManager(openingFunc); + + const url = window.openTrustedLinkIn.lastCall.args[0]; + + Assert.ok( + !url.includes("filter"), + "LoginHelper.openPasswordManager call without a filterString navigated to a URL with a filter query param" + ); + Assert.ok( + !url.includes("entryPoint"), + "LoginHelper.openPasswordManager call without an entryPoint navigated to a URL with an entryPoint query param" + ); + Assert.equal( + 0, + url.split("").filter(char => char === "&").length, + "LoginHelper.openPasswordManager call without query params navigated to a URL that included at least one '&'" + ); + Assert.equal( + url, + "about:logins", + "LoginHelper.openPasswordManager call without a filterString or entryPoint navigated to an unexpected URL" + ); + + Assert.ok(passwordManager, "Login dialog was opened"); + await passwordManager.close(); + window.openTrustedLinkIn.restore(); + } +); diff --git a/toolkit/components/passwordmgr/test/browser/browser_preselect_login.js b/toolkit/components/passwordmgr/test/browser/browser_preselect_login.js new file mode 100644 index 0000000000..146b0ae92b --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_preselect_login.js @@ -0,0 +1,183 @@ +const TEST_ORIGIN = "https://example.org"; +const ABOUT_LOGINS_ORIGIN = "about:logins"; +const TEST_URL_PATH = `${TEST_ORIGIN}${DIRECTORY_PATH}form_basic_login.html`; + +const LOGINS_DATA = [ + { + origin: "https://aurl.com", + username: "user1", + password: "pass1", + guid: Services.uuid.generateUUID().toString(), + }, + { + origin: TEST_ORIGIN, + username: "user2", + password: "pass2", + guid: Services.uuid.generateUUID().toString(), + }, + { + origin: TEST_ORIGIN, + username: "user3", + password: "pass3", + guid: Services.uuid.generateUUID().toString(), + }, +]; + +const waitForAppMenu = async () => { + const appMenu = document.getElementById("appMenu-popup"); + const appMenuButton = document.getElementById("PanelUI-menu-button"); + await TestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(appMenuButton), + "App menu button should be visible." + ); + + let popupshown = BrowserTestUtils.waitForEvent(appMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(appMenuButton, {}); + await popupshown; + Assert.equal( + appMenu.state, + "open", + `Menu panel (${appMenu.id}) should be visible` + ); +}; + +const isExpectedLoginItemSelected = async ({ expectedGuid }) => { + const loginList = content.document.querySelector("login-list").shadowRoot; + + await ContentTaskUtils.waitForCondition( + () => + loginList.querySelector("li[aria-selected='true']")?.dataset?.guid === + expectedGuid, + "Wait for login item to be selected" + ); + + Assert.equal( + loginList.querySelector("li[aria-selected='true']")?.dataset?.guid, + expectedGuid, + "Expected login is preselected" + ); +}; + +add_setup(async () => { + await Services.logins.addLogins( + LOGINS_DATA.map(login => LoginTestUtils.testData.formLogin(login)) + ); +}); + +add_task(async function test_about_logins_defaults_to_first_item() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:logins#random-guid", + }, + async function (gBrowser) { + await SpecialPowers.spawn( + gBrowser, + [{ expectedGuid: LOGINS_DATA[0].guid }], + isExpectedLoginItemSelected + ); + + Assert.ok( + true, + "First item of the list is selected when random hash is supplied." + ); + } + ); +}); + +add_task( + async function test_gear_icon_opens_about_logins_with_preselected_login() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + const popup = document.getElementById("PopupAutoComplete"); + + await openACPopup(popup, browser, "#form-basic-username"); + + const secondLoginItem = popup.firstChild.getItemAtIndex(1); + const secondLoginItemSettingsIcon = secondLoginItem.querySelector( + ".ac-settings-button" + ); + + Assert.ok( + !secondLoginItemSettingsIcon.checkVisibility({ + checkVisibilityCSS: true, + }), + "Gear icon should not be visible initially" + ); + + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + + await BrowserTestUtils.waitForCondition( + () => secondLoginItem.attributes.selected, + "Wait for second login to become active" + ); + + Assert.ok( + secondLoginItemSettingsIcon.checkVisibility({ + checkVisibilityCSS: true, + }), + "Gear icon should be visible when login item is active" + ); + + const aboutLoginsTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => url.includes(ABOUT_LOGINS_ORIGIN), + true + ); + + EventUtils.synthesizeMouseAtCenter(secondLoginItemSettingsIcon, {}); + const aboutLoginsTab = await aboutLoginsTabPromise; + + await SpecialPowers.spawn( + aboutLoginsTab.linkedBrowser, + [{ expectedGuid: LOGINS_DATA[2].guid }], + isExpectedLoginItemSelected + ); + + await closePopup(popup); + gBrowser.removeTab(aboutLoginsTab); + } + ); + } +); + +add_task( + async function test_password_menu_opens_about_logins_with_preselected_login() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await waitForAppMenu(); + + const appMenuPasswordsButton = document.getElementById( + "appMenu-passwords-button" + ); + + const aboutLoginsTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => url.includes(ABOUT_LOGINS_ORIGIN), + true + ); + + EventUtils.synthesizeMouseAtCenter(appMenuPasswordsButton, {}); + + const aboutLoginsTab = await aboutLoginsTabPromise; + + await SpecialPowers.spawn( + aboutLoginsTab.linkedBrowser, + [{ expectedGuid: LOGINS_DATA[1].guid }], + isExpectedLoginItemSelected + ); + + gBrowser.removeTab(aboutLoginsTab); + } + ); + } +); diff --git a/toolkit/components/passwordmgr/test/browser/browser_private_window.js b/toolkit/components/passwordmgr/test/browser/browser_private_window.js new file mode 100644 index 0000000000..513549fbbe --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_private_window.js @@ -0,0 +1,954 @@ +"use strict"; + +async function focusWindow(win) { + if (Services.focus.activeWindow == win) { + return; + } + let promise = new Promise(resolve => { + win.addEventListener( + "focus", + function () { + resolve(); + }, + { capture: true, once: true } + ); + }); + win.focus(); + await promise; +} + +function getDialogDoc() { + // Trudge through all the open windows, until we find the one + // that has either commonDialog.xhtml or selectDialog.xhtml loaded. + // var enumerator = Services.wm.getEnumerator("navigator:browser"); + for (let { docShell } of Services.wm.getEnumerator(null)) { + var containedDocShells = docShell.getAllDocShellsInSubtree( + docShell.typeChrome, + docShell.ENUMERATE_FORWARDS + ); + for (let childDocShell of containedDocShells) { + // Get the corresponding document for this docshell + // We don't want it if it's not done loading. + if (childDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) { + continue; + } + var childDoc = childDocShell.contentViewer.DOMDocument; + if ( + childDoc.location.href != + "chrome://global/content/commonDialog.xhtml" && + childDoc.location.href != "chrome://global/content/selectDialog.xhtml" + ) { + continue; + } + + // We're expecting the dialog to be focused. If it's not yet, try later. + // (In particular, this is needed on Linux to reliably check focused elements.) + if (Services.focus.focusedWindow != childDoc.defaultView) { + continue; + } + + return childDoc; + } + } + + return null; +} + +async function waitForAuthPrompt() { + let promptDoc = await TestUtils.waitForCondition(() => { + return getAuthPrompt(); + }); + info("Got prompt: " + promptDoc); + return promptDoc; +} + +function getAuthPrompt() { + let doc = getDialogDoc(); + if (!doc) { + return false; // try again in a bit + } + return doc; +} + +async function loadAccessRestrictedURL(browser, url, username, password) { + let browserLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, url); + + // Wait for the auth prompt, enter the login details and close the prompt + await PromptTestUtils.handleNextPrompt( + browser, + { modalType: authPromptModalType, promptType: "promptUserAndPass" }, + { buttonNumClick: 0, loginInput: username, passwordInput: password } + ); + + await SimpleTest.promiseFocus(browser.ownerGlobal); + await browserLoaded; +} + +const PRIVATE_BROWSING_CAPTURE_PREF = "signon.privateBrowsingCapture.enabled"; +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +const form1Url = `https://example.com/${DIRECTORY_PATH}subtst_privbrowsing_1.html`; +const form2Url = `https://example.com/${DIRECTORY_PATH}form_password_change.html`; +const authUrl = `https://example.com/${DIRECTORY_PATH}authenticate.sjs`; + +let normalWin; +let privateWin; +let authPromptModalType; + +// XXX: Note that tasks are currently run in sequence. Some tests may assume the state +// resulting from successful or unsuccessful logins in previous tasks + +add_task(async function test_setup() { + authPromptModalType = Services.prefs.getIntPref("prompts.modalType.httpAuth"); + normalWin = await BrowserTestUtils.openNewBrowserWindow({ private: false }); + privateWin = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + Services.logins.removeAllUserFacingLogins(); +}); + +add_task(async function test_normal_popup_notification_1() { + info("test 1: run outside of private mode, popup notification should appear"); + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); +}); + +add_task(async function test_private_popup_notification_2() { + info( + "test 2: run inside of private mode, dismissed popup notification should appear" + ); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Assert.ok( + capturePrefValue, + `Expect ${PRIVATE_BROWSING_CAPTURE_PREF} to default to true` + ); + + // clear existing logins for parity with the previous test + Services.logins.removeAllUserFacingLogins(); + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + Assert.ok(notif, "Expected notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => notif.dismissed, + "notification should be dismissed" + ); + + let { panel } = privateWin.PopupNotifications; + let promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + await promiseShown; + + let notificationElement = panel.childNodes[0]; + let toggleCheckbox = notificationElement.querySelector( + "#password-notification-visibilityToggle" + ); + + Assert.ok( + !toggleCheckbox.hidden, + "Toggle should be visible upon 1st opening" + ); + + info("Hiding popup."); + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + await promiseHidden; + + info("Clicking on anchor to reshow popup."); + promiseShown = BrowserTestUtils.waitForEvent(panel, "popupshown"); + notif.anchorElement.click(); + await promiseShown; + + Assert.ok( + toggleCheckbox.hidden, + "Toggle should be hidden upon 2nd opening" + ); + + await cleanupDoorhanger(notif); + } + } + ); + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "No logins were saved" + ); +}); + +add_task(async function test_private_popup_notification_no_capture_pref_2b() { + info( + "test 2b: run inside of private mode, with capture pref off," + + "popup notification should not appear" + ); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Services.prefs.setBoolPref(PRIVATE_BROWSING_CAPTURE_PREF, false); + + // clear existing logins for parity with the previous test + Services.logins.removeAllUserFacingLogins(); + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + // restore the pref to its original value + Services.prefs.setBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF, + capturePrefValue + ); + + Assert.ok(!notif, "Expected no notification popup"); + if (notif) { + await cleanupDoorhanger(notif); + } + } + ); + Assert.equal( + Services.logins.getAllLogins().length, + 0, + "No logins were saved" + ); +}); + +add_task(async function test_normal_popup_notification_3() { + info( + "test 3: run with a login, outside of private mode, " + + "match existing username/password: no popup notification should appear" + ); + + Services.logins.removeAllUserFacingLogins(); + await Services.logins.addLoginAsync(login); + let allLogins = Services.logins.getAllLogins(); + // Sanity check the HTTP login exists. + Assert.equal(allLogins.length, 1, "Should have the HTTP login"); + let timeLastUsed = allLogins[0].timeLastUsed; + let loginGuid = allLogins[0].guid; + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger("any", PopupNotifications, browser); + Assert.ok(!notif, "got no notification popup"); + if (notif) { + await cleanupDoorhanger(notif); + } + } + ); + allLogins = Services.logins.getAllLogins(); + Assert.equal( + allLogins[0].guid, + loginGuid, + "Sanity-check we are comparing the same login record" + ); + Assert.ok( + allLogins[0].timeLastUsed > timeLastUsed, + "The timeLastUsed timestamp has been updated" + ); +}); + +add_task(async function test_private_popup_notification_3b() { + info( + "test 3b: run with a login, in private mode," + + " match existing username/password: no popup notification should appear" + ); + + Services.logins.removeAllUserFacingLogins(); + await Services.logins.addLoginAsync(login); + let allLogins = Services.logins.getAllLogins(); + // Sanity check the HTTP login exists. + Assert.equal(allLogins.length, 1, "Should have the HTTP login"); + let timeLastUsed = allLogins[0].timeLastUsed; + let loginGuid = allLogins[0].guid; + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form1Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#user": "notifyu1", + "#pass": "notifyp1", + } + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + + let notif = getCaptureDoorhanger("any", PopupNotifications, browser); + + Assert.ok(!notif, "got no notification popup"); + if (notif) { + await cleanupDoorhanger(notif); + } + } + ); + allLogins = Services.logins.getAllLogins(); + Assert.equal( + allLogins[0].guid, + loginGuid, + "Sanity-check we are comparing the same login record" + ); + Assert.equal( + allLogins[0].timeLastUsed, + timeLastUsed, + "The timeLastUsed timestamp has not been updated" + ); +}); + +add_task(async function test_normal_new_password_4() { + info( + "test 4: run with a login, outside of private mode," + + " add a new password: popup notification should appear" + ); + Services.logins.removeAllUserFacingLogins(); + await Services.logins.addLoginAsync(login); + let allLogins = Services.logins.getAllLogins(); + // Sanity check the HTTP login exists. + Assert.equal(allLogins.length, 1, "Should have the HTTP login"); + let timeLastUsed = allLogins[0].timeLastUsed; + let loginGuid = allLogins[0].guid; + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form2Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#pass": "notifyp1", + "#newpass": "notifyp2", + } + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger( + "password-change", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); + // We put up a doorhanger, but didn't interact with it, so we expect the login timestamps + // to be unchanged + allLogins = Services.logins.getAllLogins(); + Assert.equal( + allLogins[0].guid, + loginGuid, + "Sanity-check we are comparing the same login record" + ); + Assert.equal( + allLogins[0].timeLastUsed, + timeLastUsed, + "The timeLastUsed timestamp was not updated" + ); +}); + +add_task(async function test_private_new_password_5() { + info( + "test 5: run with a login, in private mode," + + "add a new password: popup notification should appear" + ); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Assert.ok( + capturePrefValue, + `Expect ${PRIVATE_BROWSING_CAPTURE_PREF} to default to true` + ); + + let allLogins = Services.logins.getAllLogins(); + // Sanity check the HTTP login exists. + Assert.equal(allLogins.length, 1, "Should have the HTTP login"); + let timeLastUsed = allLogins[0].timeLastUsed; + let loginGuid = allLogins[0].guid; + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form2Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#pass": "notifyp1", + "#newpass": "notifyp2", + } + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger( + "password-change", + PopupNotifications, + browser + ); + Assert.ok(notif, "Expected notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); + // We put up a doorhanger, but didn't interact with it, so we expect the login timestamps + // to be unchanged + allLogins = Services.logins.getAllLogins(); + Assert.equal( + allLogins[0].guid, + loginGuid, + "Sanity-check we are comparing the same login record" + ); + Assert.equal( + allLogins[0].timeLastUsed, + timeLastUsed, + "The timeLastUsed timestamp has not been updated" + ); +}); + +add_task(async function test_normal_with_login_6() { + info( + "test 6: run with a login, outside of private mode, " + + "submit with an existing password (from test 4): popup notification should appear" + ); + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form2Url, + }, + async function (browser) { + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + { + "#pass": "notifyp1", + "#newpass": "notifyp2", + } + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + let notif = getCaptureDoorhanger( + "password-change", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + Services.logins.removeLogin(login); + } + ); +}); + +add_task(async function test_normal_autofilled_7() { + info("test 7: verify that the user/pass pair was autofilled"); + await Services.logins.addLoginAsync(login); + + // Sanity check the HTTP login exists. + Assert.equal( + Services.logins.getAllLogins().length, + 1, + "Should have the HTTP login" + ); + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: "about:blank", + }, + async function (browser) { + // Add the observer before loading the form page + let formFilled = listenForTestNotification("FormProcessed"); + await SimpleTest.promiseFocus(browser.ownerGlobal); + BrowserTestUtils.loadURIString(browser, form1Url); + await formFilled; + + // the form should have been autofilled, so submit without updating field values + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + {} + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + } + ); +}); + +add_task(async function test_private_not_autofilled_8() { + info("test 8: verify that the user/pass pair was not autofilled"); + // Sanity check the HTTP login exists. + Assert.equal( + Services.logins.getAllLogins().length, + 1, + "Should have the HTTP login" + ); + + let formFilled = listenForTestNotification("FormProcessed"); + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: form1Url, + }, + async function (browser) { + await formFilled; + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + {} + ); + Assert.ok(!fieldValues.username, "Checking submitted username"); + Assert.ok(!fieldValues.password, "Checking submitted password"); + } + ); +}); + +// Disabled for Bug 1523777 +// add_task(async function test_private_autocomplete_9() { +// info("test 9: verify that the user/pass pair was available for autocomplete"); +// // Sanity check the HTTP login exists. +// Assert.equal(Services.logins.getAllLogins().length, 1, "Should have the HTTP login"); + +// await focusWindow(privateWin); +// await BrowserTestUtils.withNewTab({ +// gBrowser: privateWin.gBrowser, +// url: form1Url, +// }, async function(browser) { +// let popup = document.getElementById("PopupAutoComplete"); +// Assert.ok(popup, "Got popup"); + +// let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + +// // focus the user field. This should trigger the autocomplete menu +// await ContentTask.spawn(browser, null, async function() { +// content.document.getElementById("user").focus(); +// }); +// await promiseShown; +// Assert.ok(promiseShown, "autocomplete shown"); + +// let promiseFormInput = ContentTask.spawn(browser, null, async function() { +// let doc = content.document; +// await new Promise(resolve => { +// doc.getElementById("form").addEventListener("input", resolve, { once: true }); +// }); +// }); +// info("sending keys"); +// // select the item and hit enter to fill the form +// await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); +// await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser); +// await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser); +// await promiseFormInput; + +// let fieldValues = await submitFormAndGetResults(browser, "formsubmit.sjs", {}); +// Assert.equal(fieldValues.username, "notifyu1", "Checking submitted username"); +// Assert.equal(fieldValues.password, "notifyp1", "Checking submitted password"); +// }); +// }); + +add_task(async function test_normal_autofilled_10() { + info( + "test 10: verify that the user/pass pair does get autofilled in non-private window" + ); + // Sanity check the HTTP login exists. + Assert.equal( + Services.logins.getAllLogins().length, + 1, + "Should have the HTTP login" + ); + + let formFilled = listenForTestNotification("FormProcessed"); + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: form1Url, + }, + async function (browser) { + await formFilled; + let fieldValues = await submitFormAndGetResults( + browser, + "formsubmit.sjs", + {} + ); + Assert.equal( + fieldValues.username, + "notifyu1", + "Checking submitted username" + ); + Assert.equal( + fieldValues.password, + "notifyp1", + "Checking submitted password" + ); + } + ); +}); + +add_task(async function test_normal_http_basic_auth() { + info( + "test normal/basic-auth: verify that we get a doorhanger after basic-auth login" + ); + Services.logins.removeAllUserFacingLogins(); + clearHttpAuths(); + + await focusWindow(normalWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: normalWin.gBrowser, + url: "https://example.com", + }, + async function (browser) { + await loadAccessRestrictedURL(browser, authUrl, "test", "testpass"); + Assert.ok(true, "Auth-required page loaded"); + + // verify result in the response document + let fieldValues = await SpecialPowers.spawn( + browser, + [[]], + async function () { + let username = content.document.getElementById("user").textContent; + let password = content.document.getElementById("pass").textContent; + let ok = content.document.getElementById("ok").textContent; + return { + username, + password, + ok, + }; + } + ); + Assert.equal(fieldValues.ok, "PASS", "Checking authorization passed"); + Assert.equal( + fieldValues.username, + "test", + "Checking authorized username" + ); + Assert.equal( + fieldValues.password, + "testpass", + "Checking authorized password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => !notif.dismissed, + "notification should not be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); +}); + +add_task(async function test_private_http_basic_auth() { + info( + "test private/basic-auth: verify that we don't get a doorhanger after basic-auth login" + ); + Services.logins.removeAllUserFacingLogins(); + clearHttpAuths(); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Assert.ok( + capturePrefValue, + `Expect ${PRIVATE_BROWSING_CAPTURE_PREF} to default to true` + ); + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: "https://example.com", + }, + async function (browser) { + await loadAccessRestrictedURL(browser, authUrl, "test", "testpass"); + + let fieldValues = await getFormSubmitResponseResult( + browser, + "authenticate.sjs" + ); + Assert.equal( + fieldValues.username, + "test", + "Checking authorized username" + ); + Assert.equal( + fieldValues.password, + "testpass", + "Checking authorized password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + Assert.ok(notif, "got notification popup"); + if (notif) { + await TestUtils.waitForCondition( + () => notif.dismissed, + "notification should be dismissed" + ); + await cleanupDoorhanger(notif); + } + } + ); +}); + +add_task(async function test_private_http_basic_auth_no_capture_pref() { + info( + "test private/basic-auth: verify that we don't get a doorhanger after basic-auth login" + + "with capture pref off" + ); + + const capturePrefValue = Services.prefs.getBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF + ); + Services.prefs.setBoolPref(PRIVATE_BROWSING_CAPTURE_PREF, false); + + Services.logins.removeAllUserFacingLogins(); + clearHttpAuths(); + + await focusWindow(privateWin); + await BrowserTestUtils.withNewTab( + { + gBrowser: privateWin.gBrowser, + url: "https://example.com", + }, + async function (browser) { + await loadAccessRestrictedURL(browser, authUrl, "test", "testpass"); + + let fieldValues = await getFormSubmitResponseResult( + browser, + "authenticate.sjs" + ); + Assert.equal( + fieldValues.username, + "test", + "Checking authorized username" + ); + Assert.equal( + fieldValues.password, + "testpass", + "Checking authorized password" + ); + + let notif = getCaptureDoorhanger( + "password-save", + PopupNotifications, + browser + ); + // restore the pref to its original value + Services.prefs.setBoolPref( + PRIVATE_BROWSING_CAPTURE_PREF, + capturePrefValue + ); + + Assert.ok(!notif, "got no notification popup"); + if (notif) { + await cleanupDoorhanger(notif); + } + } + ); +}); + +add_task(async function test_cleanup() { + await BrowserTestUtils.closeWindow(normalWin); + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_proxyAuth_prompt.js b/toolkit/components/passwordmgr/test/browser/browser_proxyAuth_prompt.js new file mode 100644 index 0000000000..478f204581 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_proxyAuth_prompt.js @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +let proxyChannel; + +function initProxy() { + return new Promise(resolve => { + let proxyChannel; + + let proxyCallback = { + QueryInterface: ChromeUtils.generateQI(["nsIProtocolProxyCallback"]), + + onProxyAvailable(req, uri, pi, status) { + class ProxyChannelListener { + onStartRequest(request) { + resolve(proxyChannel); + } + onStopRequest(request, status) {} + } + // I'm cheating a bit here... We should probably do some magic foo to get + // something implementing nsIProxiedProtocolHandler and then call + // NewProxiedChannel(), so we have something that's definately a proxied + // channel. But Mochitests use a proxy for a number of hosts, so just + // requesting a normal channel will give us a channel that's proxied. + // The proxyChannel needs to move to at least on-modify-request to + // have valid ProxyInfo, but we use OnStartRequest during startup() + // for simplicity. + proxyChannel = Services.io.newChannel( + "http://mochi.test:8888", + null, + null, + null, // aLoadingNode + Services.scriptSecurityManager.getSystemPrincipal(), + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + proxyChannel.asyncOpen(new ProxyChannelListener()); + }, + }; + + // Need to allow for arbitrary network servers defined in PAC instead of a hardcoded moz-proxy. + let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService(); + + let channel = Services.io.newChannel( + "https://example.com", + null, + null, + null, // aLoadingNode + Services.scriptSecurityManager.getSystemPrincipal(), + null, // aTriggeringPrincipal + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + pps.asyncResolve(channel, 0, proxyCallback); + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + // This test relies on tab auth prompts. + set: [["prompts.modalType.httpAuth", Services.prompt.MODAL_TYPE_TAB]], + }); + proxyChannel = await initProxy(); +}); + +/** + * Create an object for consuming an nsIAuthPromptCallback. + * @returns result + * @returns {nsIAuthPromptCallback} result.callback - Callback to be passed into + * asyncPromptAuth. + * @returns {Promise} result.promise - Promise which resolves with authInfo once + * the callback has been called. + */ +function getAuthPromptCallback() { + let callbackResolver; + let promise = new Promise(resolve => { + callbackResolver = resolve; + }); + let callback = { + onAuthAvailable(context, authInfo) { + callbackResolver(authInfo); + }, + }; + return { callback, promise }; +} + +/** + * Tests that if a window proxy auth prompt is open, subsequent auth calls with + * matching realm will be merged into the existing prompt. This should work even + * if the follwing auth call has browser parent. + */ +add_task(async function testProxyAuthPromptMerge() { + let tabA = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + let tabB = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.org" + ); + + const promptFac = Cc[ + "@mozilla.org/passwordmanager/authpromptfactory;1" + ].getService(Ci.nsIPromptFactory); + let prompter = promptFac.getPrompt(window, Ci.nsIAuthPrompt2); + + let level = Ci.nsIAuthPrompt2.LEVEL_NONE; + let proxyAuthinfo = { + username: "", + password: "", + domain: "", + flags: Ci.nsIAuthInformation.AUTH_PROXY, + authenticationScheme: "basic", + realm: "", + }; + + // The next prompt call will result in window prompt, because the prompt does + // not have a browser set yet. The browser is used as a parent for tab + // prompts. + let promptOpened = PromptTestUtils.waitForPrompt(null, { + modalType: Services.prompt.MODAL_TYPE_WINDOW, + }); + let cbWinPrompt = getAuthPromptCallback(); + info("asyncPromptAuth no parent"); + prompter.asyncPromptAuth( + proxyChannel, + cbWinPrompt.callback, + null, + level, + proxyAuthinfo + ); + let prompt = await promptOpened; + + // Set a browser so the next prompt would open as tab prompt. + prompter.QueryInterface(Ci.nsILoginManagerAuthPrompter).browser = + tabA.linkedBrowser; + + // Since we already have an open window prompts, subsequent calls with + // matching realm should be merged into this prompt and no additional prompts + // must be spawned. + let cbNoPrompt = getAuthPromptCallback(); + info("asyncPromptAuth tabA parent"); + prompter.asyncPromptAuth( + proxyChannel, + cbNoPrompt.callback, + null, + level, + proxyAuthinfo + ); + + // Switch to the next tabs browser. + prompter.QueryInterface(Ci.nsILoginManagerAuthPrompter).browser = + tabB.linkedBrowser; + + let cbNoPrompt2 = getAuthPromptCallback(); + info("asyncPromptAuth tabB parent"); + prompter.asyncPromptAuth( + proxyChannel, + cbNoPrompt2.callback, + null, + level, + proxyAuthinfo + ); + + // Accept the prompt. + PromptTestUtils.handlePrompt(prompt, {}); + + // Accepting the window prompts should complete all auth requests. + let authInfo1 = await cbWinPrompt.promise; + Assert.ok(authInfo1, "Received callback from first proxy auth call."); + let authInfo2 = await cbNoPrompt.promise; + Assert.ok(authInfo2, "Received callback from second proxy auth call."); + let authInfo3 = await cbNoPrompt2.promise; + Assert.ok(authInfo3, "Received callback from third proxy auth call."); + + BrowserTestUtils.removeTab(tabA); + BrowserTestUtils.removeTab(tabB); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js b/toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js new file mode 100644 index 0000000000..4a6cee3b43 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_relay_telemetry.js @@ -0,0 +1,514 @@ +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +const { getFxAccountsSingleton } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const { FirefoxRelayTelemetry } = ChromeUtils.importESModule( + "resource://gre/modules/FirefoxRelayTelemetry.mjs" +); + +const gFxAccounts = getFxAccountsSingleton(); +let gRelayACOptionsTitles; +let gHttpServer; + +const TEST_URL_PATH = `https://example.org${DIRECTORY_PATH}form_basic_signup.html`; + +const MOCK_MASKS = [ + { + full_address: "email1@mozilla.com", + description: "Email 1 Description", + enabled: true, + }, + { + full_address: "email2@mozilla.com", + description: "Email 2 Description", + enabled: false, + }, + { + full_address: "email3@mozilla.com", + description: "Email 3 Description", + enabled: true, + }, +]; + +const SERVER_SCENARIOS = { + free_tier_limit: { + "/relayaddresses/": { + POST: (request, response) => { + response.setStatusLine(request.httpVersion, 403); + response.write(JSON.stringify({ error_code: "free_tier_limit" })); + }, + GET: (_, response) => { + response.write(JSON.stringify(MOCK_MASKS)); + }, + }, + }, + unknown_error: { + "/relayaddresses/": { + default: (request, response) => { + response.setStatusLine(request.httpVersion, 408); + }, + }, + }, + + default: { + default: (request, response) => { + response.setStatusLine(request.httpVersion, 200); + response.write(JSON.stringify({ foo: "bar" })); + }, + }, +}; + +const simpleRouter = scenarioName => (request, response) => { + const routeHandler = + SERVER_SCENARIOS[scenarioName][request._path] ?? SERVER_SCENARIOS.default; + const methodHandler = + routeHandler?.[request._method] ?? + routeHandler.default ?? + SERVER_SCENARIOS.default.default; + methodHandler(request, response); +}; +const setupServerScenario = (scenarioName = "default") => + gHttpServer.registerPrefixHandler("/", simpleRouter(scenarioName)); + +const setupRelayScenario = async scenarioName => { + await SpecialPowers.pushPrefEnv({ + set: [["signon.firefoxRelay.feature", scenarioName]], + }); + Services.telemetry.clearEvents(); +}; + +const waitForEvents = async expectedEvents => + TestUtils.waitForCondition( + () => { + const snapshots = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + + return (snapshots.parent?.length ?? 0) >= (expectedEvents.length ?? 0); + }, + "Wait for telemetry to be collected", + 100, + 100 + ); + +async function assertEvents(expectedEvents) { + // To avoid intermittent failures, we wait for telemetry to be collected + await waitForEvents(expectedEvents); + const events = TelemetryTestUtils.getEvents( + { category: "relay_integration" }, + { process: "parent" } + ); + + for (let i = 0; i < expectedEvents.length; i++) { + const keysInExpectedEvent = Object.keys(expectedEvents[i]); + keysInExpectedEvent.forEach(key => { + const assertFn = + typeof events[i][key] === "object" + ? Assert.deepEqual.bind(Assert) + : Assert.equal.bind(Assert); + assertFn( + events[i][key], + expectedEvents[i][key], + `Key value for ${key} should match` + ); + }); + } +} + +async function openRelayAC(browser) { + // In rare cases, especially in chaos mode in verify tests, some events creep in. + // Clear them out before we start. + Services.telemetry.clearEvents(); + const popup = document.getElementById("PopupAutoComplete"); + await openACPopup(popup, browser, "#form-basic-username"); + const popupItem = document + .querySelector("richlistitem") + .getAttribute("ac-label"); + const popupItemTitle = JSON.parse(popupItem).title; + + Assert.ok( + gRelayACOptionsTitles.some(title => title.value === popupItemTitle), + "AC Popup has an item Relay option shown in popup" + ); + + const promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.firstChild.getItemAtIndex(0).click(); + await promiseHidden; +} + +add_setup(async function () { + gHttpServer = new HttpServer(); + setupServerScenario(); + + gHttpServer.start(-1); + + const API_ENDPOINT = `http://localhost:${gHttpServer.identity.primaryPort}/`; + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.firefoxRelay.feature", "available"], + ["signon.firefoxRelay.base_url", API_ENDPOINT], + ], + }); + + sinon.stub(gFxAccounts, "hasLocalSession").returns(true); + sinon + .stub(gFxAccounts.constructor.config, "isProductionConfig") + .returns(true); + sinon.stub(gFxAccounts, "getOAuthToken").returns("MOCK_TOKEN"); + sinon.stub(gFxAccounts, "getSignedInUser").returns({ + email: "example@mozilla.com", + }); + + const canRecordExtendedOld = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("relay_integration", true); + + gRelayACOptionsTitles = await new Localization([ + "browser/firefoxRelay.ftl", + "toolkit/branding/brandings.ftl", + ]).formatMessages([ + "firefox-relay-opt-in-title-1", + "firefox-relay-use-mask-title", + ]); + + registerCleanupFunction(async () => { + await new Promise(resolve => { + gHttpServer.stop(function () { + resolve(); + }); + }); + Services.telemetry.setEventRecordingEnabled("relay_integration", false); + Services.telemetry.clearEvents(); + Services.telemetry.canRecordExtended = canRecordExtendedOld; + sinon.restore(); + }); +}); + +add_task(async function test_pref_toggle() { + await setupRelayScenario("available"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:preferences#privacy", + }, + async function (browser) { + const relayIntegrationCheckbox = content.document.querySelector( + "checkbox#relayIntegration" + ); + relayIntegrationCheckbox.click(); + relayIntegrationCheckbox.click(); + await assertEvents([ + { object: "pref_change", method: "disabled" }, + { object: "pref_change", method: "enabled" }, + ]); + } + ); +}); + +add_task(async function test_popup_option_optin_enabled() { + await setupRelayScenario("available"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + + notificationPopup + .querySelector("button.popup-notification-primary-button") + .click(); + + await notificationHidden; + + await BrowserTestUtils.waitForEvent( + ConfirmationHint._panel, + "popuphidden" + ); + + await assertEvents([ + { + object: "offer_relay", + method: "shown", + extra: { is_relay_user: "true", scenario: "SignUpFormScenario" }, + }, + { + object: "offer_relay", + method: "clicked", + extra: { is_relay_user: "true", scenario: "SignUpFormScenario" }, + }, + { object: "opt_in_panel", method: "shown" }, + { object: "opt_in_panel", method: "enabled" }, + { + object: "fill_username", + method: "shown", + extra: { error_code: "0" }, + }, + ]); + } + ); +}); + +add_task(async function test_popup_option_optin_postponed() { + await setupRelayScenario("available"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + + notificationPopup + .querySelector("button.popup-notification-secondary-button") + .click(); + + await notificationHidden; + + await assertEvents([ + { object: "offer_relay", method: "shown" }, + { object: "offer_relay", method: "clicked" }, + { object: "opt_in_panel", method: "shown" }, + { object: "opt_in_panel", method: "postponed" }, + ]); + } + ); +}); + +add_task(async function test_popup_option_optin_disabled() { + await setupRelayScenario("available"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + const menupopup = notificationPopup.querySelector("menupopup"); + const menuitem = menupopup.querySelector("menuitem"); + + menuitem.click(); + await notificationHidden; + + await assertEvents([ + { object: "offer_relay", method: "shown" }, + { object: "offer_relay", method: "clicked" }, + { object: "opt_in_panel", method: "shown" }, + { object: "opt_in_panel", method: "disabled" }, + ]); + } + ); +}); + +add_task(async function test_popup_option_fillusername() { + await setupRelayScenario("enabled"); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + await BrowserTestUtils.waitForEvent( + ConfirmationHint._panel, + "popuphidden" + ); + await assertEvents([ + { object: "fill_username", method: "shown" }, + { + object: "fill_username", + method: "clicked", + }, + ]); + } + ); +}); + +add_task(async function test_fillusername_free_tier_limit() { + await setupRelayScenario("enabled"); + setupServerScenario("free_tier_limit"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + notificationPopup.querySelector(".reusable-relay-masks button").click(); + await notificationHidden; + + await assertEvents([ + { object: "fill_username", method: "shown" }, + { + object: "fill_username", + method: "clicked", + }, + { + object: "fill_username", + method: "shown", + extra: { error_code: "free_tier_limit" }, + }, + { + object: "reuse_panel", + method: "shown", + }, + { + object: "reuse_panel", + method: "reuse_mask", + }, + ]); + + await SpecialPowers.spawn(browser, [], async function () { + const username = content.document.getElementById("form-basic-username"); + Assert.equal( + username.value, + "email1@mozilla.com", + "Username field should be filled with the first mask" + ); + }); + } + ); +}); + +add_task(async function test_fillusername_error() { + await setupRelayScenario("enabled"); + setupServerScenario("unknown_error"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + + await notificationShown; + Assert.equal( + notificationPopup.querySelector("popupnotification").id, + "relay-integration-error-notification", + "Error message should be displayed" + ); + + await assertEvents([ + { object: "fill_username", method: "shown" }, + { + object: "fill_username", + method: "clicked", + }, + { + object: "reuse_panel", + method: "shown", + extra: { error_code: "408" }, + }, + ]); + } + ); +}); + +add_task(async function test_auth_token_error() { + setupRelayScenario("enabled"); + gFxAccounts.getOAuthToken.restore(); + const oauthTokenStub = sinon.stub(gFxAccounts, "getOAuthToken").throws(); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL_PATH, + }, + async function (browser) { + await openRelayAC(browser); + const notificationPopup = document.getElementById("notification-popup"); + const notificationShown = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "shown" + ); + const notificationHidden = BrowserTestUtils.waitForPopupEvent( + notificationPopup, + "hidden" + ); + + await notificationShown; + + notificationPopup + .querySelector("button.popup-notification-primary-button") + .click(); + + await notificationHidden; + + await assertEvents([ + { + object: "fill_username", + method: "shown", + extra: { error_code: "0" }, + }, + { + object: "fill_username", + method: "clicked", + extra: { error_code: "0" }, + }, + { + object: "fill_username", + method: "shown", + extra: { error_code: "418" }, + }, + ]); + } + ); + oauthTokenStub.restore(); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js b/toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js new file mode 100644 index 0000000000..e1ea3af99a --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_telemetry_SignUpFormRuleset.js @@ -0,0 +1,57 @@ +"use strict"; + +const SIGNUP_DETECTION_HISTOGRAM = "PWMGR_SIGNUP_FORM_DETECTION_MS"; +const TEST_URL = `https://example.com${DIRECTORY_PATH}form_signup_detection.html`; + +/** + * + * @param {Object} histogramData The histogram data to examine + * @returns The amount of entries found in the histogram data + */ +function countEntries(histogramData) { + info(typeof histogramData); + return histogramData + ? Object.values(histogramData.values).reduce((a, b) => a + b, 0) + : null; +} + +/** + * @param {String} id The histogram to examine + * @param {Number} expected The expected amount of entries for a histogram + */ +async function countEntriesOfChildHistogram(id, expected) { + let histogram; + await TestUtils.waitForCondition(() => { + let histograms = Services.telemetry.getSnapshotForHistograms( + "main", + false + ).content; + + histogram = histograms[id]; + + return !!histogram && countEntries(histogram) == expected; + }, `The histogram ${id} was expected to have ${expected} entries.`); + Assert.equal(countEntries(histogram), expected); +} + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [["signon.signupDetection.enabled", true]], + }); + Services.telemetry.getHistogramById(SIGNUP_DETECTION_HISTOGRAM).clear(); +}); + +add_task(async () => { + let formProcessed = listenForTestNotification("FormProcessed", 2); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + await formProcessed; + + info( + "Test case: When loading the document the two <form> HTML elements are processed and each one is run against the SignUpFormRuleset. After the page load the histogram PWMGR_SIGNUP_FORM_DETECTION_MS should have two entries." + ); + await countEntriesOfChildHistogram(SIGNUP_DETECTION_HISTOGRAM, 2); + + gBrowser.removeTab(tab); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_test_changeContentInputValue.js b/toolkit/components/passwordmgr/test/browser/browser_test_changeContentInputValue.js new file mode 100644 index 0000000000..d0016708b2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_test_changeContentInputValue.js @@ -0,0 +1,129 @@ +/** + * Tests head.js#changeContentInputValue. + */ + +"use strict"; + +// The origin for the test URIs. +const TEST_ORIGIN = "https://example.com"; +const BASIC_FORM_PAGE_PATH = DIRECTORY_PATH + "form_basic.html"; +const USERNAME_INPUT_SELECTOR = "#form-basic-username"; + +let testCases = [ + { + name: "blank string should clear input value", + originalValue: "start text", + inputEvent: "", + expectedKeypresses: ["Backspace"], + }, + { + name: "input value that adds to original string should only add the difference", + originalValue: "start text", + inputEvent: "start text!!!", + expectedKeypresses: ["!", "!", "!"], + }, + { + name: "input value that is a subset of original string should only delete the difference", + originalValue: "start text", + inputEvent: "start", + expectedKeypresses: ["Backspace"], + }, + { + name: "input value that is unrelated to the original string should replace it", + originalValue: "start text", + inputEvent: "wut?", + expectedKeypresses: ["w", "u", "t", "?"], + }, +]; + +for (let testData of testCases) { + let tmp = { + async [testData.name]() { + await testStringChange(testData); + }, + }; + add_task(tmp[testData.name]); +} + +async function testStringChange({ + name, + originalValue, + inputEvent, + expectedKeypresses, +}) { + info("Starting test " + name); + await LoginTestUtils.clearData(); + + await LoginTestUtils.addLogin({ + username: originalValue, + password: "password", + }); + + let formProcessedPromise = listenForTestNotification("FormProcessed"); + let url = TEST_ORIGIN + BASIC_FORM_PAGE_PATH; + info("Opening tab with url: " + url); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url, + }, + async function (browser) { + info(`Opened tab with url: ${url}, waiting for focus`); + await SimpleTest.promiseFocus(browser.ownerGlobal); + info("Waiting for form-processed message"); + await formProcessedPromise; + await checkForm(browser, originalValue); + info("form checked"); + + await ContentTask.spawn( + browser, + { USERNAME_INPUT_SELECTOR, expectedKeypresses }, + async function ({ USERNAME_INPUT_SELECTOR, expectedKeypresses }) { + let input = content.document.querySelector(USERNAME_INPUT_SELECTOR); + + let verifyKeyListener = event => { + Assert.equal( + expectedKeypresses[0], + event.key, + "Key press matches expected value" + ); + expectedKeypresses.shift(); + + if (!expectedKeypresses.length) { + input.removeEventListner("keydown", verifyKeyListener); + input.addEventListener("keydown", () => { + throw new Error("Unexpected keypress encountered"); + }); + } + }; + + input.addEventListener("keydown", verifyKeyListener); + } + ); + + changeContentInputValue(browser, USERNAME_INPUT_SELECTOR, inputEvent); + } + ); +} + +async function checkForm(browser, expectedUsername) { + await ContentTask.spawn( + browser, + { + expectedUsername, + USERNAME_INPUT_SELECTOR, + }, + async function contentCheckForm({ + expectedUsername, + USERNAME_INPUT_SELECTOR, + }) { + let field = content.document.querySelector(USERNAME_INPUT_SELECTOR); + Assert.equal( + field.value, + expectedUsername, + `Username field has teh expected initial value '${expectedUsername}'` + ); + } + ); +} diff --git a/toolkit/components/passwordmgr/test/browser/browser_username_only_form_telemetry.js b/toolkit/components/passwordmgr/test/browser/browser_username_only_form_telemetry.js new file mode 100644 index 0000000000..54304c24ac --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_username_only_form_telemetry.js @@ -0,0 +1,198 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +async function setupForms(numUsernameOnly, numBasic, numOther) { + const TEST_HOSTNAME = "https://example.com"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_HOSTNAME + DIRECTORY_PATH + "empty.html" + ); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [ + { + numUsernameOnly, + numBasic, + }, + ], + async function (data) { + // type: 1: basic, 2:usernameOnly, 3:other + function addForm(type) { + const form = content.document.createElement("form"); + content.document.body.appendChild(form); + + const user = content.document.createElement("input"); + if (type === 3) { + user.type = "url"; + } else { + user.type = "text"; + user.autocomplete = "username"; + } + form.appendChild(user); + + if (type === 1) { + const password = content.document.createElement("input"); + password.type = "password"; + form.appendChild(password); + } + } + for (let i = 0; i < data.numBasic; i++) { + addForm(1); + } + for (let i = 0; i < data.numUsernameOnly; i++) { + addForm(2); + } + for (let i = 0; i < data.numOther; i++) { + addForm(3); + } + } + ); + + return tab; +} + +async function checkChildHistogram(id, index, expected) { + let histogram; + await TestUtils.waitForCondition(() => { + let histograms = Services.telemetry.getSnapshotForHistograms( + "main", + false /* clear */ + ).content; + + histogram = histograms[id]; + return !!histogram && histogram.values[index] == expected; + }); + Assert.equal(histogram.values[index], expected); +} + +add_setup(async function () { + SpecialPowers.pushPrefEnv({ + set: [ + ["signon.usernameOnlyForm.enabled", true], + ["signon.usernameOnlyForm.lookupThreshold", 100], // ignore the threshold in test + ], + }); + + // Wait 1sec to make sure all the telemetry data recorded prior to the beginning of the + // test is cleared. + await new Promise(res => setTimeout(res, 1000)); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); + +add_task(async function test_oneUsernameOnlyForm() { + const numUsernameOnlyForms = 1; + const numBasicForms = 0; + + // number of "other" forms doesn't change the outcome, set it to 2 here and + // in the following testcase just to ensure it doesn't affect the result. + let tab = await setupForms(numUsernameOnlyForms, numBasicForms, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + numUsernameOnlyForms, + 1 + ); + + BrowserTestUtils.removeTab(tab); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); + +add_task(async function test_multipleUsernameOnlyForms() { + const numUsernameOnlyForms = 3; + const numBasicForms = 2; + + let tab = await setupForms(numUsernameOnlyForms, numBasicForms, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + 5, + 1 + ); + + BrowserTestUtils.removeTab(tab); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); + +add_task(async function test_multipleDocument() { + // The first document + let numUsernameOnlyForms1 = 2; + let numBasicForms1 = 2; + + let tab1 = await setupForms(numUsernameOnlyForms1, numBasicForms1, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms1 + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + numUsernameOnlyForms1 + numBasicForms1, + 1 + ); + + // The second document + let numUsernameOnlyForms2 = 15; + let numBasicForms2 = 3; + + let tab2 = await setupForms(numUsernameOnlyForms2, numBasicForms2, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms1 + numUsernameOnlyForms2 + ); + + // the result is stacked, so the new document add a counter to all + // buckets under "numUsernameOnlyForms2 + numBasicForms2" + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + numUsernameOnlyForms1 + numBasicForms1, + 2 + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + numUsernameOnlyForms2 + numBasicForms2, + 1 + ); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); + +add_task(async function test_tooManyUsernameOnlyForms() { + const numUsernameOnlyForms = 25; + const numBasicForms = 2; + + let tab = await setupForms(numUsernameOnlyForms, numBasicForms, 2); + + await checkChildHistogram( + "PWMGR_IS_USERNAME_ONLY_FORM", + 1, + numUsernameOnlyForms + ); + await checkChildHistogram( + "PWMGR_NUM_FORM_HAS_POSSIBLE_USERNAME_EVENT_PER_DOC", + 21, + numUsernameOnlyForms + numBasicForms - 20 // maximum is 20 + ); + + BrowserTestUtils.removeTab(tab); + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js b/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js new file mode 100644 index 0000000000..03ac74e9ef --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/browser_username_select_dialog.js @@ -0,0 +1,177 @@ +/* + * Test username selection dialog, on password update from a p-only form, + * when there are multiple saved logins on the domain. + */ + +// Copied from prompt_common.js. TODO: share the code. +function getSelectDialogDoc() { + // Trudge through all the open windows, until we find the one + // that has selectDialog.xhtml loaded. + // var enumerator = Services.wm.getEnumerator("navigator:browser"); + for (let { docShell } of Services.wm.getEnumerator(null)) { + var containedDocShells = docShell.getAllDocShellsInSubtree( + docShell.typeChrome, + docShell.ENUMERATE_FORWARDS + ); + for (let childDocShell of containedDocShells) { + // We don't want it if it's not done loading. + if (childDocShell.busyFlags != Ci.nsIDocShell.BUSY_FLAGS_NONE) { + continue; + } + var childDoc = childDocShell.contentViewer.DOMDocument; + + if ( + childDoc.location.href == "chrome://global/content/selectDialog.xhtml" + ) { + return childDoc; + } + } + } + + return null; +} + +let nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" +); +let login1 = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1", + "notifyp1", + "user", + "pass" +); +let login1B = new nsLoginInfo( + "https://example.com", + "https://example.com", + null, + "notifyu1B", + "notifyp1B", + "user", + "pass" +); + +add_task(async function test_changeUPLoginOnPUpdateForm_accept() { + info( + "Select an u+p login from multiple logins, on password update form, and accept." + ); + await Services.logins.addLogins([login1, login1B]); + + let selectDialogPromise = TestUtils.topicObserved("select-dialog-loaded"); + + await testSubmittingLoginForm( + "subtst_notifications_change_p.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + + info("Waiting for select dialog to appear."); + let doc = (await selectDialogPromise)[0].document; + let dialog = doc.getElementsByTagName("dialog")[0]; + let listbox = doc.getElementById("list"); + + Assert.equal(listbox.selectedIndex, 0, "Checking selected index"); + Assert.equal(listbox.itemCount, 2, "Checking selected length"); + ["notifyu1", "notifyu1B"].forEach((username, i) => { + Assert.equal( + listbox.getItemAtIndex(i).label, + username, + "Check username selection on dialog" + ); + }); + + dialog.acceptDialog(); + + await TestUtils.waitForCondition(() => { + return !getSelectDialogDoc(); + }, "Wait for selection dialog to disappear."); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have 2 logins"); + + let login = SpecialPowers.wrap(logins[0]).QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "pass2", "Check the password changed"); + Assert.equal(login.timesUsed, 2, "Check times used"); + + login = SpecialPowers.wrap(logins[1]).QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1B", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + // cleanup + login1.password = "pass2"; + Services.logins.removeLogin(login1); + login1.password = "notifyp1"; + + Services.logins.removeLogin(login1B); +}); + +add_task(async function test_changeUPLoginOnPUpdateForm_cancel() { + info( + "Select an u+p login from multiple logins, on password update form, and cancel." + ); + await Services.logins.addLogins([login1, login1B]); + + let selectDialogPromise = TestUtils.topicObserved("select-dialog-loaded"); + + await testSubmittingLoginForm( + "subtst_notifications_change_p.html", + async function (fieldValues) { + Assert.equal(fieldValues.username, "null", "Checking submitted username"); + Assert.equal( + fieldValues.password, + "pass2", + "Checking submitted password" + ); + + info("Waiting for select dialog to appear."); + let doc = (await selectDialogPromise)[0].document; + let dialog = doc.getElementsByTagName("dialog")[0]; + let listbox = doc.getElementById("list"); + + Assert.equal(listbox.selectedIndex, 0, "Checking selected index"); + Assert.equal(listbox.itemCount, 2, "Checking selected length"); + ["notifyu1", "notifyu1B"].forEach((username, i) => { + Assert.equal( + listbox.getItemAtIndex(i).label, + username, + "Check username selection on dialog" + ); + }); + + dialog.cancelDialog(); + + await TestUtils.waitForCondition(() => { + return !getSelectDialogDoc(); + }, "Wait for selection dialog to disappear."); + } + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 2, "Should have 2 logins"); + + let login = SpecialPowers.wrap(logins[0]).QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + login = SpecialPowers.wrap(logins[1]).QueryInterface(Ci.nsILoginMetaInfo); + Assert.equal(login.username, "notifyu1B", "Check the username unchanged"); + Assert.equal(login.password, "notifyp1B", "Check the password unchanged"); + Assert.equal(login.timesUsed, 1, "Check times used"); + + // cleanup + Services.logins.removeLogin(login1); + Services.logins.removeLogin(login1B); +}); diff --git a/toolkit/components/passwordmgr/test/browser/empty.html b/toolkit/components/passwordmgr/test/browser/empty.html new file mode 100644 index 0000000000..1ad28bb1f7 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/empty.html @@ -0,0 +1,8 @@ +<!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_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_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_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_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..de01223239 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/form_signup_detection.html @@ -0,0 +1,31 @@ +<!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> +</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..70a1f685e2 --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/head.js @@ -0,0 +1,965 @@ +const DIRECTORY_PATH = "/browser/toolkit/components/passwordmgr/test/browser/"; + +var { LoginTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/LoginTestUtils.sys.mjs" +); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +add_setup(async function common_initialize() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["signon.rememberSignons", true], + ["signon.testOnlyUserHasInteractedByPrefValue", true], + ["signon.testOnlyUserHasInteractedWithDocument", true], + ["toolkit.telemetry.ipcBatchTimeout", 0], + ], + }); + if (LoginHelper.relatedRealmsEnabled) { + await LoginTestUtils.remoteSettings.setupWebsitesWithSharedCredentials(); + registerCleanupFunction(async function () { + await LoginTestUtils.remoteSettings.cleanWebsitesWithSharedCredentials(); + }); + } +}); + +registerCleanupFunction( + async function cleanup_removeAllLoginsAndResetRecipes() { + await SpecialPowers.popPrefEnv(); + + LoginTestUtils.clearData(); + LoginTestUtils.resetGeneratedPasswordsCache(); + clearHttpAuths(); + Services.telemetry.clearEvents(); + + let recipeParent = LoginTestUtils.recipes.getRecipeParent(); + if (!recipeParent) { + // No need to reset the recipes if the recipe module wasn't even loaded. + return; + } + await recipeParent.then(recipeParentResult => recipeParentResult.reset()); + + await cleanupDoorhanger(); + await cleanupPasswordNotifications(); + await closePopup(document.getElementById("contentAreaContextMenu")); + await closePopup(document.getElementById("PopupAutoComplete")); + } +); + +/** + * Compared logins in storage to expected values + * + * @param {array} expectedLogins + * An array of expected login properties + * @return {nsILoginInfo[]} - All saved logins sorted by timeCreated + */ +function verifyLogins(expectedLogins = []) { + let allLogins = Services.logins.getAllLogins(); + allLogins.sort((a, b) => a.timeCreated > b.timeCreated); + Assert.equal( + allLogins.length, + expectedLogins.length, + "Check actual number of logins matches the number of provided expected property-sets" + ); + for (let i = 0; i < expectedLogins.length; i++) { + // if the test doesn't care about comparing properties for this login, just pass false/null. + let expected = expectedLogins[i]; + if (expected) { + let login = allLogins[i]; + if (typeof expected.timesUsed !== "undefined") { + Assert.equal(login.timesUsed, expected.timesUsed, "Check timesUsed"); + } + if (typeof expected.passwordLength !== "undefined") { + Assert.equal( + login.password.length, + expected.passwordLength, + "Check passwordLength" + ); + } + if (typeof expected.username !== "undefined") { + Assert.equal(login.username, expected.username, "Check username"); + } + if (typeof expected.password !== "undefined") { + Assert.equal(login.password, expected.password, "Check password"); + } + if (typeof expected.usedSince !== "undefined") { + Assert.ok( + login.timeLastUsed > expected.usedSince, + "Check timeLastUsed" + ); + } + if (typeof expected.passwordChangedSince !== "undefined") { + Assert.ok( + login.timePasswordChanged > expected.passwordChangedSince, + "Check timePasswordChanged" + ); + } + if (typeof expected.timeCreated !== "undefined") { + Assert.equal( + login.timeCreated, + expected.timeCreated, + "Check timeCreated" + ); + } + } + } + return allLogins; +} + +/** + * Submit the content form and return a promise resolving to the username and + * password values echoed out in the response + * + * @param {Object} [browser] - browser with the form + * @param {String = ""} formAction - Optional url to set the form's action to before submitting + * @param {Object = null} selectorValues - Optional object with field values to set before form submission + * @param {Object = null} responseSelectors - Optional object with selectors to find the username and password in the response + */ +async function submitFormAndGetResults( + browser, + formAction = "", + selectorValues, + responseSelectors +) { + async function contentSubmitForm([contentFormAction, contentSelectorValues]) { + const { WrapPrivileged } = ChromeUtils.importESModule( + "resource://testing-common/WrapPrivileged.sys.mjs" + ); + let doc = content.document; + let form = doc.querySelector("form"); + if (contentFormAction) { + form.action = contentFormAction; + } + for (let [sel, value] of Object.entries(contentSelectorValues)) { + try { + let field = doc.querySelector(sel); + let gotInput = ContentTaskUtils.waitForEvent( + field, + "input", + "Got input event on " + sel + ); + // we don't get an input event if the new value == the old + field.value = "###"; + WrapPrivileged.wrap(field, this).setUserInput(value); + await gotInput; + } catch (ex) { + throw new Error( + `submitForm: Couldn't set value of field at: ${sel}: ${ex.message}` + ); + } + } + form.submit(); + } + + let loadPromise = BrowserTestUtils.browserLoaded(browser); + await SpecialPowers.spawn( + browser, + [[formAction, selectorValues]], + contentSubmitForm + ); + await loadPromise; + + let result = await getFormSubmitResponseResult( + browser, + formAction, + responseSelectors + ); + return result; +} + +/** + * Wait for a given result page to load and return a promise resolving to an object with the parsed-out + * username/password values from the response + * + * @param {Object} [browser] - browser which is loading this page + * @param {String} resultURL - the path or filename to look for in the content.location + * @param {Object = null} - Optional object with selectors to find the username and password in the response + */ +async function getFormSubmitResponseResult( + browser, + resultURL = "/formsubmit.sjs", + { username = "#user", password = "#pass" } = {} +) { + // default selectors are for the response page produced by formsubmit.sjs + let fieldValues = await ContentTask.spawn( + browser, + { + resultURL, + usernameSelector: username, + passwordSelector: password, + }, + async function ({ resultURL, usernameSelector, passwordSelector }) { + await ContentTaskUtils.waitForCondition(() => { + return ( + content.location.pathname.endsWith(resultURL) && + content.document.readyState == "complete" + ); + }, `Wait for form submission load (${resultURL})`); + let username = + content.document.querySelector(usernameSelector).textContent; + // Bug 1686071: Since generated passwords can have special characters in them, + // we need to unescape the characters. These special characters are automatically escaped + // when we submit a form in `submitFormAndGetResults`. + // Otherwise certain tests will intermittently fail when these special characters are present in the passwords. + let password = unescape( + content.document.querySelector(passwordSelector).textContent + ); + return { + username, + password, + }; + } + ); + return fieldValues; +} + +/** + * Loads a test page in `DIRECTORY_URL` which automatically submits to formsubmit.sjs and returns a + * promise resolving with the field values when the optional `aTaskFn` is done. + * + * @param {String} aPageFile - test page file name which auto-submits to formsubmit.sjs + * @param {Function} aTaskFn - task which can be run before the tab closes. + * @param {String} [aOrigin="https://example.com"] - origin of the server to use + * to load `aPageFile`. + */ +function testSubmittingLoginForm( + aPageFile, + aTaskFn, + aOrigin = "https://example.com" +) { + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: aOrigin + DIRECTORY_PATH + aPageFile, + }, + async function (browser) { + Assert.ok(true, "loaded " + aPageFile); + let fieldValues = await getFormSubmitResponseResult( + browser, + "/formsubmit.sjs" + ); + Assert.ok(true, "form submission loaded"); + if (aTaskFn) { + await aTaskFn(fieldValues, browser); + } + return fieldValues; + } + ); +} +/** + * Loads a test page in `DIRECTORY_URL` which automatically submits to formsubmit.sjs and returns a + * promise resolving with the field values when the optional `aTaskFn` is done. + * + * @param {String} aPageFile - test page file name which auto-submits to formsubmit.sjs + * @param {Function} aTaskFn - task which can be run before the tab closes. + * @param {String} [aOrigin="http://example.com"] - origin of the server to use + * to load `aPageFile`. + */ +function testSubmittingLoginFormHTTP( + aPageFile, + aTaskFn, + aOrigin = "http://example.com" +) { + return testSubmittingLoginForm(aPageFile, aTaskFn, aOrigin); +} + +function checkOnlyLoginWasUsedTwice({ justChanged }) { + // Check to make sure we updated the timestamps and use count on the + // existing login that was submitted for the test. + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 1, "Should only have 1 login"); + Assert.ok(logins[0] instanceof Ci.nsILoginMetaInfo, "metainfo QI"); + Assert.equal( + logins[0].timesUsed, + 2, + "check .timesUsed for existing login submission" + ); + Assert.ok( + logins[0].timeCreated < logins[0].timeLastUsed, + "timeLastUsed bumped" + ); + if (justChanged) { + Assert.equal( + logins[0].timeLastUsed, + logins[0].timePasswordChanged, + "timeLastUsed == timePasswordChanged" + ); + } else { + Assert.equal( + logins[0].timeCreated, + logins[0].timePasswordChanged, + "timeChanged not updated" + ); + } +} + +function clearHttpAuths() { + let authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService( + Ci.nsIHttpAuthManager + ); + authMgr.clearAll(); +} + +// Begin popup notification (doorhanger) functions // + +const REMEMBER_BUTTON = "button"; +const NEVER_MENUITEM = 0; + +const CHANGE_BUTTON = "button"; +const DONT_CHANGE_BUTTON = "secondaryButton"; +const REMOVE_LOGIN_MENUITEM = 0; + +/** + * Checks if we have a password capture popup notification + * of the right type and with the right label. + * + * @param {String} aKind The desired `passwordNotificationType` ("any" for any type) + * @param {Object} [popupNotifications = PopupNotifications] + * @param {Object} [browser = null] Optional browser whose notifications should be searched. + * @return the found password popup notification. + */ +function getCaptureDoorhanger( + aKind, + popupNotifications = PopupNotifications, + browser = null +) { + Assert.ok(true, "Looking for " + aKind + " popup notification"); + let notification = popupNotifications.getNotification("password", browser); + if (!aKind) { + throw new Error( + "getCaptureDoorhanger needs aKind to be a non-empty string" + ); + } + if (aKind !== "any" && notification) { + Assert.equal( + notification.options.passwordNotificationType, + aKind, + "Notification type matches." + ); + if (aKind == "password-change") { + Assert.equal( + notification.mainAction.label, + "Update", + "Main action label matches update doorhanger." + ); + } else if (aKind == "password-save") { + Assert.equal( + notification.mainAction.label, + "Save", + "Main action label matches save doorhanger." + ); + } + } + return notification; +} + +async function getCaptureDoorhangerThatMayOpen( + aKind, + popupNotifications = PopupNotifications, + browser = null +) { + let notif = getCaptureDoorhanger(aKind, popupNotifications, browser); + if (notif && !notif.dismissed) { + if (popupNotifications.panel.state !== "open") { + await BrowserTestUtils.waitForEvent( + popupNotifications.panel, + "popupshown" + ); + } + } + return notif; +} + +async function waitForDoorhanger(browser, type) { + let notif; + await TestUtils.waitForCondition(() => { + notif = PopupNotifications.getNotification("password", browser); + if (notif && type !== "any") { + return ( + notif.options.passwordNotificationType == type && + notif.anchorElement && + BrowserTestUtils.is_visible(notif.anchorElement) + ); + } + return notif; + }, `Waiting for a ${type} notification`); + return notif; +} + +async function hideDoorhangerPopup() { + info("hideDoorhangerPopup"); + if (!PopupNotifications.isPanelOpen) { + return; + } + let { panel } = PopupNotifications; + let promiseHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden"); + panel.hidePopup(); + await promiseHidden; + info("got popuphidden from notification panel"); +} + +function getDoorhangerButton(aPopup, aButtonIndex) { + let notifications = aPopup.owner.panel.children; + Assert.ok(!!notifications.length, "at least one notification displayed"); + Assert.ok(true, notifications.length + " notification(s)"); + let notification = notifications[0]; + + if (aButtonIndex == "button") { + return notification.button; + } else if (aButtonIndex == "secondaryButton") { + return notification.secondaryButton; + } + return notification.menupopup.querySelectorAll("menuitem")[aButtonIndex]; +} + +/** + * Clicks the specified popup notification button. + * + * @param {Element} aPopup Popup Notification element + * @param {Number} aButtonIndex Number indicating which button to click. + * See the constants in this file. + */ +function clickDoorhangerButton(aPopup, aButtonIndex) { + Assert.ok(true, "Looking for action at index " + aButtonIndex); + + let button = getDoorhangerButton(aPopup, aButtonIndex); + if (aButtonIndex == "button") { + Assert.ok(true, "Triggering main action"); + } else if (aButtonIndex == "secondaryButton") { + Assert.ok(true, "Triggering secondary action"); + } else { + Assert.ok(true, "Triggering menuitem # " + aButtonIndex); + } + button.doCommand(); +} + +async function cleanupDoorhanger(notif) { + let PN = notif ? notif.owner : PopupNotifications; + if (notif) { + notif.remove(); + } + let promiseHidden = PN.isPanelOpen + ? BrowserTestUtils.waitForEvent(PN.panel, "popuphidden") + : Promise.resolve(); + PN.panel.hidePopup(); + await promiseHidden; +} + +async function cleanupPasswordNotifications( + popupNotifications = PopupNotifications +) { + let notif; + while ((notif = popupNotifications.getNotification("password"))) { + notif.remove(); + } +} + +async function clearMessageCache(browser) { + await SpecialPowers.spawn(browser, [], async () => { + const { LoginManagerChild } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerChild.sys.mjs" + ); + let docState = LoginManagerChild.forWindow(content).stateForDocument( + content.document + ); + docState.lastSubmittedValuesByRootElement = new content.WeakMap(); + }); +} + +/** + * Checks the doorhanger's username and password. + * + * @param {String} username The username. + * @param {String} password The password. + */ +async function checkDoorhangerUsernamePassword(username, password) { + await BrowserTestUtils.waitForCondition(() => { + return ( + document.getElementById("password-notification-username").value == + username && + document.getElementById("password-notification-password").value == + password + ); + }, "Wait for nsLoginManagerPrompter writeDataToUI() to update to the correct username/password values"); +} + +/** + * Change the doorhanger's username and password input values. + * + * @param {object} newValues + * named values to update + * @param {string} [newValues.password = undefined] + * An optional string value to replace whatever is in the password field + * @param {string} [newValues.username = undefined] + * An optional string value to replace whatever is in the username field + * @param {Object} [popupNotifications = PopupNotifications] + */ +async function updateDoorhangerInputValues( + newValues, + popupNotifications = PopupNotifications +) { + let { panel } = popupNotifications; + if (popupNotifications.panel.state !== "open") { + await BrowserTestUtils.waitForEvent(popupNotifications.panel, "popupshown"); + } + Assert.equal(panel.state, "open", "Check the doorhanger is already open"); + + let notifElem = panel.childNodes[0]; + + // Note: setUserInput does not reliably dispatch input events from chrome elements? + async function setInputValue(target, value) { + info(`setInputValue: on target: ${target.id}, value: ${value}`); + target.focus(); + target.select(); + info( + `setInputValue: current value: '${target.value}', setting new value '${value}'` + ); + await EventUtils.synthesizeKey("KEY_Backspace"); + await EventUtils.sendString(value); + await EventUtils.synthesizeKey("KEY_Tab"); + return Promise.resolve(); + } + + let passwordField = notifElem.querySelector( + "#password-notification-password" + ); + let usernameField = notifElem.querySelector( + "#password-notification-username" + ); + + if (typeof newValues.password !== "undefined") { + if (passwordField.value !== newValues.password) { + await setInputValue(passwordField, newValues.password); + } + } + if (typeof newValues.username !== "undefined") { + if (usernameField.value !== newValues.username) { + await setInputValue(usernameField, newValues.username); + } + } +} + +/** + * Open doorhanger autocomplete popup and select a username value. + * + * @param {string} text the text value of the username that should be selected. + * Noop if `text` is falsy. + */ +async function selectDoorhangerUsername(text) { + await _selectDoorhanger( + text, + "#password-notification-username", + "#password-notification-username-dropmarker" + ); +} + +/** + * Open doorhanger autocomplete popup and select a password value. + * + * @param {string} text the text value of the password that should be selected. + * Noop if `text` is falsy. + */ +async function selectDoorhangerPassword(text) { + await _selectDoorhanger( + text, + "#password-notification-password", + "#password-notification-password-dropmarker" + ); +} + +async function _selectDoorhanger(text, inputSelector, dropmarkerSelector) { + if (!text) { + return; + } + + info("Opening doorhanger suggestion popup"); + + let doorhangerPopup = document.getElementById("password-notification"); + let dropmarker = doorhangerPopup.querySelector(dropmarkerSelector); + + let autocompletePopup = document.getElementById("PopupAutoComplete"); + let popupShown = BrowserTestUtils.waitForEvent( + autocompletePopup, + "popupshown" + ); + // the dropmarker gets un-hidden async when looking up username suggestions + await TestUtils.waitForCondition(() => !dropmarker.hidden); + + EventUtils.synthesizeMouseAtCenter(dropmarker, {}); + + await popupShown; + + let suggestions = [ + ...document + .getElementById("PopupAutoComplete") + .getElementsByTagName("richlistitem"), + ].filter(richlistitem => !richlistitem.collapsed); + + let suggestionText = suggestions.map( + richlistitem => richlistitem.querySelector(".ac-title-text").innerHTML + ); + + let targetIndex = suggestionText.indexOf(text); + Assert.ok(targetIndex != -1, "Suggestions include expected text"); + + let promiseHidden = BrowserTestUtils.waitForEvent( + autocompletePopup, + "popuphidden" + ); + + info("Selecting doorhanger suggestion"); + + EventUtils.synthesizeMouseAtCenter(suggestions[targetIndex], {}); + + await promiseHidden; +} + +// End popup notification (doorhanger) functions // + +async function openPasswordManager(openingFunc, waitForFilter) { + info("waiting for new tab to open"); + let tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + url => url.includes("about:logins") && !url.includes("entryPoint="), + true + ); + await openingFunc(); + let tab = await tabPromise; + Assert.ok(tab, "got password management tab"); + let filterValue; + if (waitForFilter) { + filterValue = await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + let loginFilter = Cu.waiveXrays( + content.document + .querySelector("login-list") + .shadowRoot.querySelector("login-filter") + ); + await ContentTaskUtils.waitForCondition( + () => !!loginFilter.value, + "wait for login-filter to have a value" + ); + return loginFilter.value; + }); + } + return { + filterValue, + close() { + BrowserTestUtils.removeTab(tab); + }, + }; +} + +// Autocomplete popup related functions // + +async function openACPopup( + popup, + browser, + inputSelector, + iframeBrowsingContext = null +) { + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + + await SimpleTest.promiseFocus(browser); + info("content window focused"); + + // Focus the username field to open the popup. + let target = iframeBrowsingContext || browser; + await SpecialPowers.spawn( + target, + [[inputSelector]], + function openAutocomplete(sel) { + content.document.querySelector(sel).focus(); + } + ); + + let shown = await promiseShown; + Assert.ok(shown, "autocomplete popup shown"); + return shown; +} + +async function closePopup(popup) { + if (popup.state == "closed") { + await Promise.resolve(); + } else { + let promiseHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.hidePopup(); + await promiseHidden; + } +} + +async function fillGeneratedPasswordFromOpenACPopup( + browser, + passwordInputSelector +) { + let popup = browser.ownerDocument.getElementById("PopupAutoComplete"); + let item; + + await new Promise(requestAnimationFrame); + await TestUtils.waitForCondition(() => { + item = popup.querySelector(`[originaltype="generatedPassword"]`); + return item && !EventUtils.isHidden(item); + }, "Waiting for item to become visible"); + + let inputEventPromise = ContentTask.spawn( + browser, + [passwordInputSelector], + async function waitForInput(inputSelector) { + let passwordInput = content.document.querySelector(inputSelector); + await ContentTaskUtils.waitForEvent( + passwordInput, + "input", + "Password input value changed" + ); + } + ); + + let passwordGeneratedPromise = listenForTestNotification( + "PasswordEditedOrGenerated" + ); + + info("Clicking the generated password AC item"); + EventUtils.synthesizeMouseAtCenter(item, {}); + info("Waiting for the content input value to change"); + await inputEventPromise; + info("Waiting for the passwordGeneratedPromise"); + await passwordGeneratedPromise; +} + +// Contextmenu functions // + +/** + * Synthesize mouse clicks to open the password manager context menu popup + * for a target password input element. + * + * assertCallback should return true if we should continue or else false. + */ +async function openPasswordContextMenu( + browser, + input, + assertCallback = null, + browsingContext = null, + openFillMenu = null +) { + const doc = browser.ownerDocument; + const CONTEXT_MENU = doc.getElementById("contentAreaContextMenu"); + const POPUP_HEADER = doc.getElementById("fill-login"); + const LOGIN_POPUP = doc.getElementById("fill-login-popup"); + + if (!browsingContext) { + browsingContext = browser.browsingContext; + } + + let contextMenuShownPromise = BrowserTestUtils.waitForEvent( + CONTEXT_MENU, + "popupshown" + ); + + // Synthesize a right mouse click over the password input element, we have to trigger + // both events because formfill code relies on this event happening before the contextmenu + // (which it does for real user input) in order to not show the password autocomplete. + let eventDetails = { type: "mousedown", button: 2 }; + await BrowserTestUtils.synthesizeMouseAtCenter( + input, + eventDetails, + browsingContext + ); + // Synthesize a contextmenu event to actually open the context menu. + eventDetails = { type: "contextmenu", button: 2 }; + await BrowserTestUtils.synthesizeMouseAtCenter( + input, + eventDetails, + browsingContext + ); + + await contextMenuShownPromise; + + if (assertCallback) { + let shouldContinue = await assertCallback(); + if (!shouldContinue) { + return; + } + } + + if (openFillMenu) { + // Open the fill login menu. + let popupShownPromise = BrowserTestUtils.waitForEvent( + LOGIN_POPUP, + "popupshown" + ); + POPUP_HEADER.openMenu(true); + await popupShownPromise; + } +} + +/** + * Listen for the login manager test notification specified by + * expectedMessage. Possible messages: + * FormProcessed - a form was processed after page load. + * FormSubmit - a form was just submitted. + * PasswordEditedOrGenerated - a password was filled in or modified. + * + * The count is the number of that messages to wait for. This should + * typically be used when waiting for the FormProcessed message for a page + * that has subframes to ensure all have been handled. + * + * Returns a promise that will passed additional data specific to the message. + */ +function listenForTestNotification(expectedMessage, count = 1) { + return new Promise(resolve => { + LoginManagerParent.setListenerForTests((msg, data) => { + if (msg == expectedMessage && --count == 0) { + LoginManagerParent.setListenerForTests(null); + info("listenForTestNotification, resolving for message: " + msg); + resolve(data); + } + }); + }); +} + +/** + * Use the contextmenu to fill a field with a generated password + */ +async function doFillGeneratedPasswordContextMenuItem(browser, passwordInput) { + await SimpleTest.promiseFocus(browser); + await openPasswordContextMenu(browser, passwordInput); + + let generatedPasswordItem = document.getElementById( + "fill-login-generated-password" + ); + let generatedPasswordSeparator = document.getElementById( + "passwordmgr-items-separator" + ); + + Assert.ok( + BrowserTestUtils.is_visible(generatedPasswordItem), + "generated password item is visible" + ); + Assert.ok( + BrowserTestUtils.is_visible(generatedPasswordSeparator), + "separator is visible" + ); + + let popup = document.getElementById("PopupAutoComplete"); + Assert.ok(popup, "Got popup"); + let promiseShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + + await new Promise(resolve => { + SimpleTest.executeSoon(resolve); + }); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + contextMenu.activateItem(generatedPasswordItem); + + await promiseShown; + await fillGeneratedPasswordFromOpenACPopup(browser, passwordInput); +} + +// Content form helpers +async function changeContentFormValues( + browser, + selectorValues, + shouldBlur = true +) { + for (let [sel, value] of Object.entries(selectorValues)) { + info("changeContentFormValues, update: " + sel + ", to: " + value); + await changeContentInputValue(browser, sel, value, shouldBlur); + await TestUtils.waitForTick(); + } +} + +async function changeContentInputValue( + browser, + selector, + str, + shouldBlur = true +) { + await SimpleTest.promiseFocus(browser.ownerGlobal); + let oldValue = await ContentTask.spawn(browser, [selector], function (sel) { + return content.document.querySelector(sel).value; + }); + + if (str === oldValue) { + info("no change needed to value of " + selector + ": " + oldValue); + return; + } + info(`changeContentInputValue: from "${oldValue}" to "${str}"`); + await ContentTask.spawn( + browser, + { selector, str, shouldBlur }, + async function ({ selector, str, shouldBlur }) { + const EventUtils = ContentTaskUtils.getEventUtils(content); + let input = content.document.querySelector(selector); + + input.focus(); + if (!str) { + input.select(); + await EventUtils.synthesizeKey("KEY_Backspace", {}, content); + } else if (input.value.startsWith(str)) { + info( + `New string is substring of value: ${str.length}, ${input.value.length}` + ); + input.setSelectionRange(str.length, input.value.length); + await EventUtils.synthesizeKey("KEY_Backspace", {}, content); + } else if (str.startsWith(input.value)) { + info( + `New string appends to value: ${input.value}, ${str.substring( + input.value.length + )}` + ); + input.setSelectionRange(input.value.length, input.value.length); + await EventUtils.sendString(str.substring(input.value.length), content); + } else { + input.select(); + await EventUtils.sendString(str, content); + } + + if (shouldBlur) { + let changedPromise = ContentTaskUtils.waitForEvent(input, "change"); + input.blur(); + await changedPromise; + } + + Assert.equal(str, input.value, `Expected value '${str}' is set on input`); + } + ); + info("Input value changed"); + await TestUtils.waitForTick(); +} + +async function verifyConfirmationHint( + browser, + forceClose, + anchorID = "password-notification-icon" +) { + let hintElem = browser.ownerGlobal.ConfirmationHint._panel; + await BrowserTestUtils.waitForPopupEvent(hintElem, "shown"); + try { + Assert.equal(hintElem.state, "open", "hint popup is open"); + Assert.ok( + BrowserTestUtils.is_visible(hintElem.anchorNode), + "hint anchorNode is visible" + ); + Assert.equal( + hintElem.anchorNode.id, + anchorID, + "Hint should be anchored on the expected notification icon" + ); + info("verifyConfirmationHint, hint is shown and has its anchorNode"); + if (forceClose) { + await closePopup(hintElem); + } else { + info("verifyConfirmationHint, assertion ok, wait for poopuphidden"); + await BrowserTestUtils.waitForPopupEvent(hintElem, "hidden"); + info("verifyConfirmationHint, hintElem popup is hidden"); + } + } catch (ex) { + Assert.ok(false, "Confirmation hint not shown: " + ex.message); + } finally { + info("verifyConfirmationHint promise finalized"); + } +} diff --git a/toolkit/components/passwordmgr/test/browser/insecure_test.html b/toolkit/components/passwordmgr/test/browser/insecure_test.html new file mode 100644 index 0000000000..fedea1428e --- /dev/null +++ b/toolkit/components/passwordmgr/test/browser/insecure_test.html @@ -0,0 +1,9 @@ +<!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> |