const { FXA_PWDMGR_HOST, FXA_PWDMGR_REALM } = ChromeUtils.import( "resource://gre/modules/FxAccountsCommon.js" ); 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 PropertyBag = Components.Constructor( "@mozilla.org/hash-property-bag;1", Ci.nsIWritablePropertyBag ); async function cleanup(engine, server) { await engine._tracker.stop(); await engine.wipeClient(); Svc.Prefs.resetBranch(""); 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 login = await Services.logins.addLoginAsync( new LoginInfo( "https://example.com", "", null, "username", "password", "", "" ) ); 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"); } 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 = Services.logins.findLogins("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 = Services.logins.findLogins("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 = Services.logins.findLogins("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_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 = Services.logins.findLogins("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 = Services.logins.findLogins( "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 = Services.logins.findLogins( "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 = Services.logins.findLogins("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 = Services.logins.findLogins("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.Prefs.set("engine.passwords.validation.interval", 0); Svc.Prefs.set("engine.passwords.validation.percentageChance", 100); Svc.Prefs.set("engine.passwords.validation.maxRecords", -1); Svc.Prefs.set("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", "", "" ); Services.logins.addLogin(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 = Services.logins.findLogins("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 = Services.logins.findLogins("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); } });