const { FXA_PWDMGR_HOST, FXA_PWDMGR_REALM } = ChromeUtils.importESModule( "resource://gre/modules/FxAccountsCommon.sys.mjs" ); const { LoginRec } = ChromeUtils.importESModule( "resource://services-sync/engines/passwords.sys.mjs" ); const { Service } = ChromeUtils.importESModule( "resource://services-sync/service.sys.mjs" ); const LoginInfo = Components.Constructor( "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init" ); const { LoginCSVImport } = ChromeUtils.importESModule( "resource://gre/modules/LoginCSVImport.sys.mjs" ); const { FileTestUtils } = ChromeUtils.importESModule( "resource://testing-common/FileTestUtils.sys.mjs" ); const PropertyBag = Components.Constructor( "@mozilla.org/hash-property-bag;1", Ci.nsIWritablePropertyBag ); async function cleanup(engine, server) { await engine._tracker.stop(); await engine.wipeClient(); engine.lastModified = null; for (const pref of Svc.PrefBranch.getChildList("")) { Svc.PrefBranch.clearUserPref(pref); } Service.recordManager.clearCache(); if (server) { await promiseStopServer(server); } } add_task(async function setup() { // Disable addon sync because AddonManager won't be initialized here. await Service.engineManager.unregister("addons"); await Service.engineManager.unregister("extension-storage"); }); add_task(async function test_ignored_fields() { _("Only changes to syncable fields should be tracked"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); enableValidationPrefs(); let loginInfo = new LoginInfo( "https://example.com", "", null, "username", "password", "", "" ); // Setting syncCounter to -1 so that it will be incremented to 0 when added. loginInfo.syncCounter = -1; let login = await Services.logins.addLoginAsync(loginInfo); login.QueryInterface(Ci.nsILoginMetaInfo); // For `guid`. engine._tracker.start(); try { let nonSyncableProps = new PropertyBag(); nonSyncableProps.setProperty("timeLastUsed", Date.now()); nonSyncableProps.setProperty("timesUsed", 3); Services.logins.modifyLogin(login, nonSyncableProps); let noChanges = await engine.pullNewChanges(); deepEqual(noChanges, {}, "Should not track non-syncable fields"); let syncableProps = new PropertyBag(); syncableProps.setProperty("username", "newuser"); Services.logins.modifyLogin(login, syncableProps); let changes = await engine.pullNewChanges(); deepEqual( Object.keys(changes), [login.guid], "Should track syncable fields" ); } finally { await cleanup(engine, server); } }); add_task(async function test_ignored_sync_credentials() { _("Sync credentials in login manager should be ignored"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); enableValidationPrefs(); engine._tracker.start(); try { let login = await Services.logins.addLoginAsync( new LoginInfo( FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM, "fxa-uid", "creds", "", "" ) ); let noChanges = await engine.pullNewChanges(); deepEqual(noChanges, {}, "Should not track new FxA credentials"); let props = new PropertyBag(); props.setProperty("password", "newcreds"); Services.logins.modifyLogin(login, props); noChanges = await engine.pullNewChanges(); deepEqual(noChanges, {}, "Should not track changes to FxA credentials"); let foundLogins = await Services.logins.searchLoginsAsync({ origin: FXA_PWDMGR_HOST, }); equal(foundLogins.length, 1); equal(foundLogins[0].syncCounter, 0); equal(foundLogins[0].everSynced, false); } finally { await cleanup(engine, server); } }); add_task(async function test_password_engine() { _("Basic password sync test"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); enableValidationPrefs(); _("Add new login to upload during first sync"); let newLogin; { let login = new LoginInfo( "https://example.com", "", null, "username", "password", "", "" ); await Services.logins.addLoginAsync(login); let logins = await Services.logins.searchLoginsAsync({ origin: "https://example.com", }); equal(logins.length, 1, "Should find new login in login manager"); newLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); // Insert a server record that's older, so that we prefer the local one. let rec = new LoginRec("passwords", newLogin.guid); rec.formSubmitURL = newLogin.formActionOrigin; rec.httpRealm = newLogin.httpRealm; rec.hostname = newLogin.origin; rec.username = newLogin.username; rec.password = "sekrit"; let remotePasswordChangeTime = Date.now() - 1 * 60 * 60 * 24 * 1000; rec.timeCreated = remotePasswordChangeTime; rec.timePasswordChanged = remotePasswordChangeTime; collection.insert( newLogin.guid, encryptPayload(rec.cleartext), remotePasswordChangeTime / 1000 ); } _("Add login with older password change time to replace during first sync"); let oldLogin; { let login = new LoginInfo( "https://mozilla.com", "", null, "us3r", "0ldpa55", "", "" ); await Services.logins.addLoginAsync(login); let props = new PropertyBag(); let localPasswordChangeTime = Date.now() - 1 * 60 * 60 * 24 * 1000; props.setProperty("timePasswordChanged", localPasswordChangeTime); Services.logins.modifyLogin(login, props); let logins = await Services.logins.searchLoginsAsync({ origin: "https://mozilla.com", }); equal(logins.length, 1, "Should find old login in login manager"); oldLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); equal(oldLogin.timePasswordChanged, localPasswordChangeTime); let rec = new LoginRec("passwords", oldLogin.guid); rec.hostname = oldLogin.origin; rec.formSubmitURL = oldLogin.formActionOrigin; rec.httpRealm = oldLogin.httpRealm; rec.username = oldLogin.username; // Change the password and bump the password change time to ensure we prefer // the remote one during reconciliation. rec.password = "n3wpa55"; rec.usernameField = oldLogin.usernameField; rec.passwordField = oldLogin.usernameField; rec.timeCreated = oldLogin.timeCreated; rec.timePasswordChanged = Date.now(); collection.insert(oldLogin.guid, encryptPayload(rec.cleartext)); } await engine._tracker.stop(); try { await sync_engine_and_validate_telem(engine, false); let newRec = collection.cleartext(newLogin.guid); equal( newRec.password, "password", "Should update remote password for newer login" ); let logins = await Services.logins.searchLoginsAsync({ origin: "https://mozilla.com", }); equal( logins[0].password, "n3wpa55", "Should update local password for older login" ); } finally { await cleanup(engine, server); } }); add_task(async function test_sync_outgoing() { _("Test syncing outgoing records"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); let loginInfo = new LoginInfo( "http://mozilla.com", "http://mozilla.com", null, "theuser", "thepassword", "username", "password" ); let login = await Services.logins.addLoginAsync(loginInfo); engine._tracker.start(); try { let foundLogins = await Services.logins.searchLoginsAsync({ origin: "http://mozilla.com", }); equal(foundLogins.length, 1); equal(foundLogins[0].syncCounter, 1); equal(foundLogins[0].everSynced, false); equal(collection.count(), 0); let guid = foundLogins[0].QueryInterface(Ci.nsILoginMetaInfo).guid; let changes = await engine.getChangedIDs(); let change = changes[guid]; equal(Object.keys(changes).length, 1); equal(change.counter, 1); ok(!change.deleted); // This test modifies the password and then performs a sync and // then ensures that the synced record is correct. This is done twice // to ensure that syncing occurs correctly when the server record does not // yet exist and when it does already exist. for (let i = 1; i <= 2; i++) { _("Modify the password iteration " + i); foundLogins[0].password = "newpassword" + i; Services.logins.modifyLogin(login, foundLogins[0]); foundLogins = await Services.logins.searchLoginsAsync({ origin: "http://mozilla.com", }); equal(foundLogins.length, 1); // On the first pass, the counter should be 2, one for the add and one for the modify. // No sync has occurred yet so everSynced should be false. // On the second pass, the counter will only be 1 for the modify. The everSynced // property should be true as the sync happened on the last iteration. equal(foundLogins[0].syncCounter, i == 2 ? 1 : 2); equal(foundLogins[0].everSynced, i == 2); changes = await engine.getChangedIDs(); change = changes[guid]; equal(Object.keys(changes).length, 1); equal(change.counter, i == 2 ? 1 : 2); ok(!change.deleted); _("Perform sync after modifying the password"); await sync_engine_and_validate_telem(engine, false); equal(Object.keys(await engine.getChangedIDs()), 0); // The remote login should have the updated password. let newRec = collection.cleartext(guid); equal( newRec.password, "newpassword" + i, "Should update remote password for login" ); foundLogins = await Services.logins.searchLoginsAsync({ origin: "http://mozilla.com", }); equal(foundLogins.length, 1); equal(foundLogins[0].syncCounter, 0); equal(foundLogins[0].everSynced, true); login.password = "newpassword" + i; } // Next, modify the username and sync. _("Modify the username"); foundLogins[0].username = "newuser"; Services.logins.modifyLogin(login, foundLogins[0]); foundLogins = await Services.logins.searchLoginsAsync({ origin: "http://mozilla.com", }); equal(foundLogins.length, 1); equal(foundLogins[0].syncCounter, 1); equal(foundLogins[0].everSynced, true); _("Perform sync after modifying the username"); await sync_engine_and_validate_telem(engine, false); // The remote login should have the updated password. let newRec = collection.cleartext(guid); equal( newRec.username, "newuser", "Should update remote username for login" ); foundLogins = await Services.logins.searchLoginsAsync({ origin: "http://mozilla.com", }); equal(foundLogins.length, 1); equal(foundLogins[0].syncCounter, 0); equal(foundLogins[0].everSynced, true); // Finally, remove the login. The server record should be marked as deleted. _("Remove the login"); equal(collection.count(), 1); equal(Services.logins.countLogins("", "", ""), 2); equal((await Services.logins.getAllLogins()).length, 2); ok(await engine._store.itemExists(guid)); ok((await engine._store.getAllIDs())[guid]); Services.logins.removeLogin(foundLogins[0]); foundLogins = await Services.logins.searchLoginsAsync({ origin: "http://mozilla.com", }); equal(foundLogins.length, 0); changes = await engine.getChangedIDs(); change = changes[guid]; equal(Object.keys(changes).length, 1); equal(change.counter, 1); ok(change.deleted); _("Perform sync after removing the login"); await sync_engine_and_validate_telem(engine, false); equal(collection.count(), 1); let payload = collection.payloads()[0]; ok(payload.deleted); equal(Object.keys(await engine.getChangedIDs()), 0); // All of these should not include the deleted login. Only the FxA password should exist. equal(Services.logins.countLogins("", "", ""), 1); equal((await Services.logins.getAllLogins()).length, 1); ok(!(await engine._store.itemExists(guid))); // getAllIDs includes deleted items but skips the FxA login. ok((await engine._store.getAllIDs())[guid]); let deletedLogin = await engine._store._getLoginFromGUID(guid); equal(deletedLogin.hostname, null, "deleted login hostname"); equal( deletedLogin.formActionOrigin, null, "deleted login formActionOrigin" ); equal(deletedLogin.formSubmitURL, null, "deleted login formSubmitURL"); equal(deletedLogin.httpRealm, null, "deleted login httpRealm"); equal(deletedLogin.username, null, "deleted login username"); equal(deletedLogin.password, null, "deleted login password"); equal(deletedLogin.usernameField, "", "deleted login usernameField"); equal(deletedLogin.passwordField, "", "deleted login passwordField"); equal(deletedLogin.unknownFields, null, "deleted login unknownFields"); equal(deletedLogin.timeCreated, 0, "deleted login timeCreated"); equal(deletedLogin.timeLastUsed, 0, "deleted login timeLastUsed"); equal(deletedLogin.timesUsed, 0, "deleted login timesUsed"); // These fields are not reset when the login is removed. equal(deletedLogin.guid, guid, "deleted login guid"); equal(deletedLogin.everSynced, true, "deleted login everSynced"); equal(deletedLogin.syncCounter, 0, "deleted login syncCounter"); ok( deletedLogin.timePasswordChanged > 0, "deleted login timePasswordChanged" ); } finally { await engine._tracker.stop(); await cleanup(engine, server); } }); add_task(async function test_sync_incoming() { _("Test syncing incoming records"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); const checkFields = [ "formSubmitURL", "hostname", "httpRealm", "username", "password", "usernameField", "passwordField", "timeCreated", ]; let guid1 = Utils.makeGUID(); let details = { formSubmitURL: "https://www.example.com", hostname: "https://www.example.com", httpRealm: null, username: "camel", password: "llama", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), }; try { // This test creates a remote server record and then verifies that the login // has been added locally after the sync occurs. _("Create remote login"); collection.insertRecord(Object.assign({}, details, { id: guid1 })); _("Perform sync when remote login has been added"); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", }); equal(logins.length, 1); equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); checkFields.forEach(field => { equal(logins[0][field], details[field]); }); equal(logins[0].timePasswordChanged, details.timePasswordChanged); equal(logins[0].syncCounter, 0); equal(logins[0].everSynced, true); // Modify the password within the remote record and then sync again. _("Perform sync when remote login's password has been modified"); let newTime = Date.now(); collection.updateRecord( guid1, cleartext => { cleartext.password = "alpaca"; }, newTime / 1000 + 10 ); await engine.setLastSync(newTime / 1000 - 30); await sync_engine_and_validate_telem(engine, false); logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", }); equal(logins.length, 1); details.password = "alpaca"; equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); checkFields.forEach(field => { equal(logins[0][field], details[field]); }); ok(logins[0].timePasswordChanged > details.timePasswordChanged); equal(logins[0].syncCounter, 0); equal(logins[0].everSynced, true); // Modify the username within the remote record and then sync again. _("Perform sync when remote login's username has been modified"); newTime = Date.now(); collection.updateRecord( guid1, cleartext => { cleartext.username = "guanaco"; }, newTime / 1000 + 10 ); await engine.setLastSync(newTime / 1000 - 30); await sync_engine_and_validate_telem(engine, false); logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", }); equal(logins.length, 1); details.username = "guanaco"; equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); checkFields.forEach(field => { equal(logins[0][field], details[field]); }); ok(logins[0].timePasswordChanged > details.timePasswordChanged); equal(logins[0].syncCounter, 0); equal(logins[0].everSynced, true); // Mark the remote record as deleted and then sync again. _("Perform sync when remote login has been marked for deletion"); newTime = Date.now(); collection.updateRecord( guid1, cleartext => { cleartext.deleted = true; }, newTime / 1000 + 10 ); await engine.setLastSync(newTime / 1000 - 30); await sync_engine_and_validate_telem(engine, false); logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", }); equal(logins.length, 0); } finally { await cleanup(engine, server); } }); add_task(async function test_sync_incoming_deleted() { _("Test syncing incoming deleted records"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); let guid1 = Utils.makeGUID(); let details2 = { formSubmitURL: "https://www.example.org", hostname: "https://www.example.org", httpRealm: null, username: "capybara", password: "beaver", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), deleted: true, }; try { // This test creates a remote server record that has been deleted // and then verifies that the login is not imported locally. _("Create remote login"); collection.insertRecord(Object.assign({}, details2, { id: guid1 })); _("Perform sync when remote login has been deleted"); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", }); equal(logins.length, 0); ok(!(await engine._store.getAllIDs())[guid1]); ok(!(await engine._store.itemExists(guid1))); } finally { await cleanup(engine, server); } }); add_task(async function test_sync_incoming_deleted_localchanged_remotenewer() { _( "Test syncing incoming deleted records where the local login has been changed but the remote record is newer" ); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); let loginInfo = new LoginInfo( "http://mozilla.com", "http://mozilla.com", null, "kangaroo", "kaola", "username", "password" ); let login = await Services.logins.addLoginAsync(loginInfo); let guid = login.QueryInterface(Ci.nsILoginMetaInfo).guid; try { _("Perform sync on new login"); await sync_engine_and_validate_telem(engine, false); let foundLogins = await Services.logins.searchLoginsAsync({ origin: "http://mozilla.com", }); foundLogins[0].password = "wallaby"; Services.logins.modifyLogin(login, foundLogins[0]); // Use a time in the future to ensure that the remote record is newer. collection.updateRecord( guid, cleartext => { cleartext.deleted = true; }, Date.now() / 1000 + 1000 ); _( "Perform sync when remote login has been deleted and local login has been changed" ); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://mozilla.com", }); equal(logins.length, 0); ok(await engine._store.getAllIDs()); } finally { await cleanup(engine, server); } }); add_task(async function test_sync_incoming_deleted_localchanged_localnewer() { _( "Test syncing incoming deleted records where the local login has been changed but the local record is newer" ); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); let loginInfo = new LoginInfo( "http://www.mozilla.com", "http://www.mozilla.com", null, "lion", "tiger", "username", "password" ); let login = await Services.logins.addLoginAsync(loginInfo); let guid = login.QueryInterface(Ci.nsILoginMetaInfo).guid; try { _("Perform sync on new login"); await sync_engine_and_validate_telem(engine, false); let foundLogins = await Services.logins.searchLoginsAsync({ origin: "http://www.mozilla.com", }); foundLogins[0].password = "cheetah"; Services.logins.modifyLogin(login, foundLogins[0]); // Use a time in the past to ensure that the local record is newer. collection.updateRecord( guid, cleartext => { cleartext.deleted = true; }, Date.now() / 1000 - 1000 ); _( "Perform sync when remote login has been deleted and local login has been changed" ); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "http://www.mozilla.com", }); equal(logins.length, 1); equal(logins[0].password, "cheetah"); equal(logins[0].syncCounter, 0); equal(logins[0].everSynced, true); ok(await engine._store.getAllIDs()); } finally { await cleanup(engine, server); } }); add_task(async function test_sync_incoming_no_formactionorigin() { _("Test syncing incoming a record where there is no formActionOrigin"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); const checkFields = [ "formSubmitURL", "hostname", "httpRealm", "username", "password", "usernameField", "passwordField", "timeCreated", ]; let guid1 = Utils.makeGUID(); let details = { formSubmitURL: "", hostname: "https://www.example.com", httpRealm: null, username: "rabbit", password: "squirrel", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), }; try { // This test creates a remote server record and then verifies that the login // has been added locally after the sync occurs. _("Create remote login"); collection.insertRecord(Object.assign({}, details, { id: guid1 })); _("Perform sync when remote login has been added"); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", formActionOrigin: "", }); equal(logins.length, 1); equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); checkFields.forEach(field => { equal(logins[0][field], details[field]); }); equal(logins[0].timePasswordChanged, details.timePasswordChanged); equal(logins[0].syncCounter, 0); equal(logins[0].everSynced, true); } finally { await cleanup(engine, server); } }); add_task(async function test_password_dupe() { let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); let guid1 = Utils.makeGUID(); let rec1 = new LoginRec("passwords", guid1); let guid2 = Utils.makeGUID(); let cleartext = { formSubmitURL: "https://www.example.com", hostname: "https://www.example.com", httpRealm: null, username: "foo", password: "bar", usernameField: "username-field", passwordField: "password-field", timeCreated: Math.round(Date.now()), timePasswordChanged: Math.round(Date.now()), }; rec1.cleartext = cleartext; _("Create remote record with same details and guid1"); collection.insert(guid1, encryptPayload(rec1.cleartext)); _("Create remote record with guid2"); collection.insert(guid2, encryptPayload(cleartext)); _("Create local record with same details and guid1"); await engine._store.create(rec1); try { _("Perform sync"); await sync_engine_and_validate_telem(engine, true); let logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", }); equal(logins.length, 1); equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid2); equal(null, collection.payload(guid1)); } finally { await cleanup(engine, server); } }); add_task(async function test_updated_null_password_sync() { _("Ensure updated null login username is converted to a string"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); let guid1 = Utils.makeGUID(); let guid2 = Utils.makeGUID(); let remoteDetails = { formSubmitURL: "https://www.nullupdateexample.com", hostname: "https://www.nullupdateexample.com", httpRealm: null, username: null, password: "bar", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), }; let localDetails = { formSubmitURL: "https://www.nullupdateexample.com", hostname: "https://www.nullupdateexample.com", httpRealm: null, username: "foo", password: "foobar", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), }; _("Create remote record with same details and guid1"); collection.insertRecord(Object.assign({}, remoteDetails, { id: guid1 })); try { _("Create local updated login with null password"); await engine._store.update(Object.assign({}, localDetails, { id: guid2 })); _("Perform sync"); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://www.nullupdateexample.com", }); equal(logins.length, 1); equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); } finally { await cleanup(engine, server); } }); add_task(async function test_updated_undefined_password_sync() { _("Ensure updated undefined login username is converted to a string"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); let guid1 = Utils.makeGUID(); let guid2 = Utils.makeGUID(); let remoteDetails = { formSubmitURL: "https://www.undefinedupdateexample.com", hostname: "https://www.undefinedupdateexample.com", httpRealm: null, username: undefined, password: "bar", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), }; let localDetails = { formSubmitURL: "https://www.undefinedupdateexample.com", hostname: "https://www.undefinedupdateexample.com", httpRealm: null, username: "foo", password: "foobar", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), }; _("Create remote record with same details and guid1"); collection.insertRecord(Object.assign({}, remoteDetails, { id: guid1 })); try { _("Create local updated login with undefined password"); await engine._store.update(Object.assign({}, localDetails, { id: guid2 })); _("Perform sync"); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://www.undefinedupdateexample.com", }); equal(logins.length, 1); equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).guid, guid1); } finally { await cleanup(engine, server); } }); add_task(async function test_new_null_password_sync() { _("Ensure new null login username is converted to a string"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let guid1 = Utils.makeGUID(); let rec1 = new LoginRec("passwords", guid1); rec1.cleartext = { formSubmitURL: "https://www.example.com", hostname: "https://www.example.com", httpRealm: null, username: null, password: "bar", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), }; try { _("Create local login with null password"); await engine._store.create(rec1); _("Perform sync"); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", }); equal(logins.length, 1); notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, null); notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, undefined); equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, ""); } finally { await cleanup(engine, server); } }); add_task(async function test_new_undefined_password_sync() { _("Ensure new undefined login username is converted to a string"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let guid1 = Utils.makeGUID(); let rec1 = new LoginRec("passwords", guid1); rec1.cleartext = { formSubmitURL: "https://www.example.com", hostname: "https://www.example.com", httpRealm: null, username: undefined, password: "bar", usernameField: "username-field", passwordField: "password-field", timeCreated: Date.now(), timePasswordChanged: Date.now(), }; try { _("Create local login with undefined password"); await engine._store.create(rec1); _("Perform sync"); await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://www.example.com", }); equal(logins.length, 1); notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, null); notEqual(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, undefined); equal(logins[0].QueryInterface(Ci.nsILoginMetaInfo).username, ""); } finally { await cleanup(engine, server); } }); add_task(async function test_sync_password_validation() { // This test isn't in test_password_validator to avoid duplicating cleanup. _("Ensure that if a password validation happens, it ends up in the ping"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); Svc.PrefBranch.setIntPref("engine.passwords.validation.interval", 0); Svc.PrefBranch.setIntPref( "engine.passwords.validation.percentageChance", 100 ); Svc.PrefBranch.setIntPref("engine.passwords.validation.maxRecords", -1); Svc.PrefBranch.setBoolPref("engine.passwords.validation.enabled", true); try { let ping = await wait_for_ping(() => Service.sync()); let engineInfo = ping.engines.find(e => e.name == "passwords"); ok(engineInfo, "Engine should be in ping"); let validation = engineInfo.validation; ok(validation, "Engine should have validation info"); } finally { await cleanup(engine, server); } }); add_task(async function test_roundtrip_unknown_fields() { _( "Testing that unknown fields from other clients get roundtripped back to server" ); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); enableValidationPrefs(); _("Add login with older password change time to replace during first sync"); let oldLogin; { let login = new LoginInfo( "https://mozilla.com", "", null, "us3r", "0ldpa55", "", "" ); await Services.logins.addLoginAsync(login); let props = new PropertyBag(); let localPasswordChangeTime = Math.round( Date.now() - 1 * 60 * 60 * 24 * 1000 ); props.setProperty("timePasswordChanged", localPasswordChangeTime); Services.logins.modifyLogin(login, props); let logins = await Services.logins.searchLoginsAsync({ origin: "https://mozilla.com", }); equal(logins.length, 1, "Should find old login in login manager"); oldLogin = logins[0].QueryInterface(Ci.nsILoginMetaInfo); equal(oldLogin.timePasswordChanged, localPasswordChangeTime); let rec = new LoginRec("passwords", oldLogin.guid); rec.hostname = oldLogin.origin; rec.formSubmitURL = oldLogin.formActionOrigin; rec.httpRealm = oldLogin.httpRealm; rec.username = oldLogin.username; // Change the password and bump the password change time to ensure we prefer // the remote one during reconciliation. rec.password = "n3wpa55"; rec.usernameField = oldLogin.usernameField; rec.passwordField = oldLogin.usernameField; rec.timeCreated = oldLogin.timeCreated; rec.timePasswordChanged = Math.round(Date.now()); // pretend other clients have some snazzy new fields // we don't quite understand yet rec.cleartext.someStrField = "I am a str"; rec.cleartext.someObjField = { newField: "I am a new field" }; collection.insert(oldLogin.guid, encryptPayload(rec.cleartext)); } await engine._tracker.stop(); try { await sync_engine_and_validate_telem(engine, false); let logins = await Services.logins.searchLoginsAsync({ origin: "https://mozilla.com", }); equal( logins[0].password, "n3wpa55", "Should update local password for older login" ); let expectedUnknowns = JSON.stringify({ someStrField: "I am a str", someObjField: { newField: "I am a new field" }, }); // Check that the local record has all unknown fields properly // stringified equal(logins[0].unknownFields, expectedUnknowns); // Check that the server has the unknown fields unfurled and on the // top-level record let serverRec = collection.cleartext(oldLogin.guid); equal(serverRec.someStrField, "I am a str"); equal(serverRec.someObjField.newField, "I am a new field"); } finally { await cleanup(engine, server); } }); add_task(async function test_new_passwords_from_csv() { _("Test syncing records imported from a csv file"); let engine = Service.engineManager.get("passwords"); let server = await serverForFoo(engine); await SyncTestingInfrastructure(server); let collection = server.user("foo").collection("passwords"); engine._tracker.start(); let data = [ { hostname: "https://example.com", url: "https://example.com/path", username: "exampleuser", password: "examplepassword", }, { hostname: "https://mozilla.org", url: "https://mozilla.org", username: "mozillauser", password: "mozillapassword", }, { hostname: "https://www.example.org", url: "https://www.example.org/example1/example2", username: "person", password: "mypassword", }, ]; let csvData = ["url,username,login_password"]; for (let row of data) { csvData.push(row.url + "," + row.username + "," + row.password); } let csvFile = FileTestUtils.getTempFile(`firefox_logins.csv`); await IOUtils.writeUTF8(csvFile.path, csvData.join("\r\n")); await LoginCSVImport.importFromCSV(csvFile.path); equal( engine._tracker.score, SCORE_INCREMENT_XLARGE, "Should only get one update notification for import" ); _("Ensure that the csv import is correct"); for (let item of data) { let foundLogins = await Services.logins.searchLoginsAsync({ origin: item.hostname, }); equal(foundLogins.length, 1); equal(foundLogins[0].syncCounter, 1); equal(foundLogins[0].everSynced, false); equal(foundLogins[0].username, item.username); equal(foundLogins[0].password, item.password); } _("Perform sync after modifying the password"); await sync_engine_and_validate_telem(engine, false); _("Verify that the sync counter and status are updated"); for (let item of data) { let foundLogins = await Services.logins.searchLoginsAsync({ origin: item.hostname, }); equal(foundLogins.length, 1); equal(foundLogins[0].syncCounter, 0); equal(foundLogins[0].everSynced, true); equal(foundLogins[0].username, item.username); equal(foundLogins[0].password, item.password); item.guid = foundLogins[0].guid; } equal(Object.keys(await engine.getChangedIDs()), 0); equal(collection.count(), 3); for (let item of data) { // The remote login should have the imported username and password. let newRec = collection.cleartext(item.guid); equal(newRec.username, item.username); equal(newRec.password, item.password); } });