diff options
Diffstat (limited to 'services/sync/tests/unit/test_password_engine.js')
-rw-r--r-- | services/sync/tests/unit/test_password_engine.js | 1257 |
1 files changed, 1257 insertions, 0 deletions
diff --git a/services/sync/tests/unit/test_password_engine.js b/services/sync/tests/unit/test_password_engine.js new file mode 100644 index 0000000000..081403f63d --- /dev/null +++ b/services/sync/tests/unit/test_password_engine.js @@ -0,0 +1,1257 @@ +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); + } +}); |