From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- browser/extensions/formautofill/test/unit/head.js | 378 ++++++ .../test/unit/head_addressComponent.js | 75 ++ .../formautofill/test/unit/test_activeStatus.js | 176 +++ .../test/unit/test_addressComponent_city.js | 27 + .../test/unit/test_addressComponent_country.js | 47 + .../test/unit/test_addressComponent_email.js | 73 ++ .../test/unit/test_addressComponent_name.js | 101 ++ .../unit/test_addressComponent_organization.js | 54 + .../test/unit/test_addressComponent_postal_code.js | 57 + .../test/unit/test_addressComponent_state.js | 32 + .../unit/test_addressComponent_street_address.js | 63 + .../test/unit/test_addressComponent_tel.js | 76 ++ .../test/unit/test_addressDataLoader.js | 113 ++ .../formautofill/test/unit/test_addressRecords.js | 392 ++++++ .../test/unit/test_autofillFormFields.js | 1045 +++++++++++++++ .../test/unit/test_clearPopulatedForm.js | 116 ++ .../test/unit/test_collectFormFields.js | 638 ++++++++++ .../formautofill/test/unit/test_createRecords.js | 377 ++++++ .../test/unit/test_creditCardRecords.js | 686 ++++++++++ .../test/unit/test_extractLabelStrings.js | 77 ++ .../test/unit/test_findLabelElements.js | 100 ++ .../test/unit/test_getAdaptedProfiles.js | 1328 ++++++++++++++++++++ .../test/unit/test_getAdaptedProfiles_locales.js | 272 ++++ .../test/unit/test_getCategoriesFromFieldNames.js | 95 ++ .../test/unit/test_getCreditCardLogo.js | 25 + .../test/unit/test_getFormInputDetails.js | 201 +++ .../formautofill/test/unit/test_getInfo.js | 363 ++++++ .../formautofill/test/unit/test_getRecords.js | 258 ++++ .../test/unit/test_isAddressAutofillAvailable.js | 86 ++ .../formautofill/test/unit/test_isCJKName.js | 80 ++ .../unit/test_isCreditCardAutofillAvailable.js | 84 ++ .../unit/test_isCreditCardOrAddressFieldType.js | 103 ++ .../formautofill/test/unit/test_known_strings.js | 148 +++ .../test/unit/test_markAsAutofillField.js | 201 +++ .../formautofill/test/unit/test_migrateRecords.js | 475 +++++++ .../formautofill/test/unit/test_nameUtils.js | 289 +++++ .../formautofill/test/unit/test_onFormSubmitted.js | 712 +++++++++++ .../test/unit/test_parseAddressFormat.js | 66 + .../test/unit/test_parseStreetAddress.js | 74 ++ .../formautofill/test/unit/test_phoneNumber.js | 399 ++++++ .../test/unit/test_previewFormFields.js | 197 +++ .../test/unit/test_profileAutocompleteResult.js | 452 +++++++ .../formautofill/test/unit/test_reconcile.js | 1176 +++++++++++++++++ .../formautofill/test/unit/test_savedFieldNames.js | 106 ++ .../formautofill/test/unit/test_storage_remove.js | 88 ++ .../test/unit/test_storage_syncfields.js | 496 ++++++++ .../test/unit/test_storage_tombstones.js | 190 +++ .../extensions/formautofill/test/unit/test_sync.js | 1008 +++++++++++++++ .../test_sync_deprecate_address_x_name_fields.js | 1100 ++++++++++++++++ .../unit/test_sync_deprecate_credit_card_v4.js | 248 ++++ .../test/unit/test_toOneLineAddress.js | 64 + .../formautofill/test/unit/test_transformFields.js | 1013 +++++++++++++++ .../formautofill/test/unit/xpcshell.toml | 156 +++ 53 files changed, 16256 insertions(+) create mode 100644 browser/extensions/formautofill/test/unit/head.js create mode 100644 browser/extensions/formautofill/test/unit/head_addressComponent.js create mode 100644 browser/extensions/formautofill/test/unit/test_activeStatus.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_city.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_country.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_email.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_name.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_organization.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_state.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressComponent_tel.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressDataLoader.js create mode 100644 browser/extensions/formautofill/test/unit/test_addressRecords.js create mode 100644 browser/extensions/formautofill/test/unit/test_autofillFormFields.js create mode 100644 browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js create mode 100644 browser/extensions/formautofill/test/unit/test_collectFormFields.js create mode 100644 browser/extensions/formautofill/test/unit/test_createRecords.js create mode 100644 browser/extensions/formautofill/test/unit/test_creditCardRecords.js create mode 100644 browser/extensions/formautofill/test/unit/test_extractLabelStrings.js create mode 100644 browser/extensions/formautofill/test/unit/test_findLabelElements.js create mode 100644 browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js create mode 100644 browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js create mode 100644 browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js create mode 100644 browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js create mode 100644 browser/extensions/formautofill/test/unit/test_getFormInputDetails.js create mode 100644 browser/extensions/formautofill/test/unit/test_getInfo.js create mode 100644 browser/extensions/formautofill/test/unit/test_getRecords.js create mode 100644 browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js create mode 100644 browser/extensions/formautofill/test/unit/test_isCJKName.js create mode 100644 browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js create mode 100644 browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js create mode 100644 browser/extensions/formautofill/test/unit/test_known_strings.js create mode 100644 browser/extensions/formautofill/test/unit/test_markAsAutofillField.js create mode 100644 browser/extensions/formautofill/test/unit/test_migrateRecords.js create mode 100644 browser/extensions/formautofill/test/unit/test_nameUtils.js create mode 100644 browser/extensions/formautofill/test/unit/test_onFormSubmitted.js create mode 100644 browser/extensions/formautofill/test/unit/test_parseAddressFormat.js create mode 100644 browser/extensions/formautofill/test/unit/test_parseStreetAddress.js create mode 100644 browser/extensions/formautofill/test/unit/test_phoneNumber.js create mode 100644 browser/extensions/formautofill/test/unit/test_previewFormFields.js create mode 100644 browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js create mode 100644 browser/extensions/formautofill/test/unit/test_reconcile.js create mode 100644 browser/extensions/formautofill/test/unit/test_savedFieldNames.js create mode 100644 browser/extensions/formautofill/test/unit/test_storage_remove.js create mode 100644 browser/extensions/formautofill/test/unit/test_storage_syncfields.js create mode 100644 browser/extensions/formautofill/test/unit/test_storage_tombstones.js create mode 100644 browser/extensions/formautofill/test/unit/test_sync.js create mode 100644 browser/extensions/formautofill/test/unit/test_sync_deprecate_address_x_name_fields.js create mode 100644 browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js create mode 100644 browser/extensions/formautofill/test/unit/test_toOneLineAddress.js create mode 100644 browser/extensions/formautofill/test/unit/test_transformFields.js create mode 100644 browser/extensions/formautofill/test/unit/xpcshell.toml (limited to 'browser/extensions/formautofill/test/unit') diff --git a/browser/extensions/formautofill/test/unit/head.js b/browser/extensions/formautofill/test/unit/head.js new file mode 100644 index 0000000000..2448c7efff --- /dev/null +++ b/browser/extensions/formautofill/test/unit/head.js @@ -0,0 +1,378 @@ +/** + * Provides infrastructure for automated formautofill components tests. + */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { ObjectUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ObjectUtils.sys.mjs" +); +var { FormLikeFactory } = ChromeUtils.importESModule( + "resource://gre/modules/FormLikeFactory.sys.mjs" +); +var { FormAutofillHandler } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillHandler.sys.mjs" +); +var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +var { ExtensionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs" +); +var { FileTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/FileTestUtils.sys.mjs" +); +var { MockDocument } = ChromeUtils.importESModule( + "resource://testing-common/MockDocument.sys.mjs" +); +var { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +var { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +{ + // We're going to register a mock file source + // with region names based on en-US. This is + // necessary for tests that expect to match + // on region code display names. + const fs = [ + { + path: "toolkit/intl/regionNames.ftl", + source: ` +region-name-us = United States +region-name-nz = New Zealand +region-name-au = Australia +region-name-ca = Canada +region-name-tw = Taiwan + `, + }, + ]; + + let locales = Services.locale.packagedLocales; + const mockSource = L10nFileSource.createMock( + "mock", + "app", + locales, + "resource://mock_path", + fs + ); + L10nRegistry.getInstance().registerSources([mockSource]); +} + +/** + * Mock the return value of Services.focus.elementIsFocusable + * since a field's focusability can't be tested in a unit test. + */ +(function ignoreAFieldsFocusability() { + let stub = sinon.stub(Services, "focus").get(() => { + return { + elementIsFocusable() { + return true; + }, + }; + }); + + registerCleanupFunction(() => { + stub.restore(); + }); +})(); + +do_get_profile(); + +const EXTENSION_ID = "formautofill@mozilla.org"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +function SetPref(name, value) { + switch (typeof value) { + case "string": + Services.prefs.setCharPref(name, value); + break; + case "number": + Services.prefs.setIntPref(name, value); + break; + case "boolean": + Services.prefs.setBoolPref(name, value); + break; + default: + throw new Error("Unknown type"); + } +} + +// Return the current date rounded in the manner that sync does. +function getDateForSync() { + return Math.round(Date.now() / 10) / 100; +} + +async function loadExtension() { + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1.9.2" + ); + await AddonTestUtils.promiseStartupManager(); + + let extensionPath = Services.dirsvc.get("GreD", Ci.nsIFile); + extensionPath.append("browser"); + extensionPath.append("features"); + extensionPath.append(EXTENSION_ID); + + if (!extensionPath.exists()) { + extensionPath.leafName = `${EXTENSION_ID}.xpi`; + } + + let startupPromise = new Promise(resolve => { + const { apiManager } = ExtensionParent; + function onReady(event, extension) { + if (extension.id == EXTENSION_ID) { + apiManager.off("ready", onReady); + resolve(); + } + } + + apiManager.on("ready", onReady); + }); + + await AddonManager.installTemporaryAddon(extensionPath); + await startupPromise; +} + +// Returns a reference to a temporary file that is guaranteed not to exist and +// is cleaned up later. See FileTestUtils.getTempFile for details. +function getTempFile(leafName) { + return FileTestUtils.getTempFile(leafName); +} + +async function initProfileStorage( + fileName, + records, + collectionName = "addresses" +) { + let { FormAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + let path = getTempFile(fileName).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + // AddonTestUtils inserts its own directory provider that manages TmpD. + // It removes that directory at shutdown, which races with shutdown + // handing in JSONFile/DeferredTask (which is used by FormAutofillStorage). + // Avoid the race by explicitly finalizing any formautofill JSONFile + // instances created manually by individual tests when the test finishes. + registerCleanupFunction(function finalizeAutofillStorage() { + return profileStorage._finalize(); + }); + + if (!records || !Array.isArray(records)) { + return profileStorage; + } + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "add" && subject.wrappedJSObject.collectionName == collectionName + ); + for (let record of records) { + Assert.ok(await profileStorage[collectionName].add(record)); + await onChanged; + } + await profileStorage._saveImmediately(); + return profileStorage; +} + +function verifySectionAutofillResult(sections, expectedSectionsInfo) { + sections.forEach((section, index) => { + const expectedSection = expectedSectionsInfo[index]; + + const fieldDetails = section.fieldDetails; + const expectedFieldDetails = expectedSection.fields; + + info(`verify autofill section[${index}]`); + + fieldDetails.forEach((field, fieldIndex) => { + const expeceted = expectedFieldDetails[fieldIndex]; + + Assert.equal( + expeceted.autofill, + field.element.value, + `Autofilled value for element(id=${field.element.id}, field name=${field.fieldName}) should be equal` + ); + }); + }); +} + +function verifySectionFieldDetails(sections, expectedSectionsInfo) { + sections.forEach((section, index) => { + const expectedSection = expectedSectionsInfo[index]; + + const fieldDetails = section.fieldDetails; + const expectedFieldDetails = expectedSection.fields; + + info(`section[${index}] ${expectedSection.description ?? ""}:`); + info(`FieldName Prediction Results: ${fieldDetails.map(i => i.fieldName)}`); + info( + `FieldName Expected Results: ${expectedFieldDetails.map( + detail => detail.fieldName + )}` + ); + Assert.equal( + fieldDetails.length, + expectedFieldDetails.length, + `Expected field count.` + ); + + fieldDetails.forEach((field, fieldIndex) => { + const expectedFieldDetail = expectedFieldDetails[fieldIndex]; + + const expected = { + ...{ + reason: "autocomplete", + section: "", + contactType: "", + addressType: "", + }, + ...expectedSection.default, + ...expectedFieldDetail, + }; + + const keys = new Set([...Object.keys(field), ...Object.keys(expected)]); + ["autofill", "elementWeakRef", "confidence", "part"].forEach(k => + keys.delete(k) + ); + + for (const key of keys) { + const expectedValue = expected[key]; + const actualValue = field[key]; + Assert.equal( + expectedValue, + actualValue, + `${key} should be equal, expect ${expectedValue}, got ${actualValue}` + ); + } + }); + + Assert.equal( + section.isValidSection(), + !expectedSection.invalid, + `Should be an ${expectedSection.invalid ? "invalid" : "valid"} section` + ); + }); +} + +var FormAutofillHeuristics, LabelUtils; +var AddressMetaDataLoader, FormAutofillUtils; + +function autofillFieldSelector(doc) { + return doc.querySelectorAll("input, select"); +} + +/** + * Returns the Sync change counter for a profile storage record. Synced records + * store additional metadata for tracking changes and resolving merge conflicts. + * Deleting a synced record replaces the record with a tombstone. + * + * @param {AutofillRecords} records + * The `AutofillRecords` instance to query. + * @param {string} guid + * The GUID of the record or tombstone. + * @returns {number} + * The change counter, or -1 if the record doesn't exist or hasn't + * been synced yet. + */ +function getSyncChangeCounter(records, guid) { + let record = records._findByGUID(guid, { includeDeleted: true }); + if (!record) { + return -1; + } + let sync = records._getSyncMetaData(record); + if (!sync) { + return -1; + } + return sync.changeCounter; +} + +/** + * Performs a partial deep equality check to determine if an object contains + * the given fields. To ensure the object doesn't contain a property, set the + * property of the `fields` object to `undefined` + * + * @param {object} object + * The object to check. Unlike `ObjectUtils.deepEqual`, properties in + * `object` that are not in `fields` will be ignored. + * @param {object} fields + * The fields to match. + * @returns {boolean} + * Does `object` contain `fields` with matching values? + */ +function objectMatches(object, fields) { + let actual = {}; + for (const key in fields) { + if (!object.hasOwnProperty(key)) { + if (fields[key] != undefined) { + return false; + } + } + actual[key] = object[key]; + } + return ObjectUtils.deepEqual(actual, fields); +} + +add_setup(async function head_initialize() { + Services.prefs.setBoolPref("extensions.experiments.enabled", true); + Services.prefs.setBoolPref("dom.forms.autocomplete.formautofill", true); + + Services.prefs.setCharPref( + "extensions.formautofill.addresses.supported", + "on" + ); + Services.prefs.setCharPref( + "extensions.formautofill.creditCards.supported", + "on" + ); + Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + + // Clean up after every test. + registerCleanupFunction(function head_cleanup() { + Services.prefs.clearUserPref("extensions.experiments.enabled"); + Services.prefs.clearUserPref( + "extensions.formautofill.creditCards.supported" + ); + Services.prefs.clearUserPref("extensions.formautofill.addresses.supported"); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + Services.prefs.clearUserPref("dom.forms.autocomplete.formautofill"); + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + }); + + await loadExtension(); +}); + +let OSKeyStoreTestUtils; +add_setup(async function os_key_store_setup() { + ({ OSKeyStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" + )); + OSKeyStoreTestUtils.setup(); + registerCleanupFunction(async function cleanup() { + await OSKeyStoreTestUtils.cleanup(); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/head_addressComponent.js b/browser/extensions/formautofill/test/unit/head_addressComponent.js new file mode 100644 index 0000000000..6ed7d52d7e --- /dev/null +++ b/browser/extensions/formautofill/test/unit/head_addressComponent.js @@ -0,0 +1,75 @@ +"use strict"; + +/* exported BOTH_EMPTY, A_IS_EMPTY, B_IS_EMPTY, A_CONTAINS_B, B_CONTAINS_A, SIMILAR, SAME, DIFFERENT, runIsValidTest, runCompareTest */ + +const { AddressComparison, AddressComponent } = ChromeUtils.importESModule( + "resource://gre/modules/shared/AddressComponent.sys.mjs" +); + +const { FormAutofill } = ChromeUtils.importESModule( + "resource://autofill/FormAutofill.sys.mjs" +); + +const BOTH_EMPTY = AddressComparison.BOTH_EMPTY; +const A_IS_EMPTY = AddressComparison.A_IS_EMPTY; +const B_IS_EMPTY = AddressComparison.B_IS_EMPTY; +const A_CONTAINS_B = AddressComparison.A_CONTAINS_B; +const B_CONTAINS_A = AddressComparison.B_CONTAINS_A; +const SIMILAR = AddressComparison.SIMILAR; +const SAME = AddressComparison.SAME; +const DIFFERENT = AddressComparison.DIFFERENT; + +function runIsValidTest(tests, fieldName, funcSetupRecord) { + for (const test of tests) { + if (!Array.isArray(test)) { + FormAutofill.DEFAULT_REGION = test.region; + info(`Change region to ${JSON.stringify(test.region)}`); + continue; + } + + const [testValue, expected] = test; + const record = funcSetupRecord(testValue); + + const field = new AddressComponent(record, { + ignoreInvalid: false, + }).getField(fieldName); + const result = field.isValid(); + Assert.equal( + result, + expected, + `Expect isValid returns ${expected} for ${testValue}` + ); + } + FormAutofill.DEFAULT_REGION = null; +} + +function runCompareTest(tests, fieldName, funcSetupRecord) { + for (const test of tests) { + if (!Array.isArray(test)) { + info(`change region to ${JSON.stringify(test.region)}`); + FormAutofill.DEFAULT_REGION = test.region; + continue; + } + + const [v1, v2, expected] = test; + const r1 = funcSetupRecord(v1); + const f1 = new AddressComponent(r1, { ignoreInvalid: false }).getField( + fieldName + ); + + const r2 = funcSetupRecord(v2); + const f2 = new AddressComponent(r2, { ignoreInvalid: false }).getField( + fieldName + ); + + const result = AddressComparison.compare(f1, f2); + const resultString = AddressComparison.resultToString(result); + const expectedString = AddressComparison.resultToString(expected); + Assert.equal( + result, + expected, + `Expect ${expectedString} when comparing "${v1}" & "${v2}", got ${resultString}` + ); + } + FormAutofill.DEFAULT_REGION = null; +} diff --git a/browser/extensions/formautofill/test/unit/test_activeStatus.js b/browser/extensions/formautofill/test/unit/test_activeStatus.js new file mode 100644 index 0000000000..47d79b02e5 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_activeStatus.js @@ -0,0 +1,176 @@ +/* + * Test for status handling in Form Autofill Parent. + */ + +"use strict"; + +let FormAutofillStatus; + +add_setup(async () => { + ({ FormAutofillStatus } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillParent.sys.mjs" + )); +}); + +add_task(async function test_activeStatus_init() { + sinon.spy(FormAutofillStatus, "updateStatus"); + + // Default status is null before initialization + Assert.equal(FormAutofillStatus._active, null); + Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), undefined); + + FormAutofillStatus.init(); + // init shouldn't call updateStatus since that requires storage which will + // lead to startup time regressions. + Assert.equal(FormAutofillStatus.updateStatus.called, false); + Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), undefined); + + // Initialize profile storage + await FormAutofillStatus.formAutofillStorage.initialize(); + await FormAutofillStatus.updateSavedFieldNames(); + // Upon first initializing profile storage, status should be computed. + Assert.equal(FormAutofillStatus.updateStatus.called, true); + Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), false); + + FormAutofillStatus.uninit(); +}); + +add_task(async function test_activeStatus_observe() { + FormAutofillStatus.init(); + sinon.stub(FormAutofillStatus, "computeStatus"); + sinon.spy(FormAutofillStatus, "onStatusChanged"); + + // _active = _computeStatus() => No need to trigger _onStatusChanged + FormAutofillStatus._active = true; + FormAutofillStatus.computeStatus.returns(true); + FormAutofillStatus.observe( + null, + "nsPref:changed", + "extensions.formautofill.addresses.enabled" + ); + FormAutofillStatus.observe( + null, + "nsPref:changed", + "extensions.formautofill.creditCards.enabled" + ); + Assert.equal(FormAutofillStatus.onStatusChanged.called, false); + + // _active != computeStatus() => Need to trigger onStatusChanged + FormAutofillStatus.computeStatus.returns(false); + FormAutofillStatus.onStatusChanged.resetHistory(); + FormAutofillStatus.observe( + null, + "nsPref:changed", + "extensions.formautofill.addresses.enabled" + ); + FormAutofillStatus.observe( + null, + "nsPref:changed", + "extensions.formautofill.creditCards.enabled" + ); + Assert.equal(FormAutofillStatus.onStatusChanged.called, true); + + // profile changed => Need to trigger _onStatusChanged + await Promise.all( + ["add", "update", "remove", "reconcile"].map(async event => { + FormAutofillStatus.computeStatus.returns(!FormAutofillStatus._active); + FormAutofillStatus.onStatusChanged.resetHistory(); + await FormAutofillStatus.observe( + null, + "formautofill-storage-changed", + event + ); + Assert.equal(FormAutofillStatus.onStatusChanged.called, true); + }) + ); + + // profile metadata updated => No need to trigger onStatusChanged + FormAutofillStatus.computeStatus.returns(!FormAutofillStatus._active); + FormAutofillStatus.onStatusChanged.resetHistory(); + await FormAutofillStatus.observe( + null, + "formautofill-storage-changed", + "notifyUsed" + ); + Assert.equal(FormAutofillStatus.onStatusChanged.called, false); + + FormAutofillStatus.computeStatus.restore(); +}); + +add_task(async function test_activeStatus_computeStatus() { + registerCleanupFunction(function cleanup() { + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + }); + + sinon.stub( + FormAutofillStatus.formAutofillStorage.addresses, + "getSavedFieldNames" + ); + FormAutofillStatus.formAutofillStorage.addresses.getSavedFieldNames.returns( + Promise.resolve(new Set()) + ); + sinon.stub( + FormAutofillStatus.formAutofillStorage.creditCards, + "getSavedFieldNames" + ); + FormAutofillStatus.formAutofillStorage.creditCards.getSavedFieldNames.returns( + Promise.resolve(new Set()) + ); + + // pref is enabled and profile is empty. + Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + Assert.equal(FormAutofillStatus.computeStatus(), false); + + // pref is disabled and profile is empty. + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + Assert.equal(FormAutofillStatus.computeStatus(), false); + + FormAutofillStatus.formAutofillStorage.addresses.getSavedFieldNames.returns( + Promise.resolve(new Set(["given-name"])) + ); + await FormAutofillStatus.observe(null, "formautofill-storage-changed", "add"); + + // pref is enabled and profile is not empty. + Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true); + Assert.equal(FormAutofillStatus.computeStatus(), true); + + // pref is partial enabled and profile is not empty. + Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + Assert.equal(FormAutofillStatus.computeStatus(), true); + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + Assert.equal(FormAutofillStatus.computeStatus(), true); + + // pref is disabled and profile is not empty. + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + Assert.equal(FormAutofillStatus.computeStatus(), false); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_city.js b/browser/extensions/formautofill/test/unit/test_addressComponent_city.js new file mode 100644 index 0000000000..2712eaaee1 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_city.js @@ -0,0 +1,27 @@ +"use strict"; + +const VALID_TESTS = [["New York City", true]]; + +const COMPARE_TESTS = [ + ["New York City", "New York City", SAME], + ["New York City", "new york city", SAME], + ["New York City", "New York City", SIMILAR], // Merge whitespace + ["Happy Valley-Goose Bay", "Happy Valley Goose Bay", SIMILAR], // Replace punctuation with whitespace + ["New York City", "New York", A_CONTAINS_B], + ["New York", "NewYork", DIFFERENT], + ["New York City", "City New York", DIFFERENT], +]; + +const TEST_FIELD_NAME = "address-level2"; + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { "address-level2": value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { "address-level2": value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_country.js b/browser/extensions/formautofill/test/unit/test_addressComponent_country.js new file mode 100644 index 0000000000..83e14c424a --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_country.js @@ -0,0 +1,47 @@ +"use strict"; + +const VALID_TESTS = [ + ["United States", true], + ["Not United States", true], // Invalid country name will be replaced with the default region, so + // it is still valid +]; + +const COMPARE_TESTS = [ + // United Stats, US, USA, America, U.S.A. + { region: "US" }, + ["United States", "United States", SAME], + ["United States", "united states", SAME], + ["United States", "US", SAME], + ["America", "United States", SAME], + ["America", "US", SAME], + ["US", "USA", SAME], + ["United States", "U.S.A.", SAME], // Normalize + + ["USB", "US", SAME], + + // Canada, Can, CA + ["CA", "Canada", SAME], + ["CA", "CAN", SAME], + ["CA", "US", DIFFERENT], + + { region: "DE" }, + ["USB", "US", DIFFERENT], + ["United States", "Germany", DIFFERENT], + + ["Invalid Country Name", "Germany", SAME], + ["AAA", "BBB", SAME], +]; + +const TEST_FIELD_NAME = "country"; + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { country: value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { country: value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_email.js b/browser/extensions/formautofill/test/unit/test_addressComponent_email.js new file mode 100644 index 0000000000..588ad345df --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_email.js @@ -0,0 +1,73 @@ +"use strict"; + +// TODO: +// https://help.xmatters.com/ondemand/trial/valid_email_format.htm what is the allow characters??? +const VALID_TESTS = [ + // Is Valid Test + // [email1, expected] + ["john.doe@mozilla.org", true], + + ["@mozilla.org", false], // without username + ["john.doe@", false], // without domain + ["john.doe@-mozilla.org", false], // domain starts with '-' + ["john.doe@mozilla.org-", false], // domain ends with '-' + ["john.doe@mozilla.-com.au", false], // sub-domain starts with '-' + ["john.doe@mozilla.com-.au", false], // sub-domain ends with '-' + + ["john-doe@mozilla.org", true], // dash (ok) + + // Special characters check + ["john.!#$%&'*+-/=?^_`{|}~doe@gmail.com", true], + + ["john.doe@work@mozilla.org", false], + ["äbc@mail.com", false], + + ["john.doe@" + "a".repeat(63) + ".org", true], + ["john.doe@" + "a".repeat(64) + ".org", false], + + // The following are commented out since we're using a more relax email + // validation algorithm now. + /* + ["-john.doe@mozilla.org", false], // username starts with '-' + [".john.doe@mozilla.org", false], // username starts with '.' + ["john.doe-@mozilla.org", true], // username ends with '-' ??? + ["john.doe.@mozilla.org", true], // username ends with '.' ??? + ["john.doe@-mozilla.org", true], // domain starts with '.' ??? + ["john..doe@mozilla.org", false], // consecutive period + ["john.-doe@mozilla.org", false], // period + dash + ["john-.doe@mozilla.org", false], // dash + period + ["john.doe@school.123", false], + ["peter-parker@spiderman", false], + + ["a".repeat(64) + "@mydomain.com", true], // length of username + ["b".repeat(65) + "@mydomain.com", false], +*/ +]; + +const COMPARE_TESTS = [ + // Same + ["test@mozilla.org", "test@mozilla.org", SAME], + ["john.doe@example.com", "jOhn.doE@example.com", SAME], + ["jan@gmail.com", "JAN@gmail.com", SAME], + + // Different + ["jan@gmail.com", "jan1@gmail.com", DIFFERENT], + ["jan@gmail.com", "jan@gmail.com.au", DIFFERENT], + ["john#smith@gmail.com", "johnsmith@gmail.com", DIFFERENT], +]; + +const TEST_FIELD_NAME = "email"; + +add_setup(async () => {}); + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { email: value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { email: value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_name.js b/browser/extensions/formautofill/test/unit/test_addressComponent_name.js new file mode 100644 index 0000000000..bae6c1cd2e --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_name.js @@ -0,0 +1,101 @@ +"use strict"; + +const VALID_TESTS = [ + ["John Doe", true], + ["John O'Brian'", true], + ["John O-Brian'", true], + ["John Doe", true], +]; + +// prettier-ignore +const COMPARE_TESTS = [ + // Same + ["John", "John", SAME], // first name + ["John Doe", "John Doe", SAME], // first and last name + ["John Middle Doe", "John Middle Doe", SAME], // first, middle, and last name + ["John Mid1 Mid2 Doe", "John Mid1 Mid2 Doe", SAME], + + // Same: case insenstive + ["John Doe", "john doe", SAME], + + // Similar: whitespaces are merged + ["John Doe", "John Doe", SIMILAR], + + // Similar: asscent and base + ["John Doe", "John Döe", SIMILAR], // asscent and base + + // A Contains B + ["John Doe", "Doe", A_CONTAINS_B], // first + family name contains family name + ["John Doe", "John", A_CONTAINS_B], // first + family name contains first name + ["John Middle Doe", "Doe", A_CONTAINS_B], // [first, middle, last] contains [last] + ["John Middle Doe", "John", A_CONTAINS_B], // [first, middle, last] contains [first] + ["John Middle Doe", "Middle", A_CONTAINS_B], // [first, middle, last] contains [middle] + ["John Middle Doe", "Middle Doe", A_CONTAINS_B], // [first, middle, last] contains [middle, last] + ["John Middle Doe", "John Middle", A_CONTAINS_B], // [first, middle, last] contains [fisrt, middle] + ["John Middle Doe", "John Doe", A_CONTAINS_B], // [first, middle, last] contains [fisrt, last] + ["John Mary Jane Doe", "John Doe", A_CONTAINS_B], // [first, middle, last] contains [fisrt, last] + + // Different + ["John Doe", "Jane Roe", DIFFERENT], + ["John Doe", "Doe John", DIFFERENT], // swap order + ["John Middle Doe", "Middle John", DIFFERENT], + ["John Middle Doe", "Doe Middle", DIFFERENT], + ["John Doe", "John Roe.", DIFFERENT], // different family name + ["John Doe", "Jane Doe", DIFFERENT], // different given name + ["John Middle Doe", "Jane Michael Doe", DIFFERENT], // different middle name + + // Puncuation is either removed or replaced with white space + ["John O'Brian", "John OBrian", SIMILAR], + ["John O'Brian", "John O-Brian", SIMILAR], + ["John O'Brian", "John O Brian", SIMILAR], + ["John-Mary Doe", "JohnMary Doe", SIMILAR], + ["John-Mary Doe", "John'Mary Doe", SIMILAR], + ["John-Mary Doe", "John Mary Doe", SIMILAR], + ["John-Mary Doe", "John Mary", A_CONTAINS_B], + + // Test Name Variants + ["John Doe", "J. Doe", A_CONTAINS_B], // first name to initial + ["John Doe", "J. doe", A_CONTAINS_B], + ["John Doe", "J. Doe", A_CONTAINS_B], // first name to initial without '.' + + ["John Middle Doe", "J. Middle Doe", A_CONTAINS_B], // first name to initial, middle name unchanged + ["John Middle Doe", "J. Doe", A_CONTAINS_B], // first name to initial, no middle name + + ["John Middle Doe", "John M. Doe", A_CONTAINS_B], // middle name to initial, first name unchanged + ["John Middle Doe", "J. M. Doe", A_CONTAINS_B], // first and middle name to initial + ["John Middle Doe", "J M Doe", A_CONTAINS_B], // first and middle name to initial without '.' + ["John Middle Doe", "John M. Doe", A_CONTAINS_B], // middle name with initial + + // Test Name Variants: multiple middle name + ["John Mary Jane Doe", "J. MARY JANE Doe", A_CONTAINS_B], // first to initial + ["John Mary Jane Doe", "john. M. J. doe", A_CONTAINS_B], // middle name to initial + ["John Mary Jane Doe", "J. M. J. Doe", A_CONTAINS_B], // first & middle name to initial + ["John Mary Jane Doe", "J. M. Doe", A_CONTAINS_B], // first & part of the middle name to initial + ["John Mary Jane Doe", "John M. Doe", A_CONTAINS_B], + ["John Mary Jane Doe", "J. Doe", A_CONTAINS_B], + + // Test Name Variants: merge initials + ["John Middle Doe", "JM Doe", A_CONTAINS_B], + ["John Mary Jane Doe", "JMJ. doe", A_CONTAINS_B], + + // Different: Don't consider the cases when family name is abbreviated + ["John Middle Doe", "JMD", DIFFERENT], + ["John Middle Doe", "John Middle D.", DIFFERENT], + ["John Middle Doe", "J. M. D.", DIFFERENT], +]; + +const TEST_FIELD_NAME = "name"; + +add_setup(async () => {}); + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { name: value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { name: value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js b/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js new file mode 100644 index 0000000000..3954e9bac7 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js @@ -0,0 +1,54 @@ +"use strict"; + +// prettier-ignore +const VALID_TESTS = [ + ["Mozilla", true], + ["mozilla", true], + ["@Mozilla", true], + ["-!@#%&*_(){}[:;\"',.?]", false], // Not valid when the organization name only contains punctuations +]; + +const COMPARE_TESTS = [ + // Same + ["Mozilla", "Mozilla", SAME], // Exact the same + + // Similar + ["Mozilla", "mozilla", SIMILAR], // Ignore case + ["Casavant Frères", "Casavant Freres", SIMILAR], // asscent and base + ["Graphik Dimensions, Ltd.", "Graphik Dimensions Ltd", SIMILAR], // Punctuation is stripped and trim space in the end + ["T & T Supermarket", "T&T Supermarket", SIMILAR], // & is stripped and merged consecutive whitespace + ["Food & Pharmacy", "Pharmacy & Food", SIMILAR], // Same tokens, different order + ["Johnson & Johnson", "Johnson", SIMILAR], // Can always find the same token in the other + + // A Contains B + ["Mozilla Inc.", "Mozilla", A_CONTAINS_B], // Contain, the same prefix + ["The Walt Disney", "Walt Disney", A_CONTAINS_B], // Contain, the same suffix + ["Coca-Cola Company", "Coca Cola", A_CONTAINS_B], // Contain, strip punctuation + + // Different + ["Meta", "facebook", DIFFERENT], // Completely different + ["Metro Inc.", "CGI Inc.", DIFFERENT], // Different prefix + ["AT&T Corp.", "AT&T Inc.", DIFFERENT], // Different suffix + ["AT&T Corp.", "AT&T Corporation", DIFFERENT], // Different suffix + ["Ben & Jerry's", "Ben & Jerrys", DIFFERENT], // Different because Jerry's becomes ["Jerry", "s"] + ["Arc'teryx", "Arcteryx", DIFFERENT], // Different because Arc'teryx' becomes ["Arc", "teryx"] + ["BMW", "Bayerische Motoren Werke", DIFFERENT], + + ["Linens 'n Things", "Linens'n Things", SIMILAR], // Punctuation is replaced with whitespace, so both strings become "Linesns n Things" +]; + +const TEST_FIELD_NAME = "organization"; + +add_setup(async () => {}); + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { organization: value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { organization: value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js b/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js new file mode 100644 index 0000000000..013a57198f --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js @@ -0,0 +1,57 @@ +"use strict"; + +const VALID_TESTS = [ + { region: "US" }, + ["1234", false], // too short + ["12345", true], + ["123456", false], // too long + ["1234A", false], // contain non-digit character + ["12345-123", false], + ["12345-1234", true], + ["12345-12345", false], + ["12345-1234A", false], + ["12345 1234", true], // Do we want to allow this? + ["12345_1234", false], // Do we want to allow this? + + { region: "CA" }, + ["M5T 1R5", true], + ["M5T1R5", true], // no space between the first and second parts is allowed + ["S4S 6X3", true], + ["M5T", false], // Only the first part + ["1R5", false], // Only the second part + ["D1B 1A1", false], // invalid first character, D + ["M5T 1R5A", false], // extra character at the end + ["M5T 1R5-", false], // extra character at the end + ["M5T-1R5", false], // hyphen in the wrong place + ["MT5 1R5", false], // missing letter in the first part + ["M5T 1R", false], // missing letter in the second part + ["M5T 1R55", false], // extra digit at the end + ["M5T 1R", false], // missing digit in the second part + ["M5T 1R5Q", false], // invalid second-to-last letter, Q +]; + +const COMPARE_TESTS = [ + { region: "US" }, + ["12345", "12345", SAME], + ["M5T 1R5", "m5t 1r5", SAME], + ["12345-1234", "12345 1234", SAME], + ["12345-1234", "12345", A_CONTAINS_B], + ["12345-1234", "12345#1234", SAME], // B is invalid + ["12345-1234", "1234", A_CONTAINS_B], // B is invalid +]; + +const TEST_FIELD_NAME = "postal-code"; + +add_setup(async () => {}); + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { "postal-code": value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { "postal-code": value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_state.js b/browser/extensions/formautofill/test/unit/test_addressComponent_state.js new file mode 100644 index 0000000000..41d83e78c9 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_state.js @@ -0,0 +1,32 @@ +"use strict"; + +const VALID_TESTS = [ + ["California", true], + ["california", true], + ["Californib", false], + ["CA", true], + ["CA.", true], + ["CC", false], +]; + +const COMPARE_TESTS = [ + ["California", "california", SAME], // case insensitive + ["CA", "california", SAME], + ["CA", "ca", SAME], + ["California", "New Jersey", DIFFERENT], + ["New York", "New Jersey", DIFFERENT], +]; + +const TEST_FIELD_NAME = "address-level1"; + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { "address-level1": value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { "address-level1": value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js b/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js new file mode 100644 index 0000000000..0acffb0656 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js @@ -0,0 +1,63 @@ +"use strict"; + +const VALID_TESTS = [ + ["123 Main St. Apt 4, Floor 2", true], + ["This is a street", true], + ["A", true], + ["住址", true], + ["!#%&'*+", false], + ["1234", false], +]; + +const COMPARE_TESTS = [ + { region: "US" }, + ["123 Main St.", "123 Main St.", SAME], // Exactly the same with only street number and street name + ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4, Floor 2", SAME], + ["123 Main St. Apt 4A, Floor 2", "123 main St. Apt 4a, Floor 2", SAME], + ["123 Main St. Apt 4, Floor 2", "123 Main St. Suite 4, 2nd fl", SAME], // Exactly the same after parsing + ["Main St.", "Main St.", SAME], // Exactly the same with only street name + ["Main St.", "main st.", SAME], // Exactly the same with only street name (case-insenstive) + + ["123 Main St.", "Main St.", A_CONTAINS_B], // Street number is mergeable + ["123 Main Lane St.", "123 Main St.", A_CONTAINS_B], // Street name is mergeable + ["123 Main St. Apt 4", "Main St.", A_CONTAINS_B], // Apartment number is mergeable + ["123 Main St. Apt 4, Floor 2", "123 Main St., Floor 2", A_CONTAINS_B], + ["123 Main St. Floor 2", "Main St.", A_CONTAINS_B], // Floor number is mergeable + ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4", A_CONTAINS_B], + ["123 North-South Road", "123 North South Road", SIMILAR], // Street number is mergeable + + ["123 Main St. Apt 4, Floor 2", "1234 Main St. Apt 4, Floor 2", DIFFERENT], // Street number is different + ["123 Main St. Apt 4, Floor 2", "123 Mainn St. Apt 4, Floor 2", DIFFERENT], // Street name is different + [ + "123 Lane Main St. Apt 4, Floor 2", + "123 Main Lane St. Apt 4, Floor 2", + DIFFERENT, + ], // Street name is different (token not in order) + ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 41, Floor 2", DIFFERENT], // Apartment number is different + ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4, Floor 22", DIFFERENT], // Floor number is different + + ["123 Main St. Apt 4, Floor 2", "123 Main St. Floor 2, Apt 4", DIFFERENT], + + // When address cannot be parsed + ["Any Address", "any address", SAME], + ["Any Address 4F", "Any Address", A_CONTAINS_B], + ["An Any Address 4F", "Any Address", A_CONTAINS_B], + ["Any Address", "Address Any", DIFFERENT], + ["Any Address", "Other Address", DIFFERENT], +]; + +const TEST_FIELD_NAME = "street-address"; + +add_setup(async () => {}); + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { "street-address": value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { "street-address": value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js b/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js new file mode 100644 index 0000000000..9f6ba4ba43 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js @@ -0,0 +1,76 @@ +/* import-globals-from head_addressComponent.js */ + +"use strict"; + +// prettier-ignore +const VALID_TESTS = [ + // US Valid format (XXX-XXX-XXXX) and first digit is between 2-9 + ["200-234-5678", true], // First digit should between 2-9 + ["100-234-5678", true], // First digit is not between 2-9, but currently not being checked + // when no country code is specified + ["555-abc-1234", true], // Non-digit characters are normalized according to ITU E.161 standard + ["55-555-5555", false], // The national number is too short (9 digits) + + ["2-800-555-1234", false], // "2" is not US country code so we treat + // 2-800-555-1234 as the national number, which is too long (11 digits) + + // Phone numbers with country code + ["1-800-555-1234", true], // Country code without plus sign + ["+1 200-234-5678", true], // Country code with plus sign and with a valid national number + ["+1 100-234-5678", false], // National number should be between 2-9 + ["+1 55-555-5555", false], // National number is too short (9 digits) + ["+1 1-800-555-1234", true], // "+1" and "1" are both treated as coutnry code so national number + // is a valid number (800-555-1234) + ["+1 2-800-555-1234", false], // The national number is too long (11 digits) + ["+1 555-abc-1234", true], // Non-digit characters are normalized according to ITU E.161 standard +]; + +const COMPARE_TESTS = [ + ["+1 520-248-6621", "+15202486621", SAME], + ["+1 520-248-6621", "1-520-248-6621", SAME], + ["+1 520-248-6621", "1(520)248-6621", SAME], + ["520-248-6621", "520-248-6621", SAME], // Both phone numbers don't have coutry code + ["520-248-6621", "+1 520-248-6621", SAME], // Compare phone number with and without country code + + ["+1 520-248-6621", "248-6621", A_CONTAINS_B], + ["520-248-6621", "248-6621", A_CONTAINS_B], + ["0520-248-6621", "520-248-6621", A_CONTAINS_B], + ["48-6621", "6621", A_CONTAINS_B], // Both phone number are invalid + + ["+1 520-248-6621", "+91 520-248-6622", DIFFERENT], // different national prefix and number + ["+1 520-248-6621", "+91 520-248-6621", DIFFERENT], // Same number, different national prefix + ["+1 520-248-6621", "+1 520-248-6622", DIFFERENT], // Same national prefix, different number + ["520-248-6621", "+91 520-248-6622", DIFFERENT], // Same test as above but with default region + ["520-248-6621", "+91 520-248-6621", DIFFERENT], // Same test as above but with default region + ["520-248-6621", "+1 520-248-6622", DIFFERENT], // Same test as above but with default region + ["520-248-6621", "520-248-6622", DIFFERENT], + + // Normalize + ["+1 520-248-6621", "+1 ja0-bgt-mnc1", SAME], + ["+1 1-800-555-1234", "+1 800-555-1234", SAME], + + // TODO: Support extension + //["+64 3 331-6005", "3 331 6005#1234", A_CONTAINS_B], +]; + +const TEST_FIELD_NAME = "tel"; + +add_setup(async () => { + Services.prefs.setBoolPref("browser.search.region", "US"); + + registerCleanupFunction(function head_cleanup() { + Services.prefs.clearUserPref("browser.search.region"); + }); +}); + +add_task(async function test_isValid() { + runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => { + return { tel: value }; + }); +}); + +add_task(async function test_compare() { + runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => { + return { tel: value }; + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressDataLoader.js b/browser/extensions/formautofill/test/unit/test_addressDataLoader.js new file mode 100644 index 0000000000..3510b5db46 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressDataLoader.js @@ -0,0 +1,113 @@ +"use strict"; + +const SUPPORT_COUNTRIES_TESTCASES = [ + { + country: "US", + properties: ["languages", "alternative_names", "sub_keys", "sub_names"], + }, + { + country: "CA", + properties: ["languages", "name", "sub_keys", "sub_names"], + }, + { + country: "DE", + properties: ["name"], + }, +]; + +var AddressMetaDataLoader, FormAutofillUtils; +add_setup(async () => { + ({ FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" + )); + ({ AddressMetaDataLoader } = ChromeUtils.importESModule( + "resource://gre/modules/shared/AddressMetaDataLoader.sys.mjs" + )); +}); + +add_task(async function test_initalState() { + // addressData should be empty + Assert.deepEqual(AddressMetaDataLoader.addressData, {}); + // Verify dataLoaded state + Assert.equal(AddressMetaDataLoader.dataLoaded.country, false); + Assert.equal(AddressMetaDataLoader.dataLoaded.level1.size, 0); +}); + +add_task(async function test_loadDataCountry() { + sinon.spy(AddressMetaDataLoader, "loadAddressMetaData"); + let metadata = FormAutofillUtils.getCountryAddressData("US"); + Assert.ok(AddressMetaDataLoader.addressData, "addressData exists"); + // Verify _dataLoaded state + Assert.equal(AddressMetaDataLoader.dataLoaded.country, true); + Assert.equal(AddressMetaDataLoader.dataLoaded.level1.size, 0); + // _loadAddressMetaData should be called + sinon.assert.called(AddressMetaDataLoader.loadAddressMetaData); + // Verify metadata + Assert.equal(metadata.id, "data/US"); + Assert.ok( + metadata.alternative_names, + "US alternative names should be loaded from extension" + ); + AddressMetaDataLoader.loadAddressMetaData.resetHistory(); + + // Load data without country + let newMetadata = FormAutofillUtils.getCountryAddressData(); + // _loadAddressMetaData should not be called + sinon.assert.notCalled(AddressMetaDataLoader.loadAddressMetaData); + Assert.deepEqual( + metadata, + newMetadata, + "metadata should be US if country is not specified" + ); + AddressMetaDataLoader.loadAddressMetaData.resetHistory(); +}); + +// This test is currently Disable! +// This is because - Loading a non-existent resource could potentially cause a crash +// (See Bug 1859588). To address this issue, We can check for the file's existence +// before attempting to load the script. However, given that we are not using +// state data, just keep the solution simple by disabling the test. +add_task(async function test_loadDataState() { + sinon.spy(AddressMetaDataLoader, "loadAddressMetaData"); + // Load level 1 data that does not exist + let undefinedMetadata = FormAutofillUtils.getCountryAddressData("US", "CA"); + // loadAddressMetaData should be called + sinon.assert.called(AddressMetaDataLoader.loadAddressMetaData); + Assert.equal(undefinedMetadata, undefined, "metadata should be undefined"); + Assert.ok( + AddressMetaDataLoader.dataLoaded.level1.has("US"), + "level 1 state array should be set even there's no valid metadata" + ); + AddressMetaDataLoader.loadAddressMetaData.resetHistory(); + + // Load level 1 data again + undefinedMetadata = FormAutofillUtils.getCountryAddressData("US", "AS"); + Assert.equal(undefinedMetadata, undefined, "metadata should be undefined"); + // loadAddressMetaData should not be called + sinon.assert.notCalled(AddressMetaDataLoader.loadAddressMetaData); +}).skip(); + +SUPPORT_COUNTRIES_TESTCASES.forEach(testcase => { + add_task(async function test_support_country() { + info("Starting testcase: Check " + testcase.country + " metadata"); + let metadata = FormAutofillUtils.getCountryAddressData(testcase.country); + Assert.ok( + testcase.properties.every(key => metadata[key]), + "These properties should exist: " + testcase.properties + ); + // Verify the multi-locale country + if (metadata.languages && metadata.languages.length > 1) { + let locales = FormAutofillUtils.getCountryAddressDataWithLocales( + testcase.country + ); + Assert.equal( + metadata.languages.length, + locales.length, + "Total supported locales should be matched" + ); + metadata.languages.forEach((lang, index) => { + Assert.equal(lang, locales[index].lang, `Should support ${lang}`); + }); + } + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_addressRecords.js b/browser/extensions/formautofill/test/unit/test_addressRecords.js new file mode 100644 index 0000000000..a2d7ce23b5 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_addressRecords.js @@ -0,0 +1,392 @@ +/** + * Tests FormAutofillStorage object with addresses records. + */ + +"use strict"; + +const TEST_STORE_FILE_NAME = "test-profile.json"; +const COLLECTION_NAME = "addresses"; + +const TEST_ADDRESS_1 = { + name: "Timothy John Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+16172535702", + email: "timbl@w3.org", + "unknown-1": "an unknown field from another client", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +const TEST_ADDRESS_3 = { + name: "Timothy Berners-Lee", + "street-address": "Other Address", + "postal-code": "12345", +}; + +const TEST_ADDRESS_WITH_EMPTY_FIELD = { + name: "Tim Berners", + "street-address": "", +}; + +const TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD = { + "given-name": "", + "additional-name": "", + "family-name": "", + "address-line1": "", + "address-line2": "", + "address-line3": "", + "country-name": "", + "tel-country-code": "", + "tel-national": "", + "tel-area-code": "", + "tel-local": "", + "tel-local-prefix": "", + "tel-local-suffix": "", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_WITH_INVALID_FIELD = { + "street-address": "Another Address", + email: { email: "invalidemail" }, +}; + +const TEST_ADDRESS_EMPTY_AFTER_NORMALIZE = { + country: "XXXXXX", +}; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +let do_check_record_matches = (recordWithMeta, record) => { + for (let key in record) { + Assert.equal(recordWithMeta[key], record[key]); + } +}; + +add_task(async function test_initialize() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + + Assert.equal(profileStorage._store.data.version, 1); + Assert.equal(profileStorage._store.data.addresses.length, 0); + + let data = profileStorage._store.data; + Assert.deepEqual(data.addresses, []); + + await profileStorage._saveImmediately(); + + profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + + Assert.deepEqual(profileStorage._store.data, data); + for (let { _sync } of profileStorage._store.data.addresses) { + Assert.ok(_sync); + Assert.equal(_sync.changeCounter, 1); + } +}); + +add_task(async function test_getAll() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + + Assert.equal(addresses.length, 2); + do_check_record_matches(addresses[0], TEST_ADDRESS_1); + do_check_record_matches(addresses[1], TEST_ADDRESS_2); + + // Check computed fields. + Assert.equal(addresses[0]["given-name"], "Timothy"); + Assert.equal(addresses[0]["additional-name"], "John"); + Assert.equal(addresses[0]["family-name"], "Berners-Lee"); + Assert.equal(addresses[0]["address-line1"], "32 Vassar Street"); + Assert.equal(addresses[0]["address-line2"], "MIT Room 32-G524"); + + // Test with rawData set. + addresses = await profileStorage.addresses.getAll({ rawData: true }); + // For backward-compatibility, we keep *-name fields when `rawData` is true + Assert.equal(addresses[0]["given-name"], "Timothy"); + Assert.equal(addresses[0]["additional-name"], "John"); + Assert.equal(addresses[0]["family-name"], "Berners-Lee"); + Assert.equal(addresses[0]["address-line1"], undefined); + Assert.equal(addresses[0]["address-line2"], undefined); + + // Modifying output shouldn't affect the storage. + addresses[0].organization = "test"; + do_check_record_matches( + (await profileStorage.addresses.getAll())[0], + TEST_ADDRESS_1 + ); +}); + +add_task(async function test_get() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[0].guid; + + let address = await profileStorage.addresses.get(guid); + do_check_record_matches(address, TEST_ADDRESS_1); + + // Test with rawData set. + address = await profileStorage.addresses.get(guid, { rawData: true }); + // For backward-compatibility, we keep *-name fields when `rawData` is true + Assert.equal(address["given-name"], "Timothy"); + Assert.equal(address["additional-name"], "John"); + Assert.equal(address["family-name"], "Berners-Lee"); + Assert.equal(address["address-line1"], undefined); + Assert.equal(address["address-line2"], undefined); + + // Modifying output shouldn't affect the storage. + address.organization = "test"; + do_check_record_matches( + await profileStorage.addresses.get(guid), + TEST_ADDRESS_1 + ); + + Assert.equal(await profileStorage.addresses.get("INVALID_GUID"), null); +}); + +add_task(async function test_add() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + + Assert.equal(addresses.length, 2); + + do_check_record_matches(addresses[0], TEST_ADDRESS_1); + do_check_record_matches(addresses[1], TEST_ADDRESS_2); + + Assert.notEqual(addresses[0].guid, undefined); + Assert.equal(addresses[0].version, 1); + Assert.notEqual(addresses[0].timeCreated, undefined); + Assert.equal(addresses[0].timeLastModified, addresses[0].timeCreated); + Assert.equal(addresses[0].timeLastUsed, 0); + Assert.equal(addresses[0].timesUsed, 0); + + // Empty string should be deleted before saving. + await profileStorage.addresses.add(TEST_ADDRESS_WITH_EMPTY_FIELD); + let address = profileStorage.addresses._data[2]; + Assert.equal(address.name, TEST_ADDRESS_WITH_EMPTY_FIELD.name); + Assert.equal(address["street-address"], undefined); + + // Empty computed fields shouldn't cause any problem. + await profileStorage.addresses.add(TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD); + address = profileStorage.addresses._data[3]; + Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email); + + await Assert.rejects( + profileStorage.addresses.add(TEST_ADDRESS_WITH_INVALID_FIELD), + /"email" contains invalid data type: object/ + ); + + await Assert.rejects( + profileStorage.addresses.add({}), + /Record contains no valid field\./ + ); + + await Assert.rejects( + profileStorage.addresses.add(TEST_ADDRESS_EMPTY_AFTER_NORMALIZE), + /Record contains no valid field\./ + ); +}); + +add_task(async function test_update() { + // Test assumes that when an entry is saved a second time, it's last modified date will + // be different from the first. With high values of precision reduction, we execute too + // fast for that to be true. + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[1].guid; + // We need to cheat a little due to race conditions of Date.now() when + // we're running these tests, so we subtract one and test accordingly + // in the times Date.now() returns the same timestamp + let timeLastModified = addresses[1].timeLastModified - 1; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "update" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + Assert.notEqual(addresses[1].country, undefined); + + await profileStorage.addresses.update(guid, TEST_ADDRESS_3); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below. + + let address = await profileStorage.addresses.get(guid, { rawData: true }); + + Assert.equal(address.country, "US"); + Assert.ok(address.timeLastModified > timeLastModified); + do_check_record_matches(address, TEST_ADDRESS_3); + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); + + // Test preserveOldProperties parameter and field with empty string. + await profileStorage.addresses.update( + guid, + TEST_ADDRESS_WITH_EMPTY_FIELD, + true + ); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below. + + address = await profileStorage.addresses.get(guid, { rawData: true }); + + Assert.equal(address.name, "Tim Berners"); + Assert.equal(address["street-address"], undefined); + Assert.equal(address["postal-code"], "12345"); + Assert.notEqual(address.timeLastModified, timeLastModified); + Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 2); + + // Empty string should be deleted while updating. + await profileStorage.addresses.update( + profileStorage.addresses._data[0].guid, + TEST_ADDRESS_WITH_EMPTY_FIELD + ); + address = profileStorage.addresses._data[0]; + Assert.equal(address.name, TEST_ADDRESS_WITH_EMPTY_FIELD.name); + Assert.equal(address["street-address"], undefined); + Assert.equal(address[("unknown-1", "an unknown field from another client")]); + + // Empty computed fields shouldn't cause any problem. + await profileStorage.addresses.update( + profileStorage.addresses._data[0].guid, + TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD, + false + ); + address = profileStorage.addresses._data[0]; + Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email); + await profileStorage.addresses.update( + profileStorage.addresses._data[1].guid, + TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD, + true + ); + address = profileStorage.addresses._data[1]; + Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email); + + await Assert.rejects( + profileStorage.addresses.update("INVALID_GUID", TEST_ADDRESS_3), + /No matching record\./ + ); + + await Assert.rejects( + profileStorage.addresses.update(guid, TEST_ADDRESS_WITH_INVALID_FIELD), + /"email" contains invalid data type: object/ + ); + + await Assert.rejects( + profileStorage.addresses.update(guid, {}), + /Record contains no valid field\./ + ); + + await Assert.rejects( + profileStorage.addresses.update(guid, TEST_ADDRESS_EMPTY_AFTER_NORMALIZE), + /Record contains no valid field\./ + ); + + profileStorage.addresses.update(guid, TEST_ADDRESS_2); +}); + +add_task(async function test_notifyUsed() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[1].guid; + let timeLastUsed = addresses[1].timeLastUsed; + let timesUsed = addresses[1].timesUsed; + + profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below. + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "notifyUsed" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + profileStorage.addresses.notifyUsed(guid); + await onChanged; + + let address = await profileStorage.addresses.get(guid); + + Assert.equal(address.timesUsed, timesUsed + 1); + Assert.notEqual(address.timeLastUsed, timeLastUsed); + + // Using a record should not bump its change counter. + Assert.equal( + getSyncChangeCounter(profileStorage.addresses, guid), + changeCounter + ); + + Assert.throws( + () => profileStorage.addresses.notifyUsed("INVALID_GUID"), + /No matching record\./ + ); +}); + +add_task(async function test_remove() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + let guid = addresses[1].guid; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "remove" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + Assert.equal(addresses.length, 2); + + profileStorage.addresses.remove(guid); + await onChanged; + + addresses = await profileStorage.addresses.getAll(); + + Assert.equal(addresses.length, 1); + + Assert.equal(await profileStorage.addresses.get(guid), null); +}); diff --git a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js new file mode 100644 index 0000000000..49ffb0083a --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js @@ -0,0 +1,1045 @@ +/* + * Test for form auto fill content helper fill all inputs function. + */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +const { setTimeout, clearTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); +const { OSKeyStore } = ChromeUtils.importESModule( + "resource://gre/modules/OSKeyStore.sys.mjs" +); + +const TESTCASES = [ + { + description: "Form without autocomplete property", + document: `
+ +
`, + focusedInputId: "given-name", + profileData: {}, + expectedResult: { + "street-addr": "", + city: "", + country: "", + email: "", + tel: "", + }, + }, + { + description: "Form with autocomplete properties and 1 token", + document: `
+ + + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "2 Harrison St line2", + "-moz-street-address-one-line": "2 Harrison St line2", + "address-level2": "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + expectedResult: { + "street-addr": "2 Harrison St line2", + city: "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + }, + { + description: "Form with autocomplete properties and 2 tokens", + document: `
+ + + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "2 Harrison St", + "address-level2": "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + expectedResult: { + "street-addr": "2 Harrison St", + city: "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + }, + { + description: + "Form with autocomplete properties and profile is partly matched", + document: `
+ + + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "2 Harrison St", + "address-level2": "San Francisco", + country: "US", + email: "", + tel: "", + }, + expectedResult: { + "street-addr": "2 Harrison St", + city: "San Francisco", + country: "US", + email: "", + tel: "", + }, + }, + { + description: "Form with autocomplete properties but mismatched", + document: `
+ + + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "", + "address-level2": "", + country: "", + email: "foo@mozilla.com", + tel: "1234567", + }, + expectedResult: { + "street-addr": "", + city: "", + country: "", + email: "foo@mozilla.com", + tel: "1234567", + }, + }, + { + description: `Form with elements that have autocomplete set to "off"`, + document: `
+ + + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "given-name": "John", + "family-name": "Doe", + "street-address": "2 Harrison St", + country: "US", + organization: "Test organization", + }, + expectedResult: { + "given-name": "John", + "family-name": "Doe", + "street-address": "2 Harrison St", + organization: "Test organization", + country: "US", + }, + }, + { + description: `Form with autocomplete set to "off" and no autocomplete attribute on the form's elements`, + document: `
+ + + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "given-name": "John", + "family-name": "Doe", + "street-address": "2 Harrison St", + country: "US", + "address-level2": "Somewhere", + }, + expectedResult: { + "given-name": "John", + "family-name": "Doe", + "street-address": "2 Harrison St", + city: "Somewhere", + country: "US", + }, + }, + { + description: + "Form with autocomplete select elements and matching option values", + document: `
+ + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "CA", + }, + expectedResult: { + country: "US", + state: "CA", + }, + }, + { + description: + "Form with autocomplete select elements and matching option texts", + document: `
+ + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "United States", + "address-level1": "California", + }, + expectedResult: { + country: "US", + state: "CA", + }, + }, + { + description: "Form with a readonly input and non-readonly inputs", + document: `
+ + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "given-name": "John", + "family-name": "Doe", + "street-address": "100 Main Street", + city: "Hamilton", + }, + expectedResult: { + "given-name": "John", + "family-name": "Doe", + "street-addr": "100 Main Street", + city: "TEST CITY", + }, + }, + { + description: "Fill address fields in a form with addr and CC fields.", + document: `
+ + + + + + + + + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "street-address": "2 Harrison St line2", + "-moz-street-address-one-line": "2 Harrison St line2", + "address-level2": "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + }, + expectedResult: { + "street-addr": "2 Harrison St line2", + city: "San Francisco", + country: "US", + email: "foo@mozilla.com", + tel: "1234567", + "cc-number": "", + "cc-name": "", + "cc-exp-month": "", + "cc-exp-year": "", + }, + }, + { + description: + "Fill credit card fields in a form with address and CC fields.", + document: `
+ + + + + + + + + + + +
`, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": 6, + "cc-exp-year": 25, + }, + expectedResult: { + "street-addr": "", + city: "", + country: "", + email: "", + tel: "", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": "06", + "cc-exp-year": "25", + }, + }, + { + description: + "Fill credit card fields in a form with a placeholder on expiration month input field", + document: `
+ + + + +
+ `, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": 6, + "cc-exp-year": 25, + }, + expectedResult: { + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": "06", + "cc-exp-year": "25", + }, + }, + { + description: + "Fill credit card fields in a form without a placeholder on expiration month and expiration year input fields", + document: `
+ + + + +
+ `, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": 6, + "cc-exp-year": 25, + }, + expectedResult: { + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": "06", + "cc-exp-year": "25", + }, + }, + { + description: + "Fill credit card fields in a form with a placeholder on expiration year input field", + document: `
+ + + + +
+ `, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": 6, + "cc-exp-year": 2025, + }, + expectedResult: { + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": "06", + "cc-exp-year": "25", + }, + }, + { + description: + "Fill credit card fields in a form where the value property is being used as a placeholder for cardholder name", + document: `
+ + + + +
`, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": 6, + "cc-exp-year": 25, + }, + expectedResult: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": "06", + "cc-exp-year": "25", + }, + }, + { + description: + "Fill credit card number fields in a form with multiple cc-number inputs", + document: `
+ + + + + + +
`, + focusedInputId: "cc-number1", + profileData: { + guid: "123", + "cc-number": "371449635398431", + "cc-exp-month": 6, + "cc-exp-year": 25, + }, + expectedResult: { + guid: "123", + "cc-number1": "3714", + "cc-number2": "4963", + "cc-number3": "5398", + "cc-number4": "431", + "cc-exp-month": "06", + "cc-exp-year": "25", + }, + }, + { + description: + "Fill credit card number fields in a form with multiple valid credit card sections", + document: `
+ + + + + + + + + + + + + + + + + +
+ `, + focusedInputId: "cc-number1", + profileData: { + guid: "123", + "cc-type": "mastercard", + "cc-number": "371449635398431", + "cc-exp-month": 6, + "cc-exp-year": 25, + }, + expectedResult: { + guid: "123", + "cc-type1": "mastercard", + "cc-number1": "3714", + "cc-number2": "4963", + "cc-number3": "5398", + "cc-number4": "431", + "cc-exp-month1": "06", + "cc-exp-year1": "25", + "cc-type2": "", + "cc-number-5": "", + "cc-number-6": "", + "cc-number-7": "", + "cc-number-8": "", + "cc-exp-month2": "", + "cc-exp-year2": "", + }, + }, + { + description: + "Fill credit card fields in a form with placeholders on month and year and these inputs are type=tel", + document: `
+ + + + +
+ `, + focusedInputId: "cardHolder", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": 6, + "cc-exp-year": 2025, + }, + expectedResult: { + cardHolder: "test name", + cardNumber: "4111111111111111", + month: "06", + year: "25", + }, + }, +]; + +const TESTCASES_INPUT_UNCHANGED = [ + { + description: + "Form with autocomplete select elements; with default and no matching options", + document: `
+ + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "unknown state", + }, + expectedResult: { + country: "US", + state: "", + }, + }, +]; + +const TESTCASES_FILL_SELECT = [ + // US States + { + description: "Form with US states select elements", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "CA", + }, + expectedResult: { + state: "CA", + }, + }, + { + description: + "Form with US states select elements; with lower case state key", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "CA", + }, + expectedResult: { + state: "ca", + }, + }, + { + description: + "Form with US states select elements; with state name and extra spaces", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": " California ", + }, + expectedResult: { + state: "CA", + }, + }, + { + description: + "Form with US states select elements; with partial state key match", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + "address-level1": "WA", + }, + expectedResult: { + state: "US-WA", + }, + }, + + // Country + { + description: "Form with country select elements", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "US", + }, + }, + { + description: "Form with country select elements; with lower case key", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "us", + }, + }, + { + description: "Form with country select elements; with alternative name 1", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "XX", + }, + }, + { + description: "Form with country select elements; with alternative name 2", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "XX", + }, + }, + { + description: + "Form with country select elements; with partial matching value", + document: `
+ + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + country: "US", + }, + expectedResult: { + country: "XX", + }, + }, + { + description: + "Fill credit card expiration month field in a form with select field", + document: `
+ + +
`, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": 6, + "cc-exp-year": 25, + }, + expectedResult: { + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": "6", + "cc-exp-year": "2025", + }, + }, + { + description: + "Fill credit card information correctly when one of the card type options is 'American Express'", + document: `
+ + + + + +
`, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "378282246310005", + "cc-type": "amex", + "cc-name": "test name", + "cc-exp-month": 8, + "cc-exp-year": 26, + }, + expectedResult: { + guid: "123", + "cc-number": "378282246310005", + "cc-type": "AX", + "cc-name": "test name", + "cc-exp-month": 8, + "cc-exp-year": 26, + }, + }, +]; + +const TESTCASES_BOTH_CHANGED_AND_UNCHANGED = [ + { + description: + "Form with a disabled input and non-disabled inputs. The 'country' field should not change", + document: `
+ + + + +
`, + focusedInputId: "given-name", + profileData: { + guid: "123", + "given-name": "John", + "family-name": "Doe", + "street-address": "100 Main Street", + country: "CA", + }, + expectedResult: { + "given-name": "John", + "family-name": "Doe", + "street-addr": "100 Main Street", + country: "DE", + }, + }, +]; + +function do_test(testcases, testFn) { + for (let tc of testcases) { + (function () { + let testcase = tc; + add_task(async function () { + info("Starting testcase: " + testcase.description); + let ccNumber = testcase.profileData["cc-number"]; + if (ccNumber) { + testcase.profileData["cc-number-encrypted"] = + await OSKeyStore.encrypt(ccNumber); + delete testcase.profileData["cc-number"]; + } + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let form = doc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + let handler = new FormAutofillHandler(formLike); + let promises = []; + // Replace the internal decrypt method with OSKeyStore API, + // but don't pass the reauth parameter to avoid triggering + // reauth login dialog in these tests. + let decryptHelper = async (cipherText, reauth) => { + return OSKeyStore.decrypt(cipherText, false); + }; + handler.collectFormFields(); + + let focusedInput = doc.getElementById(testcase.focusedInputId); + try { + handler.focusedInput = focusedInput; + } catch (e) { + if (e.message.includes("WeakMap key must be an object")) { + throw new Error( + `Couldn't find the focusedInputId in the current form! Make sure focusedInputId exists in your test form! testcase description:${testcase.description}` + ); + } else { + throw e; + } + } + + for (let section of handler.sections) { + section._decrypt = decryptHelper; + } + + handler.activeSection.fieldDetails.forEach(field => { + let element = field.element; + if (!testcase.profileData[field.fieldName]) { + // Avoid waiting for `change` event of a input with a blank value to + // be filled. + return; + } + promises.push(...testFn(testcase, element)); + }); + + let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([ + testcase.profileData, + ]); + await handler.autofillFormFields(adaptedProfile, focusedInput); + Assert.equal( + handler.activeSection.filledRecordGUID, + testcase.profileData.guid, + "Check if filledRecordGUID is set correctly" + ); + await Promise.all(promises); + }); + })(); + } +} + +do_test(TESTCASES, (testcase, element) => { + let id = element.id; + return [ + new Promise(resolve => { + element.addEventListener( + "input", + () => { + Assert.ok(true, "Checking " + id + " field fires input event"); + resolve(); + }, + { once: true } + ); + }), + new Promise(resolve => { + element.addEventListener( + "change", + () => { + Assert.ok(true, "Checking " + id + " field fires change event"); + Assert.equal( + element.value, + testcase.expectedResult[id], + "Check the " + id + " field was filled with correct data" + ); + resolve(); + }, + { once: true } + ); + }), + ]; +}); + +do_test(TESTCASES_INPUT_UNCHANGED, (testcase, element) => { + return [ + new Promise((resolve, reject) => { + // Make sure no change or input event is fired when no change occurs. + let cleaner; + let timer = setTimeout(() => { + let id = element.id; + element.removeEventListener("change", cleaner); + element.removeEventListener("input", cleaner); + Assert.equal( + element.value, + testcase.expectedResult[id], + "Check no value is changed on the " + id + " field" + ); + resolve(); + }, 1000); + cleaner = event => { + clearTimeout(timer); + reject(`${event.type} event should not fire`); + }; + element.addEventListener("change", cleaner); + element.addEventListener("input", cleaner); + }), + ]; +}); + +do_test(TESTCASES_FILL_SELECT, (testcase, element) => { + let id = element.id; + return [ + new Promise(resolve => { + element.addEventListener( + "input", + () => { + Assert.equal( + element.value, + testcase.expectedResult[id], + "Check the " + id + " field was filled with correct data" + ); + resolve(); + }, + { once: true } + ); + }), + ]; +}); + +do_test(TESTCASES_BOTH_CHANGED_AND_UNCHANGED, (testcase, element) => { + // Ensure readonly and disabled inputs are not autofilled + if (element.readOnly || element.disabled) { + return [ + new Promise((resolve, reject) => { + // Make sure no change or input event is fired when no change occurs. + let cleaner; + let timer = setTimeout(() => { + let id = element.id; + element.removeEventListener("change", cleaner); + element.removeEventListener("input", cleaner); + Assert.equal( + element.value, + testcase.expectedResult[id], + "Check no value is changed on the " + id + " field" + ); + resolve(); + }, 1000); + cleaner = event => { + clearTimeout(timer); + reject(`${event.type} event should not fire`); + }; + element.addEventListener("change", cleaner); + element.addEventListener("input", cleaner); + }), + ]; + } + let id = element.id; + // Ensure that non-disabled and non-readonly fields are filled correctly + return [ + new Promise(resolve => { + element.addEventListener( + "input", + () => { + Assert.ok(true, "Checking " + id + " field fires input event"); + resolve(); + }, + { once: true } + ); + }), + new Promise(resolve => { + element.addEventListener( + "change", + () => { + Assert.ok(true, "Checking " + id + " field fires change event"); + Assert.equal( + element.value, + testcase.expectedResult[id], + "Check the " + id + " field was filled with correct data" + ); + resolve(); + }, + { once: true } + ); + }), + ]; +}); diff --git a/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js b/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js new file mode 100644 index 0000000000..ce81929433 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TESTCASES = [ + { + description: "Clear populated address form with text inputs", + document: `
+ + + + +
`, + focusedInputId: "given-name", + profileData: { + "given-name": "John", + "family-name": "Doe", + "street-addr": "1000 Main Street", + city: "Nowhere", + }, + expectedResult: { + "given-name": "", + "family-name": "", + "street-addr": "", + city: "", + }, + }, + { + description: "Clear populated address form with select and text inputs", + document: `
+ + + + +
`, + focusedInputId: "given-name", + profileData: { + "given-name": "John", + "family-name": "Doe", + "street-addr": "1000 Main Street", + state: "OH", + }, + expectedResult: { + "given-name": "", + "family-name": "", + "street-addr": "", + state: "AL", + }, + }, + { + description: + "Clear populated address form with select element with selected attribute and text inputs", + document: `
+ + + + +
`, + focusedInputId: "given-name", + profileData: { + "given-name": "John", + "family-name": "Doe", + "street-addr": "1000 Main Street", + state: "OH", + }, + expectedResult: { + "given-name": "", + "family-name": "", + "street-addr": "", + state: "AK", + }, + }, +]; + +add_task(async function do_test() { + let { FormAutofillHandler } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillHandler.sys.mjs" + ); + for (let test of TESTCASES) { + info("Test case: " + test.description); + let testDoc = MockDocument.createTestDocument( + "http://localhost:8080/test", + test.document + ); + let form = testDoc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + let handler = new FormAutofillHandler(formLike); + handler.collectFormFields(); + let focusedInput = testDoc.getElementById(test.focusedInputId); + handler.focusedInput = focusedInput; + let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([ + test.profileData, + ]); + await handler.autofillFormFields(adaptedProfile, focusedInput); + + handler.activeSection.clearPopulatedForm(); + handler.activeSection.fieldDetails.forEach(detail => { + let element = detail.element; + let id = element.id; + Assert.equal( + element.value, + test.expectedResult[id], + `Check the ${id} field was restored to the correct value` + ); + }); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_collectFormFields.js b/browser/extensions/formautofill/test/unit/test_collectFormFields.js new file mode 100644 index 0000000000..4f7f1b0767 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_collectFormFields.js @@ -0,0 +1,638 @@ +/* + * Test for form auto fill content helper collectFormFields functions. + */ + +"use strict"; + +var FormAutofillHandler; +add_setup(async () => { + ({ FormAutofillHandler } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillHandler.sys.mjs" + )); +}); + +const TESTCASES = [ + { + description: "Form without autocomplete property", + document: `
+ + + + + + + +
`, + sections: [ + [ + { fieldName: "given-name" }, + { fieldName: "family-name" }, + { fieldName: "address-line1" }, + { fieldName: "address-level2" }, + { fieldName: "country" }, + { fieldName: "email" }, + { fieldName: "tel" }, + ], + ], + ids: [ + "given-name", + "family-name", + "street-addr", + "city", + "country", + "email", + "phone", + ], + }, + { + description: + "An address and credit card form with autocomplete properties and 1 token", + document: `
+ + + + + + + + + + + +
`, + sections: [ + [ + { fieldName: "given-name" }, + { fieldName: "family-name" }, + { fieldName: "street-address" }, + { fieldName: "address-level2" }, + { fieldName: "country" }, + { fieldName: "email" }, + { fieldName: "tel" }, + ], + [ + { fieldName: "cc-number" }, + { fieldName: "cc-name" }, + { fieldName: "cc-exp-month" }, + { fieldName: "cc-exp-year" }, + ], + ], + }, + { + description: "An address form with autocomplete properties and 2 tokens", + document: `
+ + + + + +
`, + sections: [ + [ + { addressType: "shipping", fieldName: "given-name" }, + { addressType: "shipping", fieldName: "family-name" }, + { addressType: "shipping", fieldName: "street-address" }, + { addressType: "shipping", fieldName: "address-level2" }, + { addressType: "shipping", fieldName: "country" }, + { addressType: "shipping", fieldName: "email" }, + { addressType: "shipping", fieldName: "tel" }, + ], + ], + }, + { + description: + "Form with autocomplete properties and profile is partly matched", + document: `
+ + + + + +
`, + sections: [ + [ + { addressType: "shipping", fieldName: "given-name" }, + { addressType: "shipping", fieldName: "family-name" }, + { addressType: "shipping", fieldName: "street-address" }, + { addressType: "shipping", fieldName: "address-level2" }, + { addressType: "shipping", fieldName: "country" }, + { addressType: "shipping", fieldName: "email" }, + { addressType: "shipping", fieldName: "tel" }, + ], + ], + }, + { + description: "It's a valid address and credit card form.", + document: `
+ + + + +
`, + sections: [ + [ + { addressType: "shipping", fieldName: "given-name" }, + { addressType: "shipping", fieldName: "family-name" }, + { addressType: "shipping", fieldName: "street-address" }, + ], + [{ addressType: "shipping", fieldName: "cc-number" }], + ], + }, + { + description: "An invalid address form due to less than 3 fields.", + document: `
+ + +
`, + sections: [], + }, + /* + * Valid Credit Card Form with autocomplete attribute + */ + { + description: "@autocomplete - A valid credit card form", + document: `
+ + + +
`, + sections: [ + [ + { fieldName: "cc-number" }, + { fieldName: "cc-name" }, + { fieldName: "cc-exp" }, + ], + ], + }, + { + description: "@autocomplete - A valid credit card form without cc-numner", + document: `
+ + +
`, + sections: [[{ fieldName: "cc-name" }, { fieldName: "cc-exp" }]], + }, + { + description: "@autocomplete - A valid cc-number only form", + document: `
`, + sections: [[{ fieldName: "cc-number" }]], + }, + { + description: "@autocomplete - A valid cc-name only form", + document: `
`, + sections: [[{ fieldName: "cc-name" }]], + }, + { + description: "@autocomplete - A valid cc-exp only form", + document: `
`, + sections: [[{ fieldName: "cc-exp" }]], + }, + { + description: "@autocomplete - A valid cc-exp-month + cc-exp-year form", + document: `
+ + +
`, + sections: [[{ fieldName: "cc-exp-month" }, { fieldName: "cc-exp-year" }]], + }, + { + description: "@autocomplete - A valid cc-exp-month only form", + document: `
`, + sections: [[{ fieldName: "cc-exp-month" }]], + }, + { + description: "@autocomplete - A valid cc-exp-year only form", + document: `
`, + sections: [[{ fieldName: "cc-exp-year" }]], + }, + /* + * Valid Credit Card Form when cc-number or cc-name is detected by fathom + */ + { + description: + "A valid credit card form without autocomplete attribute (cc-number is detected by fathom)", + document: `
+ + + +
`, + sections: [ + [ + { fieldName: "cc-number" }, + { fieldName: "cc-name" }, + { fieldName: "cc-exp" }, + ], + ], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "0.8", + ], + ], + }, + { + description: + "A valid credit card form without autocomplete attribute (only cc-number is detected by fathom)", + document: `
+ + + +
`, + sections: [ + [ + { fieldName: "cc-number" }, + { fieldName: "cc-name" }, + { fieldName: "cc-exp" }, + ], + ], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "0.8", + ], + [ + "extensions.formautofill.creditCards.heuristics.fathom.types", + "cc-number", + ], + ], + }, + { + description: + "A valid credit card form without autocomplete attribute (only cc-name is detected by fathom)", + document: `
+ + +
`, + sections: [[{ fieldName: "cc-name" }, { fieldName: "cc-exp" }]], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "0.8", + ], + [ + "extensions.formautofill.creditCards.heuristics.fathom.types", + "cc-name", + ], + ], + }, + /* + * Invalid Credit Card Form when a cc-number or cc-name is detected by fathom + */ + { + description: + "A credit card form is invalid when a fathom detected cc-number field is the only field in the form", + document: `
`, + sections: [], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", + "0.9", + ], + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "0.8", + ], + ], + }, + { + description: + "A credit card form is invalid when a fathom detected cc-name field is the only field in the form", + document: `
`, + sections: [], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", + "0.9", + ], + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "0.8", + ], + ], + }, + /* + * Valid Credit Card Form when a cc-number or cc-name only form is detected by fathom (field is high confidence) + */ + { + description: + "A cc-number only form is considered a valid credit card form when fathom is confident and there is no other in the form", + document: `
`, + sections: [[{ fieldName: "cc-number" }]], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", + "0.95", + ], + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "0.99", + ], + ], + }, + { + description: + "A cc-name only form is considered a valid credit card form when fathom is confident and there is no other in the form", + document: `
`, + sections: [[{ fieldName: "cc-name" }]], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", + "0.95", + ], + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "0.99", + ], + ], + }, + /* + * Invalid Credit Card Form when none of the fields is identified by fathom + */ + { + description: + "A credit card form is invalid when none of the fields are identified by fathom or autocomplete", + document: `
+ + + +
`, + sections: [], + prefs: [ + ["extensions.formautofill.creditCards.heuristics.fathom.types", ""], + ], + }, + // Special Cases + { + description: + "A credit card form with a high-confidence cc-name field is still considered invalid when there is another field", + document: `
+ + +
`, + sections: [], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", + "0.95", + ], + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "0.96", + ], + ], + }, + { + description: "A valid credit card form with multiple cc-number fields", + document: `
+ + + + + + +
`, + sections: [ + [ + { fieldName: "cc-number" }, + { fieldName: "cc-number" }, + { fieldName: "cc-number" }, + { fieldName: "cc-number" }, + { fieldName: "cc-exp-month" }, + { fieldName: "cc-exp-year" }, + ], + ], + ids: [ + "cc-number1", + "cc-number2", + "cc-number3", + "cc-number4", + "cc-exp-month", + "cc-exp-year", + ], + }, + { + description: "Three sets of adjacent phone number fields", + document: `
+ + + + + + + + + + + + + +
`, + sections: [ + [ + { fieldName: "tel-area-code" }, + { fieldName: "tel-local-prefix" }, + { fieldName: "tel-local-suffix" }, + { fieldName: "tel-extension" }, + ], + [ + { fieldName: "tel-area-code" }, + { fieldName: "tel-local-prefix" }, + { fieldName: "tel-local-suffix" }, + + // TODO Bug 1421181 - "tel-country-code" field should belong to the next + // section. There should be a way to group the related fields during the + // parsing stage. + { fieldName: "tel-country-code" }, + ], + [ + { fieldName: "tel-area-code" }, + { fieldName: "tel-local-prefix" }, + { fieldName: "tel-local-suffix" }, + ], + ], + ids: [ + "shippingAC", + "shippingPrefix", + "shippingSuffix", + "shippingTelExt", + "billingAC", + "billingPrefix", + "billingSuffix", + "otherCC", + "otherAC", + "otherPrefix", + "otherSuffix", + ], + }, + { + description: + "Do not dedup the same field names of the different telephone fields.", + document: `
+ + + + + + + + +
`, + sections: [ + [ + { fieldName: "given-name" }, + { fieldName: "family-name" }, + { fieldName: "street-address" }, + { fieldName: "email" }, + { fieldName: "tel" }, + { fieldName: "tel" }, + { fieldName: "tel" }, + ], + ], + ids: ["i1", "i2", "i3", "i4", "homePhone", "mobilePhone", "officePhone"], + }, + { + description: + "The duplicated phones of a single one and a set with ac, prefix, suffix.", + document: `
+ + + + + + + + +
`, + sections: [ + [ + { addressType: "shipping", fieldName: "given-name" }, + { addressType: "shipping", fieldName: "family-name" }, + { addressType: "shipping", fieldName: "street-address" }, + { addressType: "shipping", fieldName: "email" }, + + // NOTES: Ideally, there is only one full telephone field(s) in a form for + // this case. We can see if there is any better solution later. + { addressType: "shipping", fieldName: "tel" }, + { addressType: "shipping", fieldName: "tel-area-code" }, + { addressType: "shipping", fieldName: "tel-local-prefix" }, + { addressType: "shipping", fieldName: "tel-local-suffix" }, + ], + ], + ids: [ + "i1", + "i2", + "i3", + "i4", + "singlePhone", + "shippingAreaCode", + "shippingPrefix", + "shippingSuffix", + ], + }, + { + description: "Always adopt the info from autocomplete attribute.", + document: `
+ + + + + +
`, + sections: [ + [ + { addressType: "shipping", fieldName: "given-name" }, + { addressType: "shipping", fieldName: "family-name" }, + { addressType: "shipping", fieldName: "tel" }, + { addressType: "shipping", fieldName: "tel" }, + { addressType: "shipping", fieldName: "tel" }, + ], + ], + ids: [ + "given-name", + "family-name", + "dummyAreaCode", + "dummyPrefix", + "dummySuffix", + ], + }, +]; + +function verifyDetails(handlerDetails, testCaseDetails) { + if (handlerDetails === null) { + Assert.equal(handlerDetails, testCaseDetails); + return; + } + Assert.equal(handlerDetails.length, testCaseDetails.length, "field count"); + handlerDetails.forEach((detail, index) => { + Assert.equal( + detail.fieldName, + testCaseDetails[index].fieldName, + "fieldName" + ); + Assert.equal( + detail.section, + testCaseDetails[index].section ?? "", + "section" + ); + Assert.equal( + detail.addressType, + testCaseDetails[index].addressType ?? "", + "addressType" + ); + Assert.equal( + detail.contactType, + testCaseDetails[index].contactType ?? "", + "contactType" + ); + Assert.equal( + detail.element, + testCaseDetails[index].elementWeakRef.deref(), + "DOM reference" + ); + }); +} + +for (let tc of TESTCASES) { + (function () { + let testcase = tc; + add_task(async function () { + info("Starting testcase: " + testcase.description); + + if (testcase.prefs) { + testcase.prefs.forEach(pref => SetPref(pref[0], pref[1])); + } + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + testcase.sections.flat().forEach((field, idx) => { + let elementRef = doc.getElementById( + testcase.ids?.[idx] ?? field.fieldName + ); + field.elementWeakRef = new WeakRef(elementRef); + }); + + let form = doc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + + let handler = new FormAutofillHandler(formLike); + let validFieldDetails = handler.collectFormFields(); + + Assert.equal( + handler.sections.length, + testcase.sections.length, + "section count" + ); + for (let i = 0; i < handler.sections.length; i++) { + let section = handler.sections[i]; + verifyDetails(section.fieldDetails, testcase.sections[i]); + } + verifyDetails(validFieldDetails, testcase.sections.flat()); + + if (testcase.prefs) { + testcase.prefs.forEach(pref => Services.prefs.clearUserPref(pref[0])); + } + }); + })(); +} diff --git a/browser/extensions/formautofill/test/unit/test_createRecords.js b/browser/extensions/formautofill/test/unit/test_createRecords.js new file mode 100644 index 0000000000..776d40c06a --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_createRecords.js @@ -0,0 +1,377 @@ +/* + * Test for the normalization of records created by FormAutofillHandler. + */ + +"use strict"; + +var FormAutofillHandler; +add_task(async function seutp() { + ({ FormAutofillHandler } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillHandler.sys.mjs" + )); +}); + +const TESTCASES = [ + { + description: + "Don't contain a field whose length of value is greater than 200", + document: `
+ + + + + + +
`, + formValue: { + "given-name": "John", + organization: "*".repeat(200), + "address-level1": "*".repeat(201), + country: "US", + "cc-number": "1111222233334444", + "cc-name": "*".repeat(201), + }, + expectedRecord: { + address: [ + { + "given-name": "John", + organization: "*".repeat(200), + "address-level1": "", + country: "US", + }, + ], + creditCard: [ + { + "cc-number": "1111222233334444", + "cc-name": "", + }, + ], + }, + }, + { + description: "Don't create address record if filled data is less than 3", + document: `
+ + + +
`, + formValue: { + "given-name": "John", + organization: "Mozilla", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: "All name related fields should be counted as 1 field only.", + document: `
+ + + +
`, + formValue: { + "given-name": "John", + "family-name": "Doe", + organization: "Mozilla", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: + "All telephone related fields should be counted as 1 field only.", + document: `
+ + + + +
`, + formValue: { + "tel-country-code": "+1", + "tel-area-code": "123", + "tel-local": "4567890", + organization: "Mozilla", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: + "A credit card form with the value of cc-number, cc-exp, cc-name and cc-type.", + document: `
+ + + + +
`, + formValue: { + "cc-number": "5105105105105100", + "cc-name": "Foo Bar", + "cc-exp": "2022-06", + "cc-type": "Visa", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "5105105105105100", + "cc-name": "Foo Bar", + "cc-exp": "2022-06", + "cc-type": "Visa", + }, + ], + }, + }, + { + description: "A credit card form with cc-number value only.", + document: `
+ +
`, + formValue: { + "cc-number": "4111111111111111", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "4111111111111111", + }, + ], + }, + }, + { + description: "A credit card form must have cc-number value.", + document: `
+ + + +
`, + formValue: { + "cc-number": "", + "cc-name": "Foo Bar", + "cc-exp": "2022-06", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: "A credit card form must have cc-number field.", + document: `
+ + +
`, + formValue: { + "cc-name": "Foo Bar", + "cc-exp": "2022-06", + }, + expectedRecord: { + address: [], + creditCard: [], + }, + }, + { + description: "A form with multiple sections", + document: `
+ + + + + + + + + + + + + + + + + + + +
`, + formValue: { + "given-name": "Bar", + organization: "Foo", + country: "US", + + "given-name-shipping": "John", + "family-name-shipping": "Doe", + "organization-shipping": "Mozilla", + "country-shipping": "US", + + "given-name-billing": "Foo", + "organization-billing": "Bar", + "country-billing": "US", + + "cc-number-section-one": "4111111111111111", + "cc-name-section-one": "John", + + "cc-number-section-two": "5105105105105100", + "cc-name-section-two": "Foo Bar", + "cc-exp-section-two": "2026-26", + }, + expectedRecord: { + address: [ + { + "given-name": "Bar", + organization: "Foo", + country: "US", + }, + { + "given-name": "John", + "family-name": "Doe", + organization: "Mozilla", + country: "US", + }, + { + "given-name": "Foo", + organization: "Bar", + country: "US", + }, + ], + creditCard: [ + { + "cc-number": "4111111111111111", + "cc-name": "John", + }, + { + "cc-number": "5105105105105100", + "cc-name": "Foo Bar", + "cc-exp": "2026-26", + }, + ], + }, + }, + { + description: "A credit card form with a cc-type select.", + document: `
+ + + +
`, + formValue: { + "cc-number": "5105105105105100", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "5105105105105100", + "cc-type": "visa", + }, + ], + }, + }, + { + description: "A credit card form with a cc-type select from label.", + document: `
+ + + +
`, + formValue: { + "cc-number": "5105105105105100", + "cc-type": "A", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "5105105105105100", + "cc-type": "amex", + }, + ], + }, + }, + { + description: + "A credit card form with separate expiry fields should have normalized expiry data.", + document: `
+ + + +
`, + formValue: { + "cc-number": "5105105105105100", + "cc-exp-month": "05", + "cc-exp-year": "26", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "5105105105105100", + "cc-exp-month": "05", + "cc-exp-year": "26", + }, + ], + }, + }, + { + description: + "A credit card form with combined expiry fields should have normalized expiry data.", + document: `
+ + +
`, + formValue: { + "cc-number": "5105105105105100", + "cc-exp": "07/27", + }, + expectedRecord: { + address: [], + creditCard: [ + { + "cc-number": "5105105105105100", + "cc-exp": "07/27", + }, + ], + }, + }, +]; + +for (let testcase of TESTCASES) { + add_task(async function () { + info("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let form = doc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + let handler = new FormAutofillHandler(formLike); + + handler.collectFormFields(); + + for (let id in testcase.formValue) { + doc.getElementById(id).value = testcase.formValue[id]; + } + + let record = handler.createRecords(); + + let expectedRecord = testcase.expectedRecord; + for (let type in record) { + Assert.deepEqual( + record[type].map(secRecord => secRecord.record), + expectedRecord[type] + ); + } + }); +} diff --git a/browser/extensions/formautofill/test/unit/test_creditCardRecords.js b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js new file mode 100644 index 0000000000..de9752f09a --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js @@ -0,0 +1,686 @@ +/** + * Tests FormAutofillStorage object with creditCards records. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); +const { CreditCard } = ChromeUtils.importESModule( + "resource://gre/modules/CreditCard.sys.mjs" +); + +let FormAutofillStorage; +let CREDIT_CARD_SCHEMA_VERSION; +add_setup(async () => { + ({ FormAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + )); + ({ CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorageBase.sys.mjs" + )); +}); + +const TEST_STORE_FILE_NAME = "test-credit-card.json"; +const COLLECTION_NAME = "creditCards"; + +const TEST_CREDIT_CARD_1 = { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 4, + "cc-exp-year": 2017, +}; + +const TEST_CREDIT_CARD_2 = { + "cc-name": "Timothy Berners-Lee", + "cc-number": "5103059495477870", + "cc-exp-month": 12, + "cc-exp-year": 2022, +}; + +const TEST_CREDIT_CARD_3 = { + "cc-number": "3589993783099582", + "cc-exp-month": 1, + "cc-exp-year": 2000, +}; + +const TEST_CREDIT_CARD_WITH_BILLING_ADDRESS = { + "cc-name": "J. Smith", + "cc-number": "4111111111111111", + billingAddressGUID: "9m6hf4gfr6ge", +}; + +const TEST_CREDIT_CARD_WITH_EMPTY_FIELD = { + billingAddressGUID: "", + "cc-name": "", + "cc-number": "344060747836806", + "cc-exp-month": 1, +}; + +const TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD = { + "cc-given-name": "", + "cc-additional-name": "", + "cc-family-name": "", + "cc-exp": "", + "cc-number": "5415425865751454", +}; + +const TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR = { + "cc-number": "344060747836806", + "cc-exp-month": 1, + "cc-exp-year": 12, +}; + +const TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE = { + "cc-name": "John Doe", + "cc-number": "5103059495477870", + "cc-exp-month": 13, + "cc-exp-year": -3, +}; + +const TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS = { + "cc-name": "John Doe", + "cc-number": "5103 0594 9547 7870", +}; + +const TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE = { + "cc-exp-month": 13, +}; + +const TEST_CREDIT_CARD_EMPTY_AFTER_UPDATE_CREDIT_CARD_1 = { + "cc-name": "", + "cc-number": "", + "cc-exp-month": 13, + "cc-exp-year": "", +}; + +let prepareTestCreditCards = async function (path) { + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "add" && + subject.wrappedJSObject.guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + Assert.ok(await profileStorage.creditCards.add(TEST_CREDIT_CARD_1)); + await onChanged; + Assert.ok(await profileStorage.creditCards.add(TEST_CREDIT_CARD_2)); + await onChanged; + await profileStorage._saveImmediately(); +}; + +let reCCNumber = /^(\*+)(.{4})$/; + +let do_check_credit_card_matches = (creditCardWithMeta, creditCard) => { + for (let key in creditCard) { + if (key == "cc-number") { + let matches = reCCNumber.exec(creditCardWithMeta["cc-number"]); + Assert.notEqual(matches, null); + Assert.equal( + creditCardWithMeta["cc-number"].length, + creditCard["cc-number"].length + ); + Assert.equal(creditCard["cc-number"].endsWith(matches[2]), true); + Assert.notEqual(creditCard["cc-number-encrypted"], ""); + } else { + Assert.equal(creditCardWithMeta[key], creditCard[key], "Testing " + key); + } + } +}; + +add_task(async function test_initialize() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + Assert.equal(profileStorage._store.data.version, 1); + Assert.equal(profileStorage._store.data.creditCards.length, 0); + + let data = profileStorage._store.data; + Assert.deepEqual(data.creditCards, []); + + await profileStorage._saveImmediately(); + + profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + Assert.deepEqual(profileStorage._store.data, data); +}); + +add_task(async function test_getAll() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards.length, 2); + do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_1); + do_check_credit_card_matches(creditCards[1], TEST_CREDIT_CARD_2); + + // Check computed fields. + Assert.equal(creditCards[0]["cc-given-name"], "John"); + Assert.equal(creditCards[0]["cc-family-name"], "Doe"); + Assert.equal(creditCards[0]["cc-exp"], "2017-04"); + + // Test with rawData set. + creditCards = await profileStorage.creditCards.getAll({ rawData: true }); + Assert.equal(creditCards[0]["cc-given-name"], undefined); + Assert.equal(creditCards[0]["cc-family-name"], undefined); + Assert.equal(creditCards[0]["cc-exp"], undefined); + + // Modifying output shouldn't affect the storage. + creditCards[0]["cc-name"] = "test"; + do_check_credit_card_matches( + (await profileStorage.creditCards.getAll())[0], + TEST_CREDIT_CARD_1 + ); +}); + +add_task(async function test_get() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[0].guid; + + let creditCard = await profileStorage.creditCards.get(guid); + do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_1); + + // Modifying output shouldn't affect the storage. + creditCards[0]["cc-name"] = "test"; + do_check_credit_card_matches( + await profileStorage.creditCards.get(guid), + TEST_CREDIT_CARD_1 + ); + + Assert.equal(await profileStorage.creditCards.get("INVALID_GUID"), null); +}); + +add_task(async function test_add() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards.length, 2); + + do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_1); + do_check_credit_card_matches(creditCards[1], TEST_CREDIT_CARD_2); + + Assert.notEqual(creditCards[0].guid, undefined); + Assert.equal(creditCards[0].version, CREDIT_CARD_SCHEMA_VERSION); + Assert.notEqual(creditCards[0].timeCreated, undefined); + Assert.equal(creditCards[0].timeLastModified, creditCards[0].timeCreated); + Assert.equal(creditCards[0].timeLastUsed, 0); + Assert.equal(creditCards[0].timesUsed, 0); + + // Empty string should be deleted before saving. + await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_EMPTY_FIELD); + let creditCard = profileStorage.creditCards._data[2]; + Assert.equal( + creditCard["cc-exp-month"], + TEST_CREDIT_CARD_WITH_EMPTY_FIELD["cc-exp-month"] + ); + Assert.equal(creditCard["cc-name"], undefined); + Assert.equal(creditCard.billingAddressGUID, undefined); + + // Empty computed fields shouldn't cause any problem. + await profileStorage.creditCards.add( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD + ); + creditCard = profileStorage.creditCards._data[3]; + Assert.equal( + creditCard["cc-number"], + CreditCard.getLongMaskedNumber( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"] + ) + ); + + await Assert.rejects( + profileStorage.creditCards.add({}), + /Record contains no valid field\./ + ); + + await Assert.rejects( + profileStorage.creditCards.add(TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE), + /Record contains no valid field\./ + ); +}); + +add_task(async function test_addWithBillingAddress() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards.length, 0); + + await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_BILLING_ADDRESS); + + creditCards = await profileStorage.creditCards.getAll(); + Assert.equal(creditCards.length, 1); + do_check_credit_card_matches( + creditCards[0], + TEST_CREDIT_CARD_WITH_BILLING_ADDRESS + ); +}); + +add_task(async function test_update() { + // Test assumes that when an entry is saved a second time, it's last modified date will + // be different from the first. With high values of precision reduction, we execute too + // fast for that to be true. + let timerPrecision = Preferences.get("privacy.reduceTimerPrecision"); + Preferences.set("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(function () { + Preferences.set("privacy.reduceTimerPrecision", timerPrecision); + }); + + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[1].guid; + let timeLastModified = creditCards[1].timeLastModified; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "update" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + Assert.notEqual(creditCards[1]["cc-name"], undefined); + await profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_3); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCard = await profileStorage.creditCards.get(guid); + + Assert.equal(creditCard["cc-name"], undefined); + Assert.notEqual(creditCard.timeLastModified, timeLastModified); + do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_3); + + // Empty string should be deleted while updating. + await profileStorage.creditCards.update( + profileStorage.creditCards._data[0].guid, + TEST_CREDIT_CARD_WITH_EMPTY_FIELD + ); + creditCard = profileStorage.creditCards._data[0]; + Assert.equal( + creditCard["cc-exp-month"], + TEST_CREDIT_CARD_WITH_EMPTY_FIELD["cc-exp-month"] + ); + Assert.equal(creditCard["cc-name"], undefined); + Assert.equal(creditCard["cc-type"], "amex"); + Assert.equal(creditCard.billingAddressGUID, undefined); + + // Empty computed fields shouldn't cause any problem. + await profileStorage.creditCards.update( + profileStorage.creditCards._data[0].guid, + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD, + false + ); + creditCard = profileStorage.creditCards._data[0]; + Assert.equal( + creditCard["cc-number"], + CreditCard.getLongMaskedNumber( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"] + ) + ); + await profileStorage.creditCards.update( + profileStorage.creditCards._data[1].guid, + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD, + true + ); + creditCard = profileStorage.creditCards._data[1]; + Assert.equal( + creditCard["cc-number"], + CreditCard.getLongMaskedNumber( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"] + ) + ); + + // Decryption failure of existing record should not prevent it from being updated. + creditCard = profileStorage.creditCards._data[0]; + creditCard["cc-number-encrypted"] = "INVALID"; + await profileStorage.creditCards.update( + profileStorage.creditCards._data[0].guid, + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD, + false + ); + creditCard = profileStorage.creditCards._data[0]; + Assert.equal( + creditCard["cc-number"], + CreditCard.getLongMaskedNumber( + TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"] + ) + ); + + await Assert.rejects( + profileStorage.creditCards.update("INVALID_GUID", TEST_CREDIT_CARD_3), + /No matching record\./ + ); + + await Assert.rejects( + profileStorage.creditCards.update(guid, {}), + /Record contains no valid field\./ + ); + + await Assert.rejects( + profileStorage.creditCards.update( + guid, + TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE + ), + /Record contains no valid field\./ + ); + + await profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_1); + await Assert.rejects( + profileStorage.creditCards.update( + guid, + TEST_CREDIT_CARD_EMPTY_AFTER_UPDATE_CREDIT_CARD_1 + ), + /Record contains no valid field\./ + ); +}); + +add_task(async function test_validate() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + await profileStorage.creditCards.add( + TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE + ); + await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR); + await profileStorage.creditCards.add( + TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS + ); + let creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards[0]["cc-exp-month"], undefined); + Assert.equal(creditCards[0]["cc-exp-year"], undefined); + Assert.equal(creditCards[0]["cc-exp"], undefined); + + let month = TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-month"]; + let year = + parseInt(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-year"], 10) + 2000; + Assert.equal(creditCards[1]["cc-exp-month"], month); + Assert.equal(creditCards[1]["cc-exp-year"], year); + Assert.equal( + creditCards[1]["cc-exp"], + year + "-" + month.toString().padStart(2, "0") + ); + + Assert.equal(creditCards[2]["cc-number"].length, 16); +}); + +add_task(async function test_notifyUsed() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[1].guid; + let timeLastUsed = creditCards[1].timeLastUsed; + let timesUsed = creditCards[1].timesUsed; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "notifyUsed" && + subject.wrappedJSObject.collectionName == COLLECTION_NAME && + subject.wrappedJSObject.guid == guid + ); + + profileStorage.creditCards.notifyUsed(guid); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCard = await profileStorage.creditCards.get(guid); + + Assert.equal(creditCard.timesUsed, timesUsed + 1); + Assert.notEqual(creditCard.timeLastUsed, timeLastUsed); + + Assert.throws( + () => profileStorage.creditCards.notifyUsed("INVALID_GUID"), + /No matching record\./ + ); +}); + +add_task(async function test_remove() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + await prepareTestCreditCards(path); + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let creditCards = await profileStorage.creditCards.getAll(); + let guid = creditCards[1].guid; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "remove" && + subject.wrappedJSObject.guid == guid && + subject.wrappedJSObject.collectionName == COLLECTION_NAME + ); + + Assert.equal(creditCards.length, 2); + + profileStorage.creditCards.remove(guid); + await onChanged; + await profileStorage._saveImmediately(); + + profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + creditCards = await profileStorage.creditCards.getAll(); + + Assert.equal(creditCards.length, 1); + + Assert.equal(await profileStorage.creditCards.get(guid), null); +}); + +add_task(async function test_getDuplicateRecords() { + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + [TEST_CREDIT_CARD_3], + "creditCards" + ); + let guid = profileStorage.creditCards._data[0].guid; + + // Absolutely a duplicate. + let getDuplicateRecords = + profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_3); + let dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe.guid, guid); + + // Absolutely not a duplicate. + getDuplicateRecords = + profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_1); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe, null); + + // Subset with the same number is a duplicate. + let record = Object.assign({}, TEST_CREDIT_CARD_3); + delete record["cc-exp-month"]; + getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe.guid, guid); + + // Superset with the same number is a duplicate. + record = Object.assign({}, TEST_CREDIT_CARD_3); + record["cc-name"] = "John Doe"; + getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe.guid, guid); + + // Numbers with the same last 4 digits shouldn't be treated as a duplicate. + record = Object.assign({}, TEST_CREDIT_CARD_3); + let last4Digits = record["cc-number"].substr(-4); + getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe.guid, guid); + + // This number differs from TEST_CREDIT_CARD_3 by swapping the order of the + // 09 and 90 adjacent digits, which is still a valid credit card number. + record["cc-number"] = "358999378390" + last4Digits; + + // We don't treat numbers with the same last 4 digits as a duplicate. + getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe, null); +}); + +add_task(async function test_getDuplicateRecordsMatch() { + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + [TEST_CREDIT_CARD_2], + "creditCards" + ); + let guid = profileStorage.creditCards._data[0].guid; + + // Absolutely a duplicate. + let getDuplicateRecords = + profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_2); + let dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe.guid, guid); + + // Absolutely not a duplicate. + getDuplicateRecords = + profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_1); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe, null); + + record = Object.assign({}, TEST_CREDIT_CARD_2); + + // We change month from `1` to `2` + record["cc-exp-month"] = 2; + getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe.guid, guid); + + // We change year from `2000` to `2001` + record["cc-exp-year"] = 2001; + getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe.guid, guid); + + // New name, same card + record["cc-name"] = "John Doe"; + getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record); + dupe = (await getDuplicateRecords.next()).value; + Assert.equal(dupe.guid, guid); +}); + +add_task(async function test_getMatchRecord() { + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + [TEST_CREDIT_CARD_2], + "creditCards" + ); + let guid = profileStorage.creditCards._data[0].guid; + + const TEST_FIELDS = { + "cc-name": "John Doe", + "cc-exp-month": 10, + "cc-exp-year": 2001, + }; + + // Absolutely a match. + let getMatchRecords = + profileStorage.creditCards.getMatchRecords(TEST_CREDIT_CARD_2); + let match = (await getMatchRecords.next()).value; + Assert.equal(match.guid, guid); + + // Subset with the same number is a match. + for (const field of Object.keys(TEST_FIELDS)) { + let record = Object.assign({}, TEST_CREDIT_CARD_2); + delete record[field]; + getMatchRecords = profileStorage.creditCards.getMatchRecords(record); + match = (await getMatchRecords.next()).value; + Assert.equal(match.guid, guid); + } + + // Subset with different number is not a match. + for (const field of Object.keys(TEST_FIELDS)) { + let record = Object.assign({}, TEST_CREDIT_CARD_2, { + "cc-number": TEST_CREDIT_CARD_1["cc-number"], + }); + delete record[field]; + getMatchRecords = profileStorage.creditCards.getMatchRecords(record); + match = (await getMatchRecords.next()).value; + Assert.equal(match, null); + } + + // Superset with the same number is not a match. + for (const [field, value] of Object.entries(TEST_FIELDS)) { + let record = Object.assign({}, TEST_CREDIT_CARD_2); + record[field] = value; + getMatchRecords = profileStorage.creditCards.getMatchRecords(record); + match = (await getMatchRecords.next()).value; + Assert.equal(match, null); + } + + // Superset with different number is not a match. + for (const [field, value] of Object.entries(TEST_FIELDS)) { + let record = Object.assign({}, TEST_CREDIT_CARD_2, { + "cc-number": TEST_CREDIT_CARD_1["cc-number"], + }); + record[field] = value; + getMatchRecords = profileStorage.creditCards.getMatchRecords(record); + match = (await getMatchRecords.next()).value; + Assert.equal(match, null); + } +}); + +add_task(async function test_creditCardFillDisabled() { + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + Assert.equal( + !!profileStorage.creditCards, + true, + "credit card records initialized and available." + ); + + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); +}); diff --git a/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js b/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js new file mode 100644 index 0000000000..4c03c263f8 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js @@ -0,0 +1,77 @@ +"use strict"; + +var { LabelUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/LabelUtils.sys.mjs" +); + +const TESTCASES = [ + { + description: "A label element contains one input element.", + document: ``, + inputId: "typeA", + expectedStrings: ["label type A"], + }, + { + description: "A label element with inner div contains one input element.", + document: ``, + inputId: "typeB", + expectedStrings: ["label type B", "inner div"], + }, + { + description: + "A label element with inner prefix/postfix strings contains span elements.", + document: ``, + inputId: "typeC", + expectedStrings: [ + "label type C", + "inner div prefix", + "test C-1", + "test C-2", + "inner div postfix", + ], + }, +]; + +TESTCASES.forEach(testcase => { + add_task(async function () { + info("Starting testcase: " + testcase.description); + LabelUtils._labelStrings = new WeakMap(); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let element = doc.getElementById(testcase.inputId); + let strings = LabelUtils.extractLabelStrings(element); + + Assert.deepEqual(strings, testcase.expectedStrings); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_findLabelElements.js b/browser/extensions/formautofill/test/unit/test_findLabelElements.js new file mode 100644 index 0000000000..956ace83f6 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_findLabelElements.js @@ -0,0 +1,100 @@ +"use strict"; + +var { LabelUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/LabelUtils.sys.mjs" +); + +const TESTCASES = [ + { + description: "Input contains in a label element.", + document: `
+ +
`, + inputId: "typeA", + expectedLabelIds: ["labelA"], + }, + { + description: "Input contains in a label element.", + document: ``, + inputId: "typeB", + expectedLabelIds: ["labelB"], + }, + { + description: '"for" attribute used to indicate input by one label.', + document: ` + `, + inputId: "typeC", + expectedLabelIds: ["labelC"], + }, + { + description: '"for" attribute used to indicate input by multiple labels.', + document: `
+ + + + +
`, + inputId: "typeD", + expectedLabelIds: ["labelD1", "labelD2", "labelD3"], + }, + { + description: + '"for" attribute used to indicate input by multiple labels with space prefix/postfix.', + document: ` + + + + `, + inputId: " typeE ", + expectedLabelIds: [], + }, + { + description: "Input contains in a label element.", + document: ` + `, + elementId: "targetElement", + expectedReturnValue: ["email", null, null], + }, + { + description: + "A label element is out of the form contains the related input", + document: ` +
+ +
`, + elementId: "targetElement", + expectedReturnValue: ["email", null, null], + }, + { + description: "A label element contains span element", + document: ` +
+ +
`, + elementId: "targetElement", + expectedReturnValue: ["email", null, null], + }, + { + description: "The signature in 'name' attr of an input", + document: ``, + elementId: "targetElement", + expectedReturnValue: ["email", null, null], + }, + { + description: "The signature in 'id' attr of an input", + document: ``, + elementId: "targetElement_email", + expectedReturnValue: ["email", null, null], + }, + { + description: "Select element in a label element", + document: `
+ +
`, + elementId: "targetElement", + expectedReturnValue: ["address-level1", null, null], + }, + { + description: "A select element without a form wrapped", + document: ` + `, + elementId: "targetElement", + expectedReturnValue: ["address-level1", null, null], + }, + { + description: "address line input", + document: ` + `, + elementId: "targetElement", + expectedReturnValue: ["street-address", null, null], + }, + { + description: "CJK character - Traditional Chinese", + document: ``, + elementId: "targetElement", + expectedReturnValue: ["postal-code", null, null], + }, + { + description: "CJK character - Japanese", + document: ``, + elementId: "targetElement", + expectedReturnValue: ["postal-code", null, null], + }, + { + description: "CJK character - Korean", + document: ``, + elementId: "targetElement", + expectedReturnValue: ["postal-code", null, null], + }, + { + description: "", + document: ``, + elementId: "targetElement", + expectedReturnValue: ["name", null, null], + }, + { + description: 'input element with "submit" type', + document: ``, + elementId: "targetElement", + expectedReturnValue: [null, null, null], + }, + { + description: "The signature in 'name' attr of an email input", + document: ``, + elementId: "targetElement", + expectedReturnValue: ["email", null, null], + }, + { + description: 'input element with "email" type', + document: ``, + elementId: "targetElement", + expectedReturnValue: ["email", null, null], + }, + { + description: "Exclude United State string", + document: ``, + elementId: "targetElement", + expectedReturnValue: [null, null, null], + }, + { + description: '"County" field with "United State" string', + document: ``, + elementId: "targetElement", + expectedReturnValue: ["address-level1", null, null], + }, + { + description: '"city" field with double "United State" string', + document: ``, + elementId: "targetElement", + expectedReturnValue: ["address-level2", null, null], + }, + { + description: "Verify credit card number", + document: `
+ + +
`, + elementId: "targetElement", + expectedReturnValue: ["cc-number", null, 1], + }, + { + description: "Identify credit card type field", + document: `
+ + +
`, + elementId: "targetElement", + expectedReturnValue: ["cc-type", null, null], + }, + { + description: `Identify address field when contained in a form with autocomplete="off"`, + document: `
+ +
`, + elementId: "given-name", + expectedReturnValue: ["given-name", null, null], + }, + { + description: `Identify address field that has a placeholder but no label associated with it`, + document: `
+ +
`, + elementId: "targetElement", + expectedReturnValue: ["name", null, null], + }, + { + description: `Identify address field that has a placeholder, no associated label, and its autocomplete attribute is "off"`, + document: `
+ +
`, + elementId: "targetElement", + expectedReturnValue: ["street-address", null, null], + }, + { + description: `Identify address field that has a placeholder, no associated label, and the form's autocomplete attribute is "off"`, + document: `
+ +
`, + elementId: "targetElement", + expectedReturnValue: ["country", null, null], + }, +]; + +add_setup(async function () { + Services.prefs.setStringPref( + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "1" + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref( + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence" + ); + }); +}); + +TESTCASES.forEach(testcase => { + add_task(async function () { + info("Starting testcase: " + testcase.description); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let element = doc.getElementById(testcase.elementId); + let value = FormAutofillHeuristics.inferFieldInfo(element); + + Assert.deepEqual(value, testcase.expectedReturnValue); + LabelUtils.clearLabelMap(); + }); +}); + +add_task(async function test_regexp_list() { + info("Verify the fieldName support for select element."); + let SUPPORT_LIST = { + email: null, // email + "tel-extension": null, // tel-extension + phone: null, // tel + organization: null, // organization + "street-address": null, // street-address + address1: null, // address-line1 + address2: null, // address-line2 + address3: null, // address-line3 + city: "address-level2", + region: "address-level1", + "postal-code": null, // postal-code + country: "country", + fullname: null, // name + fname: null, // given-name + mname: null, // additional-name + lname: null, // family-name + cardholder: null, // cc-name + "cc-number": null, // cc-number + addmonth: "cc-exp-month", + addyear: "cc-exp-year", + }; + for (let label of Object.keys(SUPPORT_LIST)) { + let testcase = { + description: `A select element supports ${label} or not`, + document: ``, + elementId: label, + expectedReturnValue: SUPPORT_LIST[label] + ? [SUPPORT_LIST[label], null, null] + : [null, null, null], + }; + info(testcase.description); + info(testcase.document); + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let element = doc.getElementById(testcase.elementId); + let value = FormAutofillHeuristics.inferFieldInfo(element); + + Assert.deepEqual(value, testcase.expectedReturnValue, label); + } + LabelUtils.clearLabelMap(); +}); + +add_task(async function test_autofill_creditCards_autocomplete_off_pref() { + let document = `
+ + +
`; + let expected = [null, null, null]; + info(`Set pref so that credit card autofill respects autocomplete="off"`); + Services.prefs.setBoolPref( + FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF, + false + ); + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + document + ); + let element = doc.getElementById("targetElement"); + let value = FormAutofillHeuristics.inferFieldInfo(element); + + Assert.deepEqual(value, expected); + document = `
+ + +
`; + expected = ["cc-number", null, 1]; + info( + `Set pref so that credit card autofill does not respect autocomplete="off"` + ); + Services.prefs.setBoolPref( + FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF, + true + ); + doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + document + ); + element = doc.getElementById("targetElement"); + value = FormAutofillHeuristics.inferFieldInfo(element); + + Assert.deepEqual(value, expected); + Services.prefs.clearUserPref( + FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF + ); +}); + +add_task(async function test_autofill_addresses_autocomplete_off_pref() { + let document = `
+ +
`; + let expected = [null, null, null]; + info(`Set pref so that address autofill respects autocomplete="off"`); + Services.prefs.setBoolPref( + FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF, + false + ); + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + document + ); + let element = doc.getElementById("given-name"); + let value = FormAutofillHeuristics.inferFieldInfo(element); + + Assert.deepEqual(value, expected); + document = `
+ +
`; + expected = ["given-name", null, null]; + info(`Set pref so that address autofill does not respect autocomplete="off"`); + Services.prefs.setBoolPref( + FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF, + true + ); + doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + document + ); + element = doc.getElementById("given-name"); + value = FormAutofillHeuristics.inferFieldInfo(element); + + Assert.deepEqual(value, expected); + Services.prefs.clearUserPref( + FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF + ); +}); diff --git a/browser/extensions/formautofill/test/unit/test_getRecords.js b/browser/extensions/formautofill/test/unit/test_getRecords.js new file mode 100644 index 0000000000..9a7e5e6ac7 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_getRecords.js @@ -0,0 +1,258 @@ +/* + * Test for make sure getRecords can retrieve right collection from storage. + */ + +"use strict"; + +const { CreditCard } = ChromeUtils.importESModule( + "resource://gre/modules/CreditCard.sys.mjs" +); + +let FormAutofillParent, FormAutofillStatus; +let OSKeyStore; +add_setup(async () => { + ({ FormAutofillParent, FormAutofillStatus } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillParent.sys.mjs" + )); + ({ OSKeyStore } = ChromeUtils.importESModule( + "resource://gre/modules/OSKeyStore.sys.mjs" + )); +}); + +const TEST_ADDRESS_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+16172535702", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +let TEST_CREDIT_CARD_1 = { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": 2017, + "cc-type": "visa", +}; + +let TEST_CREDIT_CARD_2 = { + "cc-name": "John Dai", + "cc-number": "4929001587121045", + "cc-exp-month": 2, + "cc-exp-year": 2017, + "cc-type": "visa", +}; + +add_task(async function test_getRecords() { + FormAutofillStatus.init(); + + await FormAutofillStatus.formAutofillStorage.initialize(); + let fakeResult = { + addresses: [ + { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + }, + ], + creditCards: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": 2017, + }, + ], + }; + + for (let collectionName of ["addresses", "creditCards", "nonExisting"]) { + let collection = FormAutofillStatus.formAutofillStorage[collectionName]; + let expectedResult = fakeResult[collectionName] || []; + + if (collection) { + sinon.stub(collection, "getAll"); + collection.getAll.returns(Promise.resolve(expectedResult)); + } + await FormAutofillParent._getRecords({ collectionName }); + if (collection) { + Assert.equal(collection.getAll.called, true); + collection.getAll.restore(); + } + } +}); + +add_task(async function test_getRecords_addresses() { + await FormAutofillStatus.formAutofillStorage.initialize(); + let mockAddresses = [TEST_ADDRESS_1, TEST_ADDRESS_2]; + let collection = FormAutofillStatus.formAutofillStorage.addresses; + sinon.stub(collection, "getAll"); + collection.getAll.returns(Promise.resolve(mockAddresses)); + + let testCases = [ + { + description: "If the search string could match 1 address", + filter: { + collectionName: "addresses", + info: { fieldName: "street-address" }, + searchString: "Some", + }, + expectedResult: [TEST_ADDRESS_2], + }, + { + description: "If the search string could match multiple addresses", + filter: { + collectionName: "addresses", + info: { fieldName: "country" }, + searchString: "u", + }, + expectedResult: [TEST_ADDRESS_1, TEST_ADDRESS_2], + }, + { + description: "If the search string could not match any address", + filter: { + collectionName: "addresses", + info: { fieldName: "street-address" }, + searchString: "test", + }, + expectedResult: [], + }, + { + description: "If the search string is empty", + filter: { + collectionName: "addresses", + info: { fieldName: "street-address" }, + searchString: "", + }, + expectedResult: [TEST_ADDRESS_1, TEST_ADDRESS_2], + }, + { + description: + "Check if the filtering logic is free from searching special chars", + filter: { + collectionName: "addresses", + info: { fieldName: "street-address" }, + searchString: ".*", + }, + expectedResult: [], + }, + { + description: + "Prevent broken while searching the property that does not exist", + filter: { + collectionName: "addresses", + info: { fieldName: "tel" }, + searchString: "1", + }, + expectedResult: [], + }, + ]; + + for (let testCase of testCases) { + info("Starting testcase: " + testCase.description); + let result = await FormAutofillParent._getRecords(testCase.filter); + Assert.deepEqual(result, testCase.expectedResult); + } +}); + +add_task(async function test_getRecords_creditCards() { + await FormAutofillStatus.formAutofillStorage.initialize(); + let collection = FormAutofillStatus.formAutofillStorage.creditCards; + let encryptedCCRecords = await Promise.all( + [TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2].map(async record => { + let clonedRecord = Object.assign({}, record); + clonedRecord["cc-number"] = CreditCard.getLongMaskedNumber( + record["cc-number"] + ); + clonedRecord["cc-number-encrypted"] = await OSKeyStore.encrypt( + record["cc-number"] + ); + return clonedRecord; + }) + ); + sinon + .stub(collection, "getAll") + .callsFake(() => + Promise.resolve([ + Object.assign({}, encryptedCCRecords[0]), + Object.assign({}, encryptedCCRecords[1]), + ]) + ); + + let testCases = [ + { + description: "If the search string could match multiple creditCards", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-name" }, + searchString: "John", + }, + expectedResult: encryptedCCRecords, + }, + { + description: "If the search string could not match any creditCard", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-name" }, + searchString: "T", + }, + expectedResult: [], + }, + { + description: + "Return all creditCards if focused field is cc number; " + + "if the search string could match multiple creditCards", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-number" }, + searchString: "4", + }, + expectedResult: encryptedCCRecords, + }, + { + description: "If the search string could match 1 creditCard", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-name" }, + searchString: "John Doe", + }, + mpEnabled: true, + expectedResult: encryptedCCRecords.slice(0, 1), + }, + { + description: "Return all creditCards if focused field is cc number", + filter: { + collectionName: "creditCards", + info: { fieldName: "cc-number" }, + searchString: "411", + }, + mpEnabled: true, + expectedResult: encryptedCCRecords, + }, + ]; + + for (let testCase of testCases) { + info("Starting testcase: " + testCase.description); + if (testCase.mpEnabled) { + let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance( + Ci.nsIPK11TokenDB + ); + let token = tokendb.getInternalKeyToken(); + token.reset(); + token.initPassword("password"); + } + let result = await FormAutofillParent._getRecords(testCase.filter); + Assert.deepEqual(result, testCase.expectedResult); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js b/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js new file mode 100644 index 0000000000..40b024f193 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test enabling address autofill in specific locales and regions. + */ + +"use strict"; + +const { FormAutofill } = ChromeUtils.importESModule( + "resource://autofill/FormAutofill.sys.mjs" +); + +add_setup(async () => { + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.experiments.enabled", + false + ); + registerCleanupFunction(function cleanupRegion() { + Services.prefs.clearUserPref( + "extensions.formautofill.addresses.experiments.enabled" + ); + }); +}); + +add_task(async function test_defaultTestEnvironment() { + Assert.equal( + Services.prefs.getCharPref("extensions.formautofill.addresses.supported"), + "on" + ); +}); + +add_task(async function test_default_supported_module_and_autofill_region() { + Services.prefs.setCharPref("browser.search.region", "US"); + registerCleanupFunction(function cleanupRegion() { + Services.prefs.clearUserPref("browser.search.region"); + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.reload(); + + Assert.equal(FormAutofill.isAutofillAddressesAvailable, true); + Assert.equal(FormAutofill.isAutofillAddressesEnabled, true); +}); + +add_task( + async function test_supported_creditCard_region_unsupported_address_region() { + Services.prefs.setCharPref( + "extensions.formautofill.addresses.supported", + "detect" + ); + Services.prefs.setCharPref( + "extensions.formautofill.creditCards.supported", + "detect" + ); + Services.prefs.setCharPref("browser.search.region", "FR"); + Services.prefs.setCharPref( + "extensions.formautofill.addresses.supportedCountries", + "US,CA" + ); + Services.prefs.setCharPref( + "extensions.formautofill.creditCards.supportedCountries", + "US,CA,FR" + ); + registerCleanupFunction(function cleanupPrefs() { + Services.prefs.clearUserPref("browser.search.region"); + Services.prefs.clearUserPref( + "extensions.formautofill.addresses.supportedCountries" + ); + Services.prefs.clearUserPref( + "extensions.formautofill.addresses.supported" + ); + Services.prefs.clearUserPref( + "extensions.formautofill.creditCards.supported" + ); + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.reload(); + Assert.ok( + Services.prefs.getBoolPref("extensions.formautofill.creditCards.enabled") + ); + Assert.equal(FormAutofill.isAutofillAddressesAvailable, false); + Assert.equal(FormAutofill.isAutofillAddressesEnabled, false); + } +); diff --git a/browser/extensions/formautofill/test/unit/test_isCJKName.js b/browser/extensions/formautofill/test/unit/test_isCJKName.js new file mode 100644 index 0000000000..f0e50b60f9 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_isCJKName.js @@ -0,0 +1,80 @@ +/** + * Tests the "isCJKName" function of FormAutofillNameUtils object. + */ + +"use strict"; + +var FormAutofillNameUtils; +add_setup(async () => { + ({ FormAutofillNameUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs" + )); +}); + +// Test cases is initially copied from +// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util_unittest.cc +const TESTCASES = [ + { + // Non-CJK language with only ASCII characters. + fullName: "Homer Jay Simpson", + expectedResult: false, + }, + { + // Non-CJK language with some ASCII characters. + fullName: "Éloïse Paré", + expectedResult: false, + }, + { + // Non-CJK language with no ASCII characters. + fullName: "Σωκράτης", + expectedResult: false, + }, + { + // (Simplified) Chinese name, Unihan. + fullName: "刘翔", + expectedResult: true, + }, + { + // (Simplified) Chinese name, Unihan, with an ASCII space. + fullName: "成 龙", + expectedResult: true, + }, + { + // Korean name, Hangul. + fullName: "송지효", + expectedResult: true, + }, + { + // Korean name, Hangul, with an 'IDEOGRAPHIC SPACE' (U+3000). + fullName: "김 종국", + expectedResult: true, + }, + { + // Japanese name, Unihan. + fullName: "山田貴洋", + expectedResult: true, + }, + { + // Japanese name, Katakana, with a 'KATAKANA MIDDLE DOT' (U+30FB). + fullName: "ビル・ゲイツ", + expectedResult: true, + }, + { + // Japanese name, Katakana, with a 'MIDDLE DOT' (U+00B7) (likely a typo). + fullName: "ビル·ゲイツ", + expectedResult: true, + }, + { + // CJK names don't have a middle name, so a 3-part name is bogus to us. + fullName: "반 기 문", + expectedResult: false, + }, +]; + +add_task(async function test_isCJKName() { + TESTCASES.forEach(testcase => { + info("Starting testcase: " + testcase.fullName); + let result = FormAutofillNameUtils._isCJKName(testcase.fullName); + Assert.equal(result, testcase.expectedResult); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js b/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js new file mode 100644 index 0000000000..5be5101ee6 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js @@ -0,0 +1,84 @@ +/** + * Test enabling the feature in specific locales and regions. + */ + +"use strict"; + +const { FormAutofill } = ChromeUtils.importESModule( + "resource://autofill/FormAutofill.sys.mjs" +); + +add_task(async function test_defaultTestEnvironment() { + Assert.ok(Services.prefs.getBoolPref("dom.forms.autocomplete.formautofill")); +}); + +add_task(async function test_detect_unsupportedRegion() { + Services.prefs.setCharPref( + "extensions.formautofill.creditCards.supported", + "detect" + ); + Services.prefs.setCharPref( + "extensions.formautofill.creditCards.supportedCountries", + "US,CA" + ); + Services.prefs.setCharPref("browser.search.region", "ZZ"); + registerCleanupFunction(function cleanupRegion() { + Services.prefs.clearUserPref("browser.search.region"); + Services.prefs.clearUserPref( + "extensions.formautofill.creditCards.supported" + ); + Services.prefs.clearUserPref("extensions.formautofill.addresses.supported"); + Services.prefs.clearUserPref( + "extensions.formautofill.creditCards.supportedCountries" + ); + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.reload(); + + Assert.equal( + FormAutofill.isAutofillCreditCardsAvailable, + false, + "Credit card autofill should not be available" + ); + Assert.equal( + FormAutofill.isAutofillCreditCardsEnabled, + false, + "Credit card autofill should not be enabled" + ); +}); + +add_task(async function test_detect_supportedRegion() { + Services.prefs.setCharPref( + "extensions.formautofill.creditCards.supported", + "detect" + ); + Services.prefs.setCharPref( + "extensions.formautofill.creditCards.supportedCountries", + "US,CA" + ); + Services.prefs.setCharPref("browser.search.region", "US"); + registerCleanupFunction(function cleanupRegion() { + Services.prefs.clearUserPref("browser.search.region"); + Services.prefs.clearUserPref( + "extensions.formautofill.creditCards.supported" + ); + Services.prefs.clearUserPref( + "extensions.formautofill.creditCards.supportedCountries" + ); + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.reload(); + + Assert.equal( + FormAutofill.isAutofillCreditCardsAvailable, + true, + "Credit card autofill should be available" + ); + Assert.equal( + FormAutofill.isAutofillCreditCardsEnabled, + true, + "Credit card autofill should be enabled" + ); +}); diff --git a/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js b/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js new file mode 100644 index 0000000000..872e9cfcda --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js @@ -0,0 +1,103 @@ +"use strict"; + +var FormAutofillUtils; +add_setup(async () => { + ({ FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" + )); +}); + +const TESTCASES = [ + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: false, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: `
`, + fieldId: "targetElement", + expectedResult: false, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, + { + document: ``, + fieldId: "targetElement", + expectedResult: true, + }, +]; + +TESTCASES.forEach(testcase => { + add_task(async function () { + info("Starting testcase: " + testcase.document); + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + + let field = doc.getElementById(testcase.fieldId); + Assert.equal( + FormAutofillUtils.isCreditCardOrAddressFieldType(field), + testcase.expectedResult + ); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_known_strings.js b/browser/extensions/formautofill/test/unit/test_known_strings.js new file mode 100644 index 0000000000..b3e69dc776 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_known_strings.js @@ -0,0 +1,148 @@ +"use strict"; +/* global FormAutofillHeuristics: true */ + +const KNOWN_NAMES = { + "cc-name": ["cc-name", "card-name", "cardholder-name", "cardholder"], + "cc-number": [ + "cc-number", + "cc-num", + "card-number", + "card-num", + "number", + "cc", + "cc-no", + "card-no", + "credit-card", + "numero-carte", + "carte", + "carte-credit", + "num-carte", + "cb-num", + ], + "cc-exp": [ + "cc-exp", + "card-exp", + "cc-expiration", + "card-expiration", + "cc-ex", + "card-ex", + "card-expire", + "card-expiry", + "validite", + "expiration", + "expiry", + "mm-yy", + "mm-yyyy", + "yy-mm", + "yyyy-mm", + "expiration-date", + "payment-card-expiration", + "payment-cc-date", + ], + "cc-exp-month": [ + "exp-month", + "cc-exp-month", + "cc-month", + "card-month", + "cc-mo", + "card-mo", + "exp-mo", + "card-exp-mo", + "cc-exp-mo", + "card-expiration-month", + "expiration-month", + "cc-mm", + "cc-m", + "card-mm", + "card-m", + "card-exp-mm", + "cc-exp-mm", + "exp-mm", + "exp-m", + "expire-month", + "expire-mo", + "expiry-month", + "expiry-mo", + "card-expire-month", + "card-expire-mo", + "card-expiry-month", + "card-expiry-mo", + "mois-validite", + "mois-expiration", + "m-validite", + "m-expiration", + "expiry-date-field-month", + "expiration-date-month", + "expiration-date-mm", + "exp-mon", + "validity-mo", + "exp-date-mo", + "cb-date-mois", + "date-m", + ], + "cc-exp-year": [ + "exp-year", + "cc-exp-year", + "cc-year", + "card-year", + "cc-yr", + "card-yr", + "exp-yr", + "card-exp-yr", + "cc-exp-yr", + "card-expiration-year", + "expiration-year", + "cc-yy", + "cc-y", + "card-yy", + "card-y", + "card-exp-yy", + "cc-exp-yy", + "exp-yy", + "exp-y", + "cc-yyyy", + "card-yyyy", + "card-exp-yyyy", + "cc-exp-yyyy", + "expire-year", + "expire-yr", + "expiry-year", + "expiry-yr", + "card-expire-year", + "card-expire-yr", + "card-expiry-year", + "card-expiry-yr", + "an-validite", + "an-expiration", + "annee-validite", + "annee-expiration", + "expiry-date-field-year", + "expiration-date-year", + "cb-date-ann", + "expiration-date-yy", + "expiration-date-yyyy", + "validity-year", + "exp-date-year", + "date-y", + ], +}; + +add_setup(async () => { + ({ FormAutofillHeuristics } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs" + )); +}); + +for (let field in KNOWN_NAMES) { + KNOWN_NAMES[field].forEach(name => { + add_task(async () => { + ok( + FormAutofillHeuristics.testRegex( + FormAutofillHeuristics.RULES[field], + name + ), + `RegExp for ${field} matches string '${name}'` + ); + }); + }); +} diff --git a/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js b/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js new file mode 100644 index 0000000000..72960fcaf2 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js @@ -0,0 +1,201 @@ +"use strict"; + +const TESTCASES = [ + { + description: "Form containing 8 fields with autocomplete attribute.", + document: `
+ + + + + + + + + + +
`, + targetElementId: "given-name", + expectedResult: [ + "given-name", + "additional-name", + "family-name", + "street-addr", + "city", + "country", + "email", + "tel", + ], + }, + { + description: "Form containing only 2 fields with autocomplete attribute.", + document: `
+ + + + +
`, + targetElementId: "street-addr", + expectedResult: [], + }, + { + description: "Fields without form element.", + document: ` + + + + + + `, + targetElementId: "street-addr", + expectedResult: ["street-addr", "city", "country", "email", "tel"], + }, + { + description: "Form containing credit card autocomplete attributes.", + document: `
+ + + + +
`, + targetElementId: "cc-number", + expectedResult: ["cc-number", "cc-name", "cc-exp-month", "cc-exp-year"], + }, + { + description: + "Form containing multiple cc-number fields without autocomplete attributes.", + document: `
+ + + + + + + +
`, + targetElementId: "cc-number1", + expectedResult: [ + "cc-number1", + "cc-number2", + "cc-number3", + "cc-number4", + "cc-name", + "cc-exp-month", + "cc-exp-year", + ], + }, + { + description: + "Invalid form containing three consecutive cc-number fields without autocomplete attributes.", + document: `
+ + + +
`, + targetElementId: "cc-number1", + expectedResult: [], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "1.0", + ], + ], + }, + { + description: + "Invalid form containing five consecutive cc-number fields without autocomplete attributes.", + document: `
+ + + + + +
`, + targetElementId: "cc-number1", + expectedResult: [], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "1.0", + ], + ], + }, + { + description: + "Valid form containing three consecutive cc-number fields without autocomplete attributes.", + document: `
+ + + + + + +
`, + targetElementId: "cc-number1", + expectedResult: ["cc-number3", "cc-name", "cc-exp-month", "cc-exp-year"], + prefs: [ + [ + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + "1.0", + ], + ], + }, + { + description: + "Valid form containing five consecutive cc-number fields without autocomplete attributes.", + document: `
+ + + + + + + + +
`, + targetElementId: "cc-number1", + expectedResult: ["cc-number5", "cc-name", "cc-exp-month", "cc-exp-year"], + }, +]; + +let markedFieldId = []; + +var FormAutofillContent; +add_setup(async () => { + ({ FormAutofillContent } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillContent.sys.mjs" + )); + + FormAutofillContent._markAsAutofillField = function (field) { + markedFieldId.push(field.id); + }; +}); + +TESTCASES.forEach(testcase => { + add_task(async function () { + info("Starting testcase: " + testcase.description); + + if (testcase.prefs) { + testcase.prefs.forEach(pref => SetPref(pref[0], pref[1])); + } + + markedFieldId = []; + + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let element = doc.getElementById(testcase.targetElementId); + FormAutofillContent.identifyAutofillFields(element); + + Assert.deepEqual( + markedFieldId, + testcase.expectedResult, + "Check the fields were marked correctly." + ); + + if (testcase.prefs) { + testcase.prefs.forEach(pref => Services.prefs.clearUserPref(pref[0])); + } + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_migrateRecords.js b/browser/extensions/formautofill/test/unit/test_migrateRecords.js new file mode 100644 index 0000000000..33c2d53876 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_migrateRecords.js @@ -0,0 +1,475 @@ +/** + * Tests the migration algorithm in profileStorage. + */ + +"use strict"; + +let FormAutofillStorage; +add_setup(async () => { + ({ FormAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + )); +}); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const { ADDRESS_SCHEMA_VERSION } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorageBase.sys.mjs" +); +const { CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorageBase.sys.mjs" +); + +const ADDRESS_TESTCASES = [ + { + description: + "The record version is equal to the current version. The migration shouldn't be invoked.", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + // The cached address-line1 field doesn't align "street-address" but it + // won't be recomputed because the migration isn't invoked. + "address-line1": "Some Address", + "street-address": "32 Vassar Street", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "address-line1": "Some Address", + "street-address": "32 Vassar Street", + }, + }, + { + description: + "The record version is greater than the current version. The migration shouldn't be invoked.", + record: { + guid: "test-guid", + version: 99, + "address-line1": "Some Address", + "street-address": "32 Vassar Street", + }, + expectedResult: { + guid: "test-guid", + version: 99, + "address-line1": "Some Address", + "street-address": "32 Vassar Street", + }, + }, + { + description: + "The record version is less than the current version. The migration should be invoked.", + record: { + guid: "test-guid", + version: 0, + "address-line1": "Some Address", + "street-address": "32 Vassar Street", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "address-line1": "32 Vassar Street", + "street-address": "32 Vassar Street", + }, + }, + { + description: + "The record version is omitted. The migration should be invoked.", + record: { + guid: "test-guid", + "address-line1": "Some Address", + "street-address": "32 Vassar Street", + "unknown-1": "an unknown field from another client", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "address-line1": "32 Vassar Street", + "street-address": "32 Vassar Street", + "unknown-1": "an unknown field from another client", + }, + }, + { + description: + "The record version is an invalid value. The migration should be invoked.", + record: { + guid: "test-guid", + version: "ABCDE", + "address-line1": "Some Address", + "street-address": "32 Vassar Street", + "unknown-1": "an unknown field from another client", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "address-line1": "32 Vassar Street", + "street-address": "32 Vassar Street", + "unknown-1": "an unknown field from another client", + }, + }, + { + description: + "The omitted computed fields should be always recomputed even the record version is up-to-date.", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "street-address": "32 Vassar Street", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "address-line1": "32 Vassar Street", + "street-address": "32 Vassar Street", + }, + }, + { + description: "The migration shouldn't be invoked on tombstones.", + record: { + guid: "test-guid", + timeLastModified: 12345, + deleted: true, + }, + expectedResult: { + guid: "test-guid", + timeLastModified: 12345, + deleted: true, + + // Make sure no new fields are appended. + version: undefined, + name: undefined, + }, + }, + + // Bug 1836438 - Migrate "*-name" to "name" + { + description: + "Migrate address - `given-name`, `additional-name`, and `family-name` should be migrated to `name`", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + name: "Timothy John Berners-Lee", + }, + }, + { + description: "Migrate address - `given-name` should be migrated to `name`", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + name: "Timothy", + }, + }, + { + description: + "Migrate address - `additional-name` should be migrated to `name`", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "additional-name": "John", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + name: "John", + }, + }, + { + description: "Migrate address - `family-name` should be migrated to `name`", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "family-name": "Berners-Lee", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + name: "Berners-Lee", + }, + }, + { + description: + "Migrate address - `name` should still be empty when there is no *-name fields in the record", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + }, + }, + { + description: + "Migrate address - do not run migration as long as the name field exists", + record: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + // The cached field doesn't align "name" but it + // won't be recomputed because the migration isn't invoked. + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + name: "Jane", + }, + expectedResult: { + guid: "test-guid", + version: ADDRESS_SCHEMA_VERSION, + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + name: "Jane", + }, + }, +]; + +const CREDIT_CARD_TESTCASES = [ + { + description: + "The record version is equal to the current version. The migration shouldn't be invoked.", + record: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "John", // The cached "cc-given-name" field doesn't align + // "cc-name" but it won't be recomputed because + // the migration isn't invoked. + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "John", + }, + }, + { + description: + "The record version is greater than the current version. The migration shouldn't be invoked.", + record: { + guid: "test-guid", + version: 99, + "cc-name": "Timothy", + "cc-given-name": "John", + }, + expectedResult: { + guid: "test-guid", + version: 99, + "cc-name": "Timothy", + "cc-given-name": "John", + }, + }, + { + description: + "The record version is less than the current version. The migration should be invoked.", + record: { + guid: "test-guid", + version: 0, + "cc-name": "Timothy", + "cc-given-name": "John", + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "Timothy", + }, + }, + { + description: + "The record version is omitted. The migration should be invoked.", + record: { + guid: "test-guid", + "cc-name": "Timothy", + "cc-given-name": "John", + "unknown-1": "an unknown field from another client", + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "Timothy", + "unknown-1": "an unknown field from another client", + }, + }, + { + description: + "The record version is an invalid value. The migration should be invoked.", + record: { + guid: "test-guid", + version: "ABCDE", + "cc-name": "Timothy", + "cc-given-name": "John", + "unknown-1": "an unknown field from another client", + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "Timothy", + "unknown-1": "an unknown field from another client", + }, + }, + { + description: + "The omitted computed fields should be always recomputed even the record version is up-to-date.", + record: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + }, + expectedResult: { + guid: "test-guid", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Timothy", + "cc-given-name": "Timothy", + }, + }, + { + description: "The migration shouldn't be invoked on tombstones.", + record: { + guid: "test-guid", + timeLastModified: 12345, + deleted: true, + }, + expectedResult: { + guid: "test-guid", + timeLastModified: 12345, + deleted: true, + + // Make sure no new fields are appended. + version: undefined, + "cc-given-name": undefined, + }, + }, +]; + +let do_check_record_matches = (expectedRecord, record) => { + for (let key in expectedRecord) { + Assert.equal(expectedRecord[key], record[key]); + } +}; + +add_task(async function test_migrateAddressRecords() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of ADDRESS_TESTCASES) { + info(testcase.description); + profileStorage._store.data.addresses = [testcase.record]; + await profileStorage.addresses._migrateRecord(testcase.record, 0); + do_check_record_matches( + testcase.expectedResult, + profileStorage.addresses._data[0] + ); + } +}); + +add_task(async function test_migrateCreditCardRecords() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of CREDIT_CARD_TESTCASES) { + info(testcase.description); + profileStorage._store.data.creditCards = [testcase.record]; + await profileStorage.creditCards._migrateRecord(testcase.record, 0); + do_check_record_matches( + testcase.expectedResult, + profileStorage.creditCards._data[0] + ); + } +}); + +add_task(async function test_migrateEncryptedCreditCardNumber() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + info("v1 and v2 schema cards should be abandoned."); + + let v1record = { + guid: "test-guid1", + version: 1, + "cc-name": "Timothy", + "cc-number-encrypted": "aaaa", + }; + + let v2record = { + guid: "test-guid2", + version: 2, + "cc-name": "Bob", + "cc-number-encrypted": "bbbb", + }; + + profileStorage._store.data.creditCards = [v1record, v2record]; + await profileStorage.creditCards._migrateRecord(v1record, 0); + await profileStorage.creditCards._migrateRecord(v2record, 1); + v1record = profileStorage.creditCards._data[0]; + v2record = profileStorage.creditCards._data[1]; + + Assert.ok(v1record.deleted); + Assert.ok(v2record.deleted); +}); + +add_task(async function test_migrateDeprecatedCreditCardV4() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + let records = [ + { + guid: "test-guid1", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "Alice", + _sync: { + changeCounter: 0, + lastSyncedFields: {}, + }, + }, + { + guid: "test-guid2", + version: 4, + "cc-name": "Timothy", + _sync: { + changeCounter: 0, + lastSyncedFields: {}, + }, + }, + { + guid: "test-guid3", + version: 4, + "cc-name": "Bob", + }, + ]; + + profileStorage._store.data.creditCards = records; + for (let idx = 0; idx < records.length; idx++) { + await profileStorage.creditCards._migrateRecord(records[idx], idx); + } + + profileStorage.creditCards.pullSyncChanges(); + + // Record that has already synced before, do not sync again + equal(getSyncChangeCounter(profileStorage.creditCards, records[0].guid), 0); + + // alaways force sync v4 record + equal(records[1].version, CREDIT_CARD_SCHEMA_VERSION); + equal(getSyncChangeCounter(profileStorage.creditCards, records[1].guid), 1); + + equal(records[2].version, CREDIT_CARD_SCHEMA_VERSION); + equal(getSyncChangeCounter(profileStorage.creditCards, records[2].guid), 1); +}); diff --git a/browser/extensions/formautofill/test/unit/test_nameUtils.js b/browser/extensions/formautofill/test/unit/test_nameUtils.js new file mode 100644 index 0000000000..1b8bdb6d49 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_nameUtils.js @@ -0,0 +1,289 @@ +/** + * Tests FormAutofillNameUtils object. + */ + +"use strict"; + +var FormAutofillNameUtils; +add_task(async function () { + ({ FormAutofillNameUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs" + )); +}); + +// Test cases initially copied from +// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util_unittest.cc +const TESTCASES = [ + { + description: "Full name including given, middle and family names", + fullName: "Homer Jay Simpson", + nameParts: { + given: "Homer", + middle: "Jay", + family: "Simpson", + }, + }, + { + description: "No middle name", + fullName: "Moe Szyslak", + nameParts: { + given: "Moe", + middle: "", + family: "Szyslak", + }, + }, + { + description: "Common name prefixes removed", + fullName: "Reverend Timothy Lovejoy", + nameParts: { + given: "Timothy", + middle: "", + family: "Lovejoy", + }, + expectedFullName: "Timothy Lovejoy", + }, + { + description: "Common name suffixes removed", + fullName: "John Frink Phd", + nameParts: { + given: "John", + middle: "", + family: "Frink", + }, + expectedFullName: "John Frink", + }, + { + description: "Exception to the name suffix removal", + fullName: "John Ma", + nameParts: { + given: "John", + middle: "", + family: "Ma", + }, + }, + { + description: "Common family name prefixes not considered a middle name", + fullName: "Milhouse Van Houten", + nameParts: { + given: "Milhouse", + middle: "", + family: "Van Houten", + }, + }, + + // CJK names have reverse order (surname goes first, given name goes second). + { + description: "Chinese name, Unihan", + fullName: "孫 德明", + nameParts: { + given: "德明", + middle: "", + family: "孫", + }, + expectedFullName: "孫德明", + }, + { + description: 'Chinese name, Unihan, "IDEOGRAPHIC SPACE"', + fullName: "孫 德明", + nameParts: { + given: "德明", + middle: "", + family: "孫", + }, + expectedFullName: "孫德明", + }, + { + description: "Korean name, Hangul", + fullName: "홍 길동", + nameParts: { + given: "길동", + middle: "", + family: "홍", + }, + expectedFullName: "홍길동", + }, + { + description: "Japanese name, Unihan", + fullName: "山田 貴洋", + nameParts: { + given: "貴洋", + middle: "", + family: "山田", + }, + expectedFullName: "山田貴洋", + }, + + // In Japanese, foreign names use 'KATAKANA MIDDLE DOT' (U+30FB) as a + // separator. There is no consensus for the ordering. For now, we use the same + // ordering as regular Japanese names ("last・first"). + { + description: "Foreign name in Japanese, Katakana", + fullName: "ゲイツ・ビル", + nameParts: { + given: "ビル", + middle: "", + family: "ゲイツ", + }, + expectedFullName: "ゲイツビル", + }, + + // 'KATAKANA MIDDLE DOT' is occasionally typoed as 'MIDDLE DOT' (U+00B7). + { + description: "Foreign name in Japanese, Katakana", + fullName: "ゲイツ·ビル", + nameParts: { + given: "ビル", + middle: "", + family: "ゲイツ", + }, + expectedFullName: "ゲイツビル", + }, + + // CJK names don't usually have a space in the middle, but most of the time, + // the surname is only one character (in Chinese & Korean). + { + description: "Korean name, Hangul", + fullName: "최성훈", + nameParts: { + given: "성훈", + middle: "", + family: "최", + }, + }, + { + description: "(Simplified) Chinese name, Unihan", + fullName: "刘翔", + nameParts: { + given: "翔", + middle: "", + family: "刘", + }, + }, + { + description: "(Traditional) Chinese name, Unihan", + fullName: "劉翔", + nameParts: { + given: "翔", + middle: "", + family: "劉", + }, + }, + + // There are a few exceptions. Occasionally, the surname has two characters. + { + description: "Korean name, Hangul", + fullName: "남궁도", + nameParts: { + given: "도", + middle: "", + family: "남궁", + }, + }, + { + description: "Korean name, Hangul", + fullName: "황보혜정", + nameParts: { + given: "혜정", + middle: "", + family: "황보", + }, + }, + { + description: "(Traditional) Chinese name, Unihan", + fullName: "歐陽靖", + nameParts: { + given: "靖", + middle: "", + family: "歐陽", + }, + }, + + // In Korean, some 2-character surnames are rare/ambiguous, like "강전": "강" + // is a common surname, and "전" can be part of a given name. In those cases, + // we assume it's 1/2 for 3-character names, or 2/2 for 4-character names. + { + description: "Korean name, Hangul", + fullName: "강전희", + nameParts: { + given: "전희", + middle: "", + family: "강", + }, + }, + { + description: "Korean name, Hangul", + fullName: "황목치승", + nameParts: { + given: "치승", + middle: "", + family: "황목", + }, + }, + + // It occasionally happens that a full name is 2 characters, 1/1. + { + description: "Korean name, Hangul", + fullName: "이도", + nameParts: { + given: "도", + middle: "", + family: "이", + }, + }, + { + description: "Korean name, Hangul", + fullName: "孫文", + nameParts: { + given: "文", + middle: "", + family: "孫", + }, + }, + + // These are no CJK names for us, they're just bogus. + { + description: "Bogus", + fullName: "Homer シンプソン", + nameParts: { + given: "Homer", + middle: "", + family: "シンプソン", + }, + }, + { + description: "Bogus", + fullName: "ホーマー Simpson", + nameParts: { + given: "ホーマー", + middle: "", + family: "Simpson", + }, + }, + { + description: "CJK has a middle-name, too unusual", + fullName: "반 기 문", + nameParts: { + given: "반", + middle: "기", + family: "문", + }, + }, +]; + +add_task(async function test_splitName() { + TESTCASES.forEach(testcase => { + if (testcase.fullName) { + info("Starting testcase: " + testcase.description); + let nameParts = FormAutofillNameUtils.splitName(testcase.fullName); + Assert.deepEqual(nameParts, testcase.nameParts); + } + }); +}); + +add_task(async function test_joinName() { + TESTCASES.forEach(testcase => { + info("Starting testcase: " + testcase.description); + let name = FormAutofillNameUtils.joinNameParts(testcase.nameParts); + Assert.equal(name, testcase.expectedFullName || testcase.fullName); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js new file mode 100644 index 0000000000..484112cc86 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js @@ -0,0 +1,712 @@ +"use strict"; + +var FormAutofillContent; +add_setup(async () => { + ({ FormAutofillContent } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillContent.sys.mjs" + )); +}); + +const DEFAULT_TEST_DOC = `
+ + + + + + + + + + + + +
`; +const TARGET_ELEMENT_ID = "street-addr"; + +const TESTCASES = [ + { + description: + "Should not trigger address saving if the number of fields is less than 3", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "street-addr": "331 E. Evelyn Avenue", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: false, + }, + }, + { + description: "Should not trigger credit card saving if number is empty", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "cc-name": "John Doe", + "cc-exp-month": 12, + "cc-exp-year": 2000, + }, + expectedResult: { + formSubmission: false, + }, + }, + { + description: + "Should not trigger credit card saving if there is more than one cc-number field but less than four fields", + document: `
+ + + + + + + + +
+ `, + targetElementId: "cc-name", + formValue: { + "cc-name": "John Doe", + "cc-number1": "3714", + "cc-number2": "4963", + "cc-number3": "5398", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "amex", + }, + expectedResult: { + formSubmission: false, + }, + }, + { + description: "Trigger address saving", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + email: "", + tel: "1-650-903-0800", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Trigger credit card saving", + document: DEFAULT_TEST_DOC, + targetElementId: "cc-type", + formValue: { + "cc-name": "John Doe", + "cc-number": "5105105105105100", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "amex", + }, + expectedResult: { + formSubmission: true, + records: { + address: [], + creditCard: [ + { + guid: null, + record: { + "cc-name": "John Doe", + "cc-number": "5105105105105100", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "amex", + }, + untouchedFields: [], + }, + ], + }, + }, + }, + { + description: "Trigger credit card saving using multiple cc-number fields", + document: `
+ + + + + + + + + +
`, + targetElementId: "cc-type", + formValue: { + "cc-name": "John Doe", + "cc-number1": "3714", + "cc-number2": "4963", + "cc-number3": "5398", + "cc-number4": "431", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "amex", + }, + expectedResult: { + formSubmission: true, + records: { + address: [], + creditCard: [ + { + guid: null, + record: { + "cc-name": "John Doe", + "cc-number": "371449635398431", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "amex", + }, + untouchedFields: [], + }, + ], + }, + }, + }, + { + description: "Trigger address and credit card saving", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "1-650-903-0800", + "cc-name": "John Doe", + "cc-number": "5105105105105100", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "visa", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + email: "", + tel: "1-650-903-0800", + }, + untouchedFields: [], + }, + ], + creditCard: [ + { + guid: null, + record: { + "cc-name": "John Doe", + "cc-number": "5105105105105100", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "cc-type": "visa", + }, + untouchedFields: [], + }, + ], + }, + }, + }, + { + description: "Profile saved with trimmed string", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "street-addr": "331 E. Evelyn Avenue ", + country: "US", + tel: " 1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + email: "", + tel: "1-650-903-0800", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Eliminate the field that is empty after trimmed", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "street-addr": "331 E. Evelyn Avenue", + country: "US", + email: " ", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + email: "", + tel: "1-650-903-0800", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Save state with regular select option", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "address-level1": "CA", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "CA", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Save state with lowercase value", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "address-level1": "ca", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "CA", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Save state with a country code prefixed to the label", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "address-level1": "AR", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "AR", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Save state with a country code prefixed to the value", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "address-level1": "US-CA", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "CA", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: + "Save state with a country code prefixed to the value and label", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "address-level1": "US-AZ", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "AZ", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: + "Should save select label instead when failed to abbreviate the value", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "address-level1": "Ariz", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "address-level1": "Arizonac", + "address-level2": "", + "street-address": "331 E. Evelyn Avenue", + country: "US", + email: "", + tel: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Shouldn't save select with multiple selections", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "address-level1": ["AL", "AK", "AP"], + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + tel: "1-650-903-0800", + email: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, + { + description: "Shouldn't save select with empty value", + document: DEFAULT_TEST_DOC, + targetElementId: TARGET_ELEMENT_ID, + formValue: { + "address-level1": "", + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "1-650-903-0800", + }, + expectedResult: { + formSubmission: true, + records: { + address: [ + { + guid: null, + record: { + "street-address": "331 E. Evelyn Avenue", + "address-level1": "", + "address-level2": "", + country: "US", + tel: "1-650-903-0800", + email: "", + }, + untouchedFields: [], + }, + ], + creditCard: [], + }, + }, + }, +]; + +add_task(async function handle_invalid_form() { + info("Starting testcase: Test an invalid form element"); + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test", + DEFAULT_TEST_DOC + ); + let fakeForm = doc.createElement("form"); + sinon.spy(FormAutofillContent, "_onFormSubmit"); + + FormAutofillContent.formSubmitted(fakeForm, undefined, null); + Assert.equal(FormAutofillContent._onFormSubmit.called, false); + FormAutofillContent._onFormSubmit.restore(); +}); + +add_task(async function autofill_disabled() { + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test", + DEFAULT_TEST_DOC + ); + let form = doc.getElementById("form1"); + form.reset(); + + let testcase = { + "street-addr": "331 E. Evelyn Avenue", + country: "US", + tel: "+16509030800", + "cc-number": "1111222233334444", + }; + for (let key in testcase) { + let input = doc.getElementById(key); + input.value = testcase[key]; + } + + let element = doc.getElementById(TARGET_ELEMENT_ID); + FormAutofillContent.identifyAutofillFields(element); + + sinon.stub(FormAutofillContent, "_onFormSubmit"); + + // "_onFormSubmit" shouldn't be called if both "addresses" and "creditCards" + // are disabled. + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + FormAutofillContent.formSubmitted(form, undefined, null); + Assert.equal(FormAutofillContent._onFormSubmit.called, false); + FormAutofillContent._onFormSubmit.resetHistory(); + + // "_onFormSubmit" should be called as usual. + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + + FormAutofillContent.formSubmitted(form, undefined, null); + Assert.equal(FormAutofillContent._onFormSubmit.called, true); + Assert.notDeepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []); + Assert.notDeepEqual( + FormAutofillContent._onFormSubmit.args[0][0].creditCard, + [] + ); + FormAutofillContent._onFormSubmit.resetHistory(); + + // "address" should be empty if "addresses" pref is disabled. + Services.prefs.setBoolPref( + "extensions.formautofill.addresses.enabled", + false + ); + FormAutofillContent.formSubmitted(form, undefined, null); + Assert.equal(FormAutofillContent._onFormSubmit.called, true); + Assert.deepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []); + Assert.notDeepEqual( + FormAutofillContent._onFormSubmit.args[0][0].creditCard, + [] + ); + FormAutofillContent._onFormSubmit.resetHistory(); + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + + // "creditCard" should be empty if "creditCards" pref is disabled. + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + false + ); + FormAutofillContent.formSubmitted(form, undefined, null); + Assert.deepEqual(FormAutofillContent._onFormSubmit.called, true); + Assert.notDeepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []); + Assert.deepEqual(FormAutofillContent._onFormSubmit.args[0][0].creditCard, []); + FormAutofillContent._onFormSubmit.resetHistory(); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + + FormAutofillContent._onFormSubmit.restore(); +}); + +TESTCASES.forEach(testcase => { + add_task(async function check_records_saving_is_called_correctly() { + info("Starting testcase: " + testcase.description); + + Services.prefs.setBoolPref( + "extensions.formautofill.creditCards.enabled", + true + ); + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let form = doc.getElementById("form1"); + form.reset(); + for (let key in testcase.formValue) { + let input = doc.getElementById(key); + let value = testcase.formValue[key]; + if (ChromeUtils.getClassName(input) === "HTMLSelectElement" && value) { + input.multiple = Array.isArray(value); + [...input.options].forEach(option => { + option.selected = value.includes(option.value); + }); + } else { + input.value = testcase.formValue[key]; + } + } + sinon.stub(FormAutofillContent, "_onFormSubmit"); + + let element = doc.getElementById(testcase.targetElementId); + FormAutofillContent.identifyAutofillFields(element); + FormAutofillContent.formSubmitted(form, undefined, null); + + Assert.equal( + FormAutofillContent._onFormSubmit.called, + testcase.expectedResult.formSubmission, + "Check expected onFormSubmit.called" + ); + if (FormAutofillContent._onFormSubmit.called) { + for (let ccRecord of FormAutofillContent._onFormSubmit.args[0][0] + .creditCard) { + delete ccRecord.flowId; + } + for (let addrRecord of FormAutofillContent._onFormSubmit.args[0][0] + .address) { + delete addrRecord.flowId; + } + + Assert.deepEqual( + FormAutofillContent._onFormSubmit.args[0][0], + testcase.expectedResult.records + ); + } + FormAutofillContent._onFormSubmit.restore(); + Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled"); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js b/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js new file mode 100644 index 0000000000..df6c3e3069 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js @@ -0,0 +1,66 @@ +"use strict"; + +var FormAutofillUtils; +add_setup(async () => { + ({ FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" + )); +}); + +add_task(async function test_parseAddressFormat() { + const TEST_CASES = [ + { + fmt: "%N%n%O%n%A%n%C, %S %Z", // US + parsed: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "address-level2" }, + { fieldId: "address-level1" }, + { fieldId: "postal-code" }, + ], + }, + { + fmt: "%N%n%O%n%A%n%C %S %Z", // CA + parsed: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "address-level2" }, + { fieldId: "address-level1" }, + { fieldId: "postal-code" }, + ], + }, + { + fmt: "%N%n%O%n%A%n%Z %C", // DE + parsed: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "postal-code" }, + { fieldId: "address-level2" }, + ], + }, + { + fmt: "%N%n%O%n%A%n%D%n%C%n%S %Z", // IE + parsed: [ + { fieldId: "name", newLine: true }, + { fieldId: "organization", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "address-level3", newLine: true }, + { fieldId: "address-level2", newLine: true }, + { fieldId: "address-level1" }, + { fieldId: "postal-code" }, + ], + }, + ]; + + Assert.throws( + () => FormAutofillUtils.parseAddressFormat(), + /fmt string is missing./, + "Should throw if fmt is empty" + ); + for (let tc of TEST_CASES) { + Assert.deepEqual(FormAutofillUtils.parseAddressFormat(tc.fmt), tc.parsed); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js b/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js new file mode 100644 index 0000000000..dca30fa0e0 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js @@ -0,0 +1,74 @@ +"use strict"; + +const { AddressParser } = ChromeUtils.importESModule( + "resource://gre/modules/shared/AddressParser.sys.mjs" +); + +// To add a new test entry to a "TESTCASES" variable, +// you would need to create a new array containing two elements. +// - The first element is a string representing a street address to be parsed. +// - The second element is an array containing the expected output after parsing the address, +// which should follow the format of +// [street number, street name, apartment number (if applicable), floor number (if applicable)]. +// +// Note. If we expect the passed street address cannot be parsed, set the second element to null. +const TESTCASES = [ + ["123 Main St. Apt 4, Floor 2", [123, "Main St.", "4", 2]], + ["32 Vassar Street MIT Room 4", [32, "Vassar Street MIT", "4", null]], + ["32 Vassar Street MIT", [32, "Vassar Street MIT", null, null]], + [ + "32 Vassar Street MIT Room 32-G524", + [32, "Vassar Street MIT", "32-G524", null], + ], + ["163 W Hastings\nSuite 209", [163, "W Hastings", "209", null]], + ["1234 Elm St. Apt 4, Floor 2", [1234, "Elm St.", "4", 2]], + ["456 Oak Drive, Unit 2A", [456, "Oak Drive", "2A", null]], + ["789 Maple Ave, Suite 300", [789, "Maple Ave", "300", null]], + ["321 Willow Lane, #5", [321, "Willow Lane", "5", null]], + ["654 Pine Circle, Apt B", [654, "Pine Circle", "B", null]], + ["987 Birch Court, 3rd Floor", [987, "Birch Court", null, 3]], + ["234 Cedar Way, Unit 6-2", [234, "Cedar Way", "6-2", null]], + ["345 Cherry St, Ste 12", [345, "Cherry St", "12", null]], + ["234 Palm St, Bldg 1, Apt 12", null], +]; + +add_task(async function test_parseStreetAddress() { + for (const TEST of TESTCASES) { + let [address, expected] = TEST; + const result = AddressParser.parseStreetAddress(address); + if (!expected) { + Assert.equal(result, null, "Expect failure to parse this street address"); + continue; + } + + const options = { + trim: true, + ignore_case: true, + }; + + const expectedSN = AddressParser.normalizeString(expected[0], options); + Assert.equal( + result.street_number, + expectedSN, + `expect street number to be ${expectedSN}, but got ${result.street_number}` + ); + const expectedSNA = AddressParser.normalizeString(expected[1], options); + Assert.equal( + result.street_name, + expectedSNA, + `expect street name to be ${expectedSNA}, but got ${result.street_name}` + ); + const expectedAN = AddressParser.normalizeString(expected[2], options); + Assert.equal( + result.apartment_number, + expectedAN, + `expect apartment number to be ${expectedAN}, but got ${result.apartment_number}` + ); + const expectedFN = AddressParser.normalizeString(expected[3], options); + Assert.equal( + result.floor_number, + expectedFN, + `expect floor number to be ${expectedFN}, but got ${result.floor_number}` + ); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_phoneNumber.js b/browser/extensions/formautofill/test/unit/test_phoneNumber.js new file mode 100644 index 0000000000..1c1d67e166 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_phoneNumber.js @@ -0,0 +1,399 @@ +/** + * Tests PhoneNumber.jsm and PhoneNumberNormalizer.jsm. + */ + +"use strict"; + +var PhoneNumber, PhoneNumberNormalizer; +add_setup(async () => { + ({ PhoneNumber } = ChromeUtils.importESModule( + "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs" + )); + ({ PhoneNumberNormalizer } = ChromeUtils.importESModule( + "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs" + )); +}); + +function IsPlain(dial, expected) { + let result = PhoneNumber.IsPlain(dial); + Assert.equal(result, expected); +} + +function Normalize(dial, expected) { + let result = PhoneNumberNormalizer.Normalize(dial); + Assert.equal(result, expected); +} + +function CantParse(dial, currentRegion) { + let result = PhoneNumber.Parse(dial, currentRegion); + Assert.equal(null, result); +} + +function Parse(dial, currentRegion) { + let result = PhoneNumber.Parse(dial, currentRegion); + Assert.notEqual(result, null); + return result; +} + +function Test(dial, currentRegion, nationalNumber, region) { + let result = Parse(dial, currentRegion); + Assert.equal(result.nationalNumber, nationalNumber); + Assert.equal(result.region, region); + return result; +} + +function TestProperties(dial, currentRegion) { + let result = Parse(dial, currentRegion); + Assert.ok(result.internationalFormat); + Assert.ok(result.internationalNumber); + Assert.ok(result.nationalFormat); + Assert.ok(result.nationalNumber); + Assert.ok(result.countryName); + Assert.ok(result.countryCode); +} + +function Format( + dial, + currentRegion, + nationalNumber, + region, + nationalFormat, + internationalFormat +) { + let result = Test(dial, currentRegion, nationalNumber, region); + Assert.equal(result.nationalFormat, nationalFormat); + Assert.equal(result.internationalFormat, internationalFormat); + return result; +} + +function AllEqual(list, currentRegion) { + let parsedList = list.map(item => Parse(item, currentRegion)); + let firstItem = parsedList.shift(); + for (let item of parsedList) { + Assert.deepEqual(item, firstItem); + } +} + +add_task(async function test_phoneNumber() { + // Test whether could a string be a phone number. + IsPlain(null, false); + IsPlain("", false); + IsPlain("1", true); + IsPlain("*2", true); // Real number used in Venezuela + IsPlain("*8", true); // Real number used in Venezuela + IsPlain("12", true); + IsPlain("123", true); + IsPlain("1a2", false); + IsPlain("12a", false); + IsPlain("1234", true); + IsPlain("123a", false); + IsPlain("+", true); + IsPlain("+1", true); + IsPlain("+12", true); + IsPlain("+123", true); + IsPlain("()123", false); + IsPlain("(1)23", false); + IsPlain("(12)3", false); + IsPlain("(123)", false); + IsPlain("(123)4", false); + IsPlain("(123)4", false); + IsPlain("123;ext=", false); + IsPlain("123;ext=1", false); + IsPlain("123;ext=1234567", false); + IsPlain("123;ext=12345678", false); + IsPlain("123 ext:1", false); + IsPlain("123 ext:1#", false); + IsPlain("123-1#", false); + IsPlain("123 1#", false); + IsPlain("123 12345#", false); + IsPlain("123 +123456#", false); + + // Getting international number back from intl number. + TestProperties("+19497262896"); + + // Test parsing national numbers. + Parse("033316005", "NZ"); + Parse("03-331 6005", "NZ"); + Parse("03 331 6005", "NZ"); + // Testing international prefixes. + // Should strip country code. + Parse("0064 3 331 6005", "NZ"); + + // Test CA before US because CA has to import meta-information for US. + Parse("4031234567", "CA"); + Parse("(416) 585-4319", "CA"); + Parse("647-967-4357", "CA"); + Parse("416-716-8768", "CA"); + Parse("18002684646", "CA"); + Parse("416-445-9119", "CA"); + Parse("1-800-668-6866", "CA"); + Parse("(416) 453-6486", "CA"); + Parse("(647) 268-4778", "CA"); + Parse("647-218-1313", "CA"); + Parse("+1 647-209-4642", "CA"); + Parse("416-559-0133", "CA"); + Parse("+1 647-639-4118", "CA"); + Parse("+12898803664", "CA"); + Parse("780-901-4687", "CA"); + Parse("+14167070550", "CA"); + Parse("+1-647-522-6487", "CA"); + Parse("(416) 877-0880", "CA"); + + // Try again, but this time we have an international number with region rode US. It should + // recognize the country code and parse accordingly. + Parse("01164 3 331 6005", "US"); + Parse("+64 3 331 6005", "US"); + Parse("64(0)64123456", "NZ"); + // Check that using a "/" is fine in a phone number. + Parse("123/45678", "DE"); + Parse("123-456-7890", "US"); + + // Test parsing international numbers. + Parse("+1 (650) 333-6000", "NZ"); + Parse("1-650-333-6000", "US"); + // Calling the US number from Singapore by using different service providers + // 1st test: calling using SingTel IDD service (IDD is 001) + Parse("0011-650-333-6000", "SG"); + // 2nd test: calling using StarHub IDD service (IDD is 008) + Parse("0081-650-333-6000", "SG"); + // 3rd test: calling using SingTel V019 service (IDD is 019) + Parse("0191-650-333-6000", "SG"); + // Calling the US number from Poland + Parse("0~01-650-333-6000", "PL"); + // Using "++" at the start. + Parse("++1 (650) 333-6000", "PL"); + // Using a full-width plus sign. + Parse("\uFF0B1 (650) 333-6000", "SG"); + // The whole number, including punctuation, is here represented in full-width form. + Parse( + "\uFF0B\uFF11\u3000\uFF08\uFF16\uFF15\uFF10\uFF09" + + "\u3000\uFF13\uFF13\uFF13\uFF0D\uFF16\uFF10\uFF10\uFF10", + "SG" + ); + + // Test parsing with leading zeros. + Parse("+39 02-36618 300", "NZ"); + Parse("02-36618 300", "IT"); + Parse("312 345 678", "IT"); + + // Test parsing numbers in Argentina. + Parse("+54 9 343 555 1212", "AR"); + Parse("0343 15 555 1212", "AR"); + Parse("+54 9 3715 65 4320", "AR"); + Parse("03715 15 65 4320", "AR"); + Parse("+54 11 3797 0000", "AR"); + Parse("011 3797 0000", "AR"); + Parse("+54 3715 65 4321", "AR"); + Parse("03715 65 4321", "AR"); + Parse("+54 23 1234 0000", "AR"); + Parse("023 1234 0000", "AR"); + + // Test numbers in Mexico + Parse("+52 (449)978-0001", "MX"); + Parse("01 (449)978-0001", "MX"); + Parse("(449)978-0001", "MX"); + Parse("+52 1 33 1234-5678", "MX"); + Parse("044 (33) 1234-5678", "MX"); + Parse("045 33 1234-5678", "MX"); + + // Test that lots of spaces are ok. + Parse("0 3 3 3 1 6 0 0 5", "NZ"); + + // Test omitting the current region. This is only valid when the number starts + // with a '+'. + Parse("+64 3 331 6005"); + Parse("+64 3 331 6005", null); + + // US numbers + Format( + "19497261234", + "US", + "9497261234", + "US", + "(949) 726-1234", + "+1 949-726-1234" + ); + + // Try a couple german numbers from the US with various access codes. + Format( + "49451491934", + "US", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + Format( + "+49451491934", + "US", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + Format( + "01149451491934", + "US", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + + // Now try dialing the same number from within the German region. + Format( + "451491934", + "DE", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + Format( + "0451491934", + "DE", + "0451491934", + "DE", + "0451 491934", + "+49 451 491934" + ); + + // Numbers in italy keep the leading 0 in the city code when dialing internationally. + Format( + "0577-555-555", + "IT", + "0577555555", + "IT", + "05 7755 5555", + "+39 05 7755 5555" + ); + + // Colombian international number without the leading "+" + Format("5712234567", "CO", "12234567", "CO", "(1) 2234567", "+57 1 2234567"); + + // Telefonica tests + Format( + "612123123", + "ES", + "612123123", + "ES", + "612 12 31 23", + "+34 612 12 31 23" + ); + + // Chile mobile number from a landline + Format( + "0997654321", + "CL", + "997654321", + "CL", + "(99) 765 4321", + "+56 99 765 4321" + ); + + // Chile mobile number from another mobile number + Format( + "997654321", + "CL", + "997654321", + "CL", + "(99) 765 4321", + "+56 99 765 4321" + ); + + // Dialing 911 in the US. This is not a national number. + CantParse("911", "US"); + + // China mobile number with a 0 in it + Format( + "15955042864", + "CN", + "015955042864", + "CN", + "0159 5504 2864", + "+86 159 5504 2864" + ); + + // Testing international region numbers. + CantParse("883510000000091", "001"); + Format( + "+883510000000092", + "001", + "510000000092", + "001", + "510 000 000 092", + "+883 510 000 000 092" + ); + Format( + "883510000000093", + "FR", + "510000000093", + "001", + "510 000 000 093", + "+883 510 000 000 093" + ); + Format( + "+883510000000094", + "FR", + "510000000094", + "001", + "510 000 000 094", + "+883 510 000 000 094" + ); + Format( + "883510000000095", + "US", + "510000000095", + "001", + "510 000 000 095", + "+883 510 000 000 095" + ); + Format( + "+883510000000096", + "US", + "510000000096", + "001", + "510 000 000 096", + "+883 510 000 000 096" + ); + CantParse("979510000012", "001"); + Format( + "+979510000012", + "001", + "510000012", + "001", + "5 1000 0012", + "+979 5 1000 0012" + ); + + // Test normalizing numbers. Only 0-9,#* are valid in a phone number. + Normalize("+ABC # * , 9 _ 1 _0", "+222#*,910"); + Normalize("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "22233344455566677778889999"); + Normalize("abcdefghijklmnopqrstuvwxyz", "22233344455566677778889999"); + + // 8 and 9 digit numbers with area code in Brazil with collect call prefix (90) + AllEqual( + [ + "01187654321", + "0411187654321", + "551187654321", + "90411187654321", + "+551187654321", + ], + "BR" + ); + AllEqual( + [ + "011987654321", + "04111987654321", + "5511987654321", + "904111987654321", + "+5511987654321", + ], + "BR" + ); + + Assert.equal(PhoneNumberNormalizer.Normalize("123abc", true), "123"); + Assert.equal(PhoneNumberNormalizer.Normalize("12345", true), "12345"); + Assert.equal(PhoneNumberNormalizer.Normalize("1abcd", false), "12223"); +}); diff --git a/browser/extensions/formautofill/test/unit/test_previewFormFields.js b/browser/extensions/formautofill/test/unit/test_previewFormFields.js new file mode 100644 index 0000000000..1b1a01860d --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_previewFormFields.js @@ -0,0 +1,197 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Bug 1762063 - we need to fix this pattern of having to wrap destructuring calls in parentheses. +// We can't do a standard destructuring call because FormAutofillUtils is already declared as a var in head.js +({ FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" +)); +const { FIELD_STATES } = FormAutofillUtils; +const PREVIEW = FIELD_STATES.PREVIEW; +const NORMAL = FIELD_STATES.NORMAL; + +const { OSKeyStore } = ChromeUtils.importESModule( + "resource://gre/modules/OSKeyStore.sys.mjs" +); + +const TESTCASES = [ + { + description: "Preview best case address form", + document: `
+ + + + +
`, + focusedInputId: "given-name", + profileData: { + "given-name": "John", + "family-name": "Doe", + "street-address": "100 Main Street", + "address-level2": "Hamilton", + }, + expectedResultState: { + "given-name": PREVIEW, + "family-name": PREVIEW, + "street-address": PREVIEW, + "address-level2": PREVIEW, + }, + }, + { + description: "Preview form with a readonly input and non-readonly inputs", + document: `
+ + + + +
`, + focusedInputId: "given-name", + profileData: { + "given-name": "John", + "family-name": "Doe", + "street-address": "100 Main Street", + city: "Hamilton", + }, + expectedResultState: { + "given-name": PREVIEW, + "family-name": PREVIEW, + "street-address": PREVIEW, + "address-level2": undefined, + }, + }, + { + description: "Preview form with a disabled input and non-disabled inputs", + document: `
+ + + + +
`, + focusedInputId: "given-name", + profileData: { + "given-name": "John", + "family-name": "Doe", + "street-address": "100 Main Street", + country: "CA", + }, + expectedResultState: { + "given-name": PREVIEW, + "family-name": PREVIEW, + "street-address": PREVIEW, + country: undefined, + }, + }, + { + description: + "Preview form with autocomplete select elements and matching option values", + document: `
+ + + +
`, + focusedInputId: "given-name", + profileData: { + country: "US", + "address-level1": "CA", + }, + expectedResultState: { + "given-name": NORMAL, + country: PREVIEW, + "address-level1": PREVIEW, + }, + }, + { + description: "Preview best case credit card form", + document: `
+ + + + + +
+ `, + focusedInputId: "cc-number", + profileData: { + guid: "123", + "cc-number": "4111111111111111", + "cc-name": "test name", + "cc-exp-month": 6, + "cc-exp-year": 25, + }, + expectedResultState: { + "cc-number": PREVIEW, + "cc-name": PREVIEW, + "cc-exp-month": PREVIEW, + "cc-exp-year": PREVIEW, + "cc-csc": NORMAL, + }, + }, +]; + +function run_tests(testcases) { + for (let testcase of testcases) { + add_task(async function () { + info("Starting testcase: " + testcase.description); + let doc = MockDocument.createTestDocument( + "http://localhost:8080/test/", + testcase.document + ); + let form = doc.querySelector("form"); + let formLike = FormLikeFactory.createFromForm(form); + let handler = new FormAutofillHandler(formLike); + + // Replace the internal decrypt method with OSKeyStore API, + // but don't pass the reauth parameter to avoid triggering + // reauth login dialog in these tests. + let decryptHelper = async (cipherText, reauth) => { + return OSKeyStore.decrypt(cipherText, false); + }; + handler.collectFormFields(); + + let focusedInput = doc.getElementById(testcase.focusedInputId); + try { + handler.focusedInput = focusedInput; + } catch (e) { + if (e.message.includes("WeakMap key must be an object")) { + throw new Error( + `Couldn't find the focusedInputId in the current form! Make sure focusedInputId exists in your test form! testcase description:${testcase.description}` + ); + } else { + throw e; + } + } + + for (let section of handler.sections) { + section._decrypt = decryptHelper; + } + + let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([ + testcase.profileData, + ]); + + await handler.activeSection.previewFormFields(adaptedProfile); + + for (let field of handler.fieldDetails) { + let actual = handler.getFilledStateByElement(field.element); + let expected = testcase.expectedResultState[field.fieldName]; + info(`Checking ${field.fieldName} state`); + Assert.equal( + actual, + expected, + "Check if preview state is set correctly" + ); + } + }); + } +} + +run_tests(TESTCASES); diff --git a/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js b/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js new file mode 100644 index 0000000000..7200bc8975 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js @@ -0,0 +1,452 @@ +"use strict"; + +var AddressResult, CreditCardResult; +add_setup(async () => { + ({ AddressResult, CreditCardResult } = ChromeUtils.importESModule( + "resource://autofill/ProfileAutoCompleteResult.sys.mjs" + )); +}); + +let matchingProfiles = [ + { + guid: "test-guid-1", + "given-name": "Timothy", + "family-name": "Berners-Lee", + name: "Timothy Berners-Lee", + organization: "Sesame Street", + "street-address": "123 Sesame Street.", + "address-line1": "123 Sesame Street.", + tel: "1-345-345-3456.", + }, + { + guid: "test-guid-2", + "given-name": "John", + "family-name": "Doe", + name: "John Doe", + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue", + "address-line1": "331 E. Evelyn Avenue", + tel: "1-650-903-0800", + }, + { + guid: "test-guid-3", + organization: "", + "street-address": "321, No Name St. 2nd line 3rd line", + "-moz-street-address-one-line": "321, No Name St. 2nd line 3rd line", + "address-line1": "321, No Name St.", + "address-line2": "2nd line", + "address-line3": "3rd line", + tel: "1-000-000-0000", + }, +]; + +let allFieldNames = [ + "given-name", + "family-name", + "street-address", + "address-line1", + "address-line2", + "address-line3", + "organization", + "tel", +]; + +let addressTestCases = [ + { + description: "Focus on an `organization` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "organization", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "Sesame Street", + secondary: "123 Sesame Street.", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "Mozilla", + secondary: "331 E. Evelyn Avenue", + }), + image: "", + }, + ], + }, + }, + { + description: "Focus on an `tel` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "tel", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "1-345-345-3456.", + secondary: "123 Sesame Street.", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "1-650-903-0800", + secondary: "331 E. Evelyn Avenue", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[2]), + label: JSON.stringify({ + primary: "1-000-000-0000", + secondary: "321, No Name St. 2nd line 3rd line", + }), + image: "", + }, + ], + }, + }, + { + description: "Focus on an `street-address` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "street-address", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "123 Sesame Street.", + secondary: "Timothy Berners-Lee", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "331 E. Evelyn Avenue", + secondary: "John Doe", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[2]), + label: JSON.stringify({ + primary: "321, No Name St. 2nd line 3rd line", + secondary: "1-000-000-0000", + }), + image: "", + }, + ], + }, + }, + { + description: "Focus on an `address-line1` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "address-line1", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "123 Sesame Street.", + secondary: "Timothy Berners-Lee", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "331 E. Evelyn Avenue", + secondary: "John Doe", + }), + image: "", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[2]), + label: JSON.stringify({ + primary: "321, No Name St.", + secondary: "1-000-000-0000", + }), + image: "", + }, + ], + }, + }, + { + description: "No matching profiles", + options: {}, + matchingProfiles: [], + allFieldNames, + searchString: "", + fieldName: "", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH, + defaultIndex: 0, + items: [], + }, + }, + { + description: "Search with failure", + options: { resultCode: Ci.nsIAutoCompleteResult.RESULT_FAILURE }, + matchingProfiles: [], + allFieldNames, + searchString: "", + fieldName: "", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE, + defaultIndex: 0, + items: [], + }, + }, +]; + +matchingProfiles = [ + { + guid: "test-guid-1", + "cc-name": "Timothy Berners-Lee", + "cc-number": "************6785", + "cc-exp-month": 12, + "cc-exp-year": 2014, + "cc-type": "visa", + }, + { + guid: "test-guid-2", + "cc-name": "John Doe", + "cc-number": "************1234", + "cc-exp-month": 4, + "cc-exp-year": 2014, + "cc-type": "amex", + }, + { + guid: "test-guid-3", + "cc-number": "************5678", + "cc-exp-month": 8, + "cc-exp-year": 2018, + }, +]; + +allFieldNames = ["cc-name", "cc-number", "cc-exp-month", "cc-exp-year"]; + +let creditCardTestCases = [ + { + description: "Focus on a `cc-name` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "cc-name", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "Timothy Berners-Lee", + secondary: "****6785", + ariaLabel: "Visa Timothy Berners-Lee ****6785", + image: "chrome://formautofill/content/third-party/cc-logo-visa.svg", + }), + image: "chrome://formautofill/content/third-party/cc-logo-visa.svg", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "John Doe", + secondary: "****1234", + ariaLabel: "American Express John Doe ****1234", + image: "chrome://formautofill/content/third-party/cc-logo-amex.png", + }), + image: "chrome://formautofill/content/third-party/cc-logo-amex.png", + }, + ], + }, + }, + { + description: "Focus on a `cc-number` field", + options: {}, + matchingProfiles, + allFieldNames, + searchString: "", + fieldName: "cc-number", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + defaultIndex: 0, + items: [ + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[0]), + label: JSON.stringify({ + primary: "****6785", + secondary: "Timothy Berners-Lee", + ariaLabel: "Visa 6785 Timothy Berners-Lee", + image: "chrome://formautofill/content/third-party/cc-logo-visa.svg", + }), + image: "chrome://formautofill/content/third-party/cc-logo-visa.svg", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[1]), + label: JSON.stringify({ + primary: "****1234", + secondary: "John Doe", + ariaLabel: "American Express 1234 John Doe", + image: "chrome://formautofill/content/third-party/cc-logo-amex.png", + }), + image: "chrome://formautofill/content/third-party/cc-logo-amex.png", + }, + { + value: "", + style: "autofill-profile", + comment: JSON.stringify(matchingProfiles[2]), + label: JSON.stringify({ + primary: "****5678", + secondary: "", + ariaLabel: "5678", + image: "chrome://formautofill/content/icon-credit-card-generic.svg", + }), + image: "chrome://formautofill/content/icon-credit-card-generic.svg", + }, + ], + }, + }, + { + description: "No matching profiles", + options: {}, + matchingProfiles: [], + allFieldNames, + searchString: "", + fieldName: "", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH, + defaultIndex: 0, + items: [], + }, + }, + { + description: "Search with failure", + options: { resultCode: Ci.nsIAutoCompleteResult.RESULT_FAILURE }, + matchingProfiles: [], + allFieldNames, + searchString: "", + fieldName: "", + expected: { + searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE, + defaultIndex: 0, + items: [], + }, + }, +]; + +add_task(async function test_all_patterns() { + let testSets = [ + { + collectionConstructor: AddressResult, + testCases: addressTestCases, + }, + { + collectionConstructor: CreditCardResult, + testCases: creditCardTestCases, + }, + ]; + + testSets.forEach(({ collectionConstructor, testCases }) => { + testCases.forEach(testCase => { + info("Starting testcase: " + testCase.description); + let actual = new collectionConstructor( + testCase.searchString, + testCase.fieldName, + testCase.allFieldNames, + testCase.matchingProfiles, + testCase.options + ); + let expectedValue = testCase.expected; + let expectedItemLength = expectedValue.items.length; + // If the last item shows up as a footer, we expect one more item + // than expected. + if (actual.getStyleAt(actual.matchCount - 1) == "autofill-footer") { + expectedItemLength++; + } + + equal(actual.searchResult, expectedValue.searchResult); + equal(actual.defaultIndex, expectedValue.defaultIndex); + equal(actual.matchCount, expectedItemLength); + expectedValue.items.forEach((item, index) => { + equal(actual.getValueAt(index), item.value); + equal(actual.getCommentAt(index), item.comment); + equal(actual.getLabelAt(index), item.label); + equal(actual.getStyleAt(index), item.style); + equal(actual.getImageAt(index), item.image); + }); + + if (expectedValue.items.length) { + Assert.throws( + () => actual.getValueAt(expectedItemLength), + /Index out of range\./ + ); + + Assert.throws( + () => actual.getLabelAt(expectedItemLength), + /Index out of range\./ + ); + + Assert.throws( + () => actual.getCommentAt(expectedItemLength), + /Index out of range\./ + ); + } + }); + }); +}); diff --git a/browser/extensions/formautofill/test/unit/test_reconcile.js b/browser/extensions/formautofill/test/unit/test_reconcile.js new file mode 100644 index 0000000000..d9417eef8c --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_reconcile.js @@ -0,0 +1,1176 @@ +"use strict"; + +const TEST_STORE_FILE_NAME = "test-profile.json"; +const { CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorageBase.sys.mjs" +); + +// NOTE: a guide to reading these test-cases: +// parent: What the local record looked like the last time we wrote the +// record to the Sync server. +// local: What the local record looks like now. IOW, the differences between +// 'parent' and 'local' are changes recently made which we wish to sync. +// remote: An incoming record we need to apply (ie, a record that was possibly +// changed on a remote device) +// +// To further help understanding this, a few of the testcases are annotated. +const ADDRESS_RECONCILE_TESTCASES = [ + { + description: "Local change", + parent: { + // So when we last wrote the record to the server, it had these values. + guid: "2bbd2d8fbc6b", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + local: [ + { + // The current local record - by comparing against parent we can see that + // only the name has changed locally. + name: "Skip", + "street-address": "32 Vassar Street", + }, + ], + remote: { + // This is the incoming record. It has the same values as "parent", so + // we can deduce the record hasn't actually been changed remotely so we + // can safely ignore the incoming record and write our local changes. + guid: "2bbd2d8fbc6b", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + reconciled: { + guid: "2bbd2d8fbc6b", + name: "Skip", + "street-address": "32 Vassar Street", + }, + }, + { + description: "Remote change", + parent: { + guid: "e3680e9f890d", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + local: [ + { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + ], + remote: { + guid: "e3680e9f890d", + version: 1, + name: "Skip", + "street-address": "32 Vassar Street", + }, + reconciled: { + guid: "e3680e9f890d", + name: "Skip", + "street-address": "32 Vassar Street", + }, + }, + { + description: "New local field", + parent: { + guid: "0cba738b1be0", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + local: [ + { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + }, + ], + remote: { + guid: "0cba738b1be0", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + reconciled: { + guid: "0cba738b1be0", + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + }, + }, + { + description: "New remote field", + parent: { + guid: "be3ef97f8285", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + local: [ + { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + ], + remote: { + guid: "be3ef97f8285", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + }, + reconciled: { + guid: "be3ef97f8285", + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + }, + }, + { + description: "Deleted field locally", + parent: { + guid: "9627322248ec", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + }, + local: [ + { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + ], + remote: { + guid: "9627322248ec", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + }, + reconciled: { + guid: "9627322248ec", + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + }, + { + description: "Deleted field remotely", + parent: { + guid: "7d7509f3eeb2", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + }, + local: [ + { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + }, + ], + remote: { + guid: "7d7509f3eeb2", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + reconciled: { + guid: "7d7509f3eeb2", + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + }, + { + description: "Local and remote changes to unrelated fields", + parent: { + // The last time we wrote this to the server, country was NZ. + guid: "e087a06dfc57", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "NZ", + // We also had an unknown field we round-tripped + foo: "bar", + }, + local: [ + { + // The current local record - so locally we've changed name to Skip. + name: "Skip", + "street-address": "32 Vassar Street", + country: "NZ", + }, + ], + remote: { + // Remotely, we've changed the country to AU. + guid: "e087a06dfc57", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "AU", + // This is a new unknown field that should send instead! + "unknown-1": "an unknown field from another client", + }, + reconciled: { + guid: "e087a06dfc57", + name: "Skip", + "street-address": "32 Vassar Street", + country: "AU", + // This is a new unknown field that should send instead! + "unknown-1": "an unknown field from another client", + }, + }, + { + description: "Multiple local changes", + parent: { + guid: "340a078c596f", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + country: "US", + }, + local: [ + { + name: "Skip", + "street-address": "32 Vassar Street", + country: "US", + }, + { + name: "Skip", + "street-address": "32 Vassar Street", + organization: "Mozilla", + country: "US", + }, + ], + remote: { + guid: "340a078c596f", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + country: "AU", + }, + reconciled: { + guid: "340a078c596f", + name: "Skip", + "street-address": "32 Vassar Street", + organization: "Mozilla", + country: "AU", + }, + }, + { + // Local and remote diverged from the shared parent, but the values are the + // same, so we shouldn't fork. + description: "Same change to local and remote", + parent: { + guid: "0b3a72a1bea2", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + // unknown fields we previously roundtripped + foo: "bar", + }, + local: [ + { + name: "Skip", + "street-address": "32 Vassar Street", + }, + ], + remote: { + guid: "0b3a72a1bea2", + version: 1, + name: "Skip", + "street-address": "32 Vassar Street", + // New unknown field that should be the new round trip + "unknown-1": "an unknown field from another client", + }, + reconciled: { + guid: "0b3a72a1bea2", + name: "Skip", + "street-address": "32 Vassar Street", + }, + }, + { + description: "Conflicting changes to single field", + parent: { + // This is what we last wrote to the sync server. + guid: "62068784d089", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + "unknown-1": "an unknown field from another client", + }, + local: [ + { + // The current version of the local record - the name has changed locally. + name: "Skip", + "street-address": "32 Vassar Street", + }, + ], + remote: { + // An incoming record has a different name than any of the above! + guid: "62068784d089", + version: 1, + name: "Kip", + "street-address": "32 Vassar Street", + "unknown-1": "an unknown field from another client", + }, + forked: { + // So we've forked the local record to a new GUID (and the next sync is + // going to write this as a new record) + name: "Skip", + "street-address": "32 Vassar Street", + "unknown-1": "an unknown field from another client", + }, + reconciled: { + // And we've updated the local version of the record to be the remote version. + guid: "62068784d089", + name: "Kip", + "street-address": "32 Vassar Street", + "unknown-1": "an unknown field from another client", + }, + }, + { + description: "Conflicting changes to multiple fields", + parent: { + guid: "244dbb692e94", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "NZ", + }, + local: [ + { + name: "Skip", + "street-address": "32 Vassar Street", + country: "AU", + }, + ], + remote: { + guid: "244dbb692e94", + version: 1, + name: "Kip", + "street-address": "32 Vassar Street", + country: "CA", + }, + forked: { + name: "Skip", + "street-address": "32 Vassar Street", + country: "AU", + }, + reconciled: { + guid: "244dbb692e94", + name: "Kip", + "street-address": "32 Vassar Street", + country: "CA", + }, + }, + { + description: "Field deleted locally, changed remotely", + parent: { + guid: "6fc45e03d19a", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "AU", + }, + local: [ + { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + ], + remote: { + guid: "6fc45e03d19a", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "NZ", + }, + forked: { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + reconciled: { + guid: "6fc45e03d19a", + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "NZ", + }, + }, + { + description: "Field changed locally, deleted remotely", + parent: { + guid: "fff9fa27fa18", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "AU", + }, + local: [ + { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "NZ", + }, + ], + remote: { + guid: "fff9fa27fa18", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + forked: { + name: "Mark Hammond", + "street-address": "32 Vassar Street", + country: "NZ", + }, + reconciled: { + guid: "fff9fa27fa18", + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }, + }, + { + // Created, last modified should be synced; last used and times used should + // be local. Remote created time older than local, remote modified time + // newer than local. + description: + "Created, last modified time reconciliation without local changes", + parent: { + guid: "5113f329c42f", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + timeCreated: 1234, + timeLastModified: 5678, + timeLastUsed: 5678, + timesUsed: 6, + }, + local: [], + remote: { + guid: "5113f329c42f", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + timeCreated: 1200, + timeLastModified: 5700, + timeLastUsed: 5700, + timesUsed: 3, + }, + reconciled: { + guid: "5113f329c42f", + name: "Mark Hammond", + "street-address": "32 Vassar Street", + timeCreated: 1200, + timeLastModified: 5700, + timeLastUsed: 5678, + timesUsed: 6, + }, + }, + { + // Local changes, remote created time newer than local, remote modified time + // older than local. + description: + "Created, last modified time reconciliation with local changes", + parent: { + guid: "791e5608b80a", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + timeCreated: 1234, + timeLastModified: 5678, + timeLastUsed: 5678, + timesUsed: 6, + }, + local: [ + { + name: "Skip", + "street-address": "32 Vassar Street", + }, + ], + remote: { + guid: "791e5608b80a", + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + timeCreated: 1300, + timeLastModified: 5000, + timeLastUsed: 5000, + timesUsed: 3, + }, + reconciled: { + guid: "791e5608b80a", + name: "Skip", + "street-address": "32 Vassar Street", + timeCreated: 1234, + timeLastUsed: 5678, + timesUsed: 6, + }, + }, +]; + +const CREDIT_CARD_RECONCILE_TESTCASES = [ + { + description: "Local change", + parent: { + // So when we last wrote the record to the server, it had these values. + guid: "2bbd2d8fbc6b", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "unknown-1": "an unknown field from another client", + }, + local: [ + { + // The current local record - by comparing against parent we can see that + // only the cc-number has changed locally. + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + ], + remote: { + // This is the incoming record. It has the same values as "parent", so + // we can deduce the record hasn't actually been changed remotely so we + // can safely ignore the incoming record and write our local changes. + guid: "2bbd2d8fbc6b", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "unknown-2": "a newer unknown field", + }, + reconciled: { + guid: "2bbd2d8fbc6b", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "unknown-2": "a newer unknown field", + }, + }, + { + description: "Remote change", + parent: { + guid: "e3680e9f890d", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "unknown-1": "unknown field", + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + ], + remote: { + guid: "e3680e9f890d", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "unknown-1": "unknown field", + }, + reconciled: { + guid: "e3680e9f890d", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "unknown-1": "unknown field", + }, + }, + + { + description: "New local field", + parent: { + guid: "0cba738b1be0", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + ], + remote: { + guid: "0cba738b1be0", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + reconciled: { + guid: "0cba738b1be0", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + }, + { + description: "New remote field", + parent: { + guid: "be3ef97f8285", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + ], + remote: { + guid: "be3ef97f8285", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + reconciled: { + guid: "be3ef97f8285", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + }, + { + description: "Deleted field locally", + parent: { + guid: "9627322248ec", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + ], + remote: { + guid: "9627322248ec", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + reconciled: { + guid: "9627322248ec", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + }, + { + description: "Deleted field remotely", + parent: { + guid: "7d7509f3eeb2", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + ], + remote: { + guid: "7d7509f3eeb2", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + reconciled: { + guid: "7d7509f3eeb2", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + }, + { + description: "Local and remote changes to unrelated fields", + parent: { + // The last time we wrote this to the server, "cc-exp-month" was 12. + guid: "e087a06dfc57", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + "unknown-1": "unknown field", + }, + local: [ + { + // The current local record - so locally we've changed "cc-number". + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 12, + }, + ], + remote: { + // Remotely, we've changed "cc-exp-month" to 1. + guid: "e087a06dfc57", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 1, + "unknown-2": "a newer unknown field", + }, + reconciled: { + guid: "e087a06dfc57", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 1, + "unknown-2": "a newer unknown field", + }, + }, + { + description: "Multiple local changes", + parent: { + guid: "340a078c596f", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "unknown-1": "unknown field", + }, + local: [ + { + "cc-name": "Skip", + "cc-number": "4111111111111111", + }, + { + "cc-name": "Skip", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + ], + remote: { + guid: "340a078c596f", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-year": 2000, + "unknown-1": "unknown field", + }, + reconciled: { + guid: "340a078c596f", + "cc-name": "Skip", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + "cc-exp-year": 2000, + "unknown-1": "unknown field", + }, + }, + { + // Local and remote diverged from the shared parent, but the values are the + // same, so we shouldn't fork. + description: "Same change to local and remote", + parent: { + guid: "0b3a72a1bea2", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + ], + remote: { + guid: "0b3a72a1bea2", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + reconciled: { + guid: "0b3a72a1bea2", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + }, + { + description: "Conflicting changes to single field", + parent: { + // This is what we last wrote to the sync server. + guid: "62068784d089", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "unknown-1": "unknown field", + }, + local: [ + { + // The current version of the local record - the cc-number has changed locally. + "cc-name": "John Doe", + "cc-number": "5103059495477870", + }, + ], + remote: { + // An incoming record has a different cc-number than any of the above! + guid: "62068784d089", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "unknown-1": "unknown field", + }, + forked: { + // So we've forked the local record to a new GUID (and the next sync is + // going to write this as a new record) + "cc-name": "John Doe", + "cc-number": "5103059495477870", + "unknown-1": "unknown field", + }, + reconciled: { + // And we've updated the local version of the record to be the remote version. + guid: "62068784d089", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "unknown-1": "unknown field", + }, + }, + { + description: "Conflicting changes to multiple fields", + parent: { + guid: "244dbb692e94", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "5103059495477870", + "cc-exp-month": 1, + }, + ], + remote: { + guid: "244dbb692e94", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 3, + }, + forked: { + "cc-name": "John Doe", + "cc-number": "5103059495477870", + "cc-exp-month": 1, + }, + reconciled: { + guid: "244dbb692e94", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + "cc-exp-month": 3, + }, + }, + { + description: "Field deleted locally, changed remotely", + parent: { + guid: "6fc45e03d19a", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + ], + remote: { + guid: "6fc45e03d19a", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 3, + }, + forked: { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + reconciled: { + guid: "6fc45e03d19a", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 3, + }, + }, + { + description: "Field changed locally, deleted remotely", + parent: { + guid: "fff9fa27fa18", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 12, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 3, + }, + ], + remote: { + guid: "fff9fa27fa18", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + forked: { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 3, + }, + reconciled: { + guid: "fff9fa27fa18", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + }, + }, + { + // Created, last modified should be synced; last used and times used should + // be local. Remote created time older than local, remote modified time + // newer than local. + description: + "Created, last modified time reconciliation without local changes", + parent: { + guid: "5113f329c42f", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1234, + timeLastModified: 5678, + timeLastUsed: 5678, + timesUsed: 6, + }, + local: [], + remote: { + guid: "5113f329c42f", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1200, + timeLastModified: 5700, + timeLastUsed: 5700, + timesUsed: 3, + }, + reconciled: { + guid: "5113f329c42f", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1200, + timeLastModified: 5700, + timeLastUsed: 5678, + timesUsed: 6, + }, + }, + { + // Local changes, remote created time newer than local, remote modified time + // older than local. + description: + "Created, last modified time reconciliation with local changes", + parent: { + guid: "791e5608b80a", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1234, + timeLastModified: 5678, + timeLastUsed: 5678, + timesUsed: 6, + }, + local: [ + { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + ], + remote: { + guid: "791e5608b80a", + version: CREDIT_CARD_SCHEMA_VERSION, + "cc-name": "John Doe", + "cc-number": "4111111111111111", + timeCreated: 1300, + timeLastModified: 5000, + timeLastUsed: 5000, + timesUsed: 3, + }, + reconciled: { + guid: "791e5608b80a", + "cc-name": "John Doe", + "cc-number": "4929001587121045", + timeCreated: 1234, + timeLastUsed: 5678, + timesUsed: 6, + }, + }, +]; + +add_task(async function test_reconcile_unknown_version() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + + // Cross-version reconciliation isn't supported yet. See bug 1377204. + await Assert.rejects( + profileStorage.addresses.reconcile({ + guid: "31d83d2725ec", + version: 3, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + }), + /Got unknown record version/ + ); +}); + +add_task(async function test_reconcile_idempotent() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + + let guid = "de1ba7b094fe"; + await profileStorage.addresses.add( + { + guid, + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + // an unknown field from a previous sync + foo: "bar", + }, + { sourceSync: true } + ); + await profileStorage.addresses.update(guid, { + name: "Skip", + "street-address": "32 Vassar Street", + organization: "Mozilla", + }); + + let remote = { + guid, + version: 1, + name: "Mark Hammond", + "street-address": "32 Vassar Street", + tel: "123456", + "unknown-1": "an unknown field from another client", + }; + + { + let { forkedGUID } = await profileStorage.addresses.reconcile(remote); + let updatedRecord = await profileStorage.addresses.get(guid, { + rawData: true, + }); + + ok(!forkedGUID, "First merge should not fork record"); + ok( + objectMatches(updatedRecord, { + guid: "de1ba7b094fe", + name: "Skip", + "street-address": "32 Vassar Street", + organization: "Mozilla", + tel: "123456", + "unknown-1": "an unknown field from another client", + }), + "First merge should merge local and remote changes" + ); + } + + { + let { forkedGUID } = await profileStorage.addresses.reconcile(remote); + let updatedRecord = await profileStorage.addresses.get(guid, { + rawData: true, + }); + + ok(!forkedGUID, "Second merge should not fork record"); + ok( + objectMatches(updatedRecord, { + guid: "de1ba7b094fe", + name: "Skip", + "street-address": "32 Vassar Street", + organization: "Mozilla", + tel: "123456", + "unknown-1": "an unknown field from another client", + }), + "Second merge should not change record" + ); + } +}); + +add_task(async function test_reconcile_three_way_merge() { + let TESTCASES = { + addresses: ADDRESS_RECONCILE_TESTCASES, + creditCards: CREDIT_CARD_RECONCILE_TESTCASES, + }; + + for (let collectionName in TESTCASES) { + info(`Start to test reconcile on ${collectionName}`); + + let profileStorage = await initProfileStorage( + TEST_STORE_FILE_NAME, + null, + collectionName + ); + + for (let test of TESTCASES[collectionName]) { + info(test.description); + + await profileStorage[collectionName].add(test.parent, { + sourceSync: true, + }); + + for (let updatedRecord of test.local) { + await profileStorage[collectionName].update( + test.parent.guid, + updatedRecord + ); + } + + let localRecord = await profileStorage[collectionName].get( + test.parent.guid, + { + rawData: true, + } + ); + + let onReconciled = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => + data == "reconcile" && + subject.wrappedJSObject.collectionName == collectionName + ); + let { forkedGUID } = await profileStorage[collectionName].reconcile( + test.remote + ); + await onReconciled; + let reconciledRecord = await profileStorage[collectionName].get( + test.parent.guid, + { + rawData: true, + } + ); + if (forkedGUID) { + let forkedRecord = await profileStorage[collectionName].get( + forkedGUID, + { + rawData: true, + } + ); + + notEqual(forkedRecord.guid, reconciledRecord.guid); + equal(forkedRecord.timeLastModified, localRecord.timeLastModified); + ok( + objectMatches(forkedRecord, test.forked), + `${test.description} should fork record` + ); + } else { + ok(!test.forked, `${test.description} should not fork record`); + } + + ok(objectMatches(reconciledRecord, test.reconciled)); + } + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_savedFieldNames.js b/browser/extensions/formautofill/test/unit/test_savedFieldNames.js new file mode 100644 index 0000000000..6e3474c06d --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_savedFieldNames.js @@ -0,0 +1,106 @@ +/* + * Test for keeping the valid fields information in sharedData. + */ + +"use strict"; + +let FormAutofillStatus; + +add_setup(async () => { + ({ FormAutofillStatus } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillParent.sys.mjs" + )); +}); + +add_task(async function test_profileSavedFieldNames_init() { + FormAutofillStatus.init(); + sinon.stub(FormAutofillStatus, "updateSavedFieldNames"); + + await FormAutofillStatus.formAutofillStorage.initialize(); + Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, true); + + FormAutofillStatus.uninit(); +}); + +add_task(async function test_profileSavedFieldNames_observe() { + FormAutofillStatus.init(); + + // profile changed => Need to trigger updateValidFields + ["add", "update", "remove", "reconcile", "removeAll"].forEach(event => { + FormAutofillStatus.observe(null, "formautofill-storage-changed", event); + Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, true); + }); + + // profile metadata updated => no need to trigger updateValidFields + FormAutofillStatus.updateSavedFieldNames.resetHistory(); + FormAutofillStatus.observe( + null, + "formautofill-storage-changed", + "notifyUsed" + ); + Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, false); + FormAutofillStatus.updateSavedFieldNames.restore(); +}); + +add_task(async function test_profileSavedFieldNames_update() { + registerCleanupFunction(function cleanup() { + Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled"); + }); + + Object.defineProperty( + FormAutofillStatus.formAutofillStorage.addresses, + "_data", + { writable: true } + ); + + FormAutofillStatus.formAutofillStorage.addresses._data = []; + + // The set is empty if there's no profile in the store. + await FormAutofillStatus.updateSavedFieldNames(); + Assert.equal( + Services.ppmm.sharedData.get("FormAutofill:savedFieldNames").size, + 0 + ); + + // 2 profiles with 4 valid fields. + FormAutofillStatus.formAutofillStorage.addresses._data = [ + { + guid: "test-guid-1", + organization: "Sesame Street", + "street-address": "123 Sesame Street.", + tel: "1-345-345-3456", + email: "", + timeCreated: 0, + timeLastUsed: 0, + timeLastModified: 0, + timesUsed: 0, + }, + { + guid: "test-guid-2", + organization: "Mozilla", + "street-address": "331 E. Evelyn Avenue", + tel: "1-650-903-0800", + country: "US", + timeCreated: 0, + timeLastUsed: 0, + timeLastModified: 0, + timesUsed: 0, + }, + ]; + + await FormAutofillStatus.updateSavedFieldNames(); + + let autofillSavedFieldNames = Services.ppmm.sharedData.get( + "FormAutofill:savedFieldNames" + ); + Assert.equal(autofillSavedFieldNames.size, 4); + Assert.equal(autofillSavedFieldNames.has("organization"), true); + Assert.equal(autofillSavedFieldNames.has("street-address"), true); + Assert.equal(autofillSavedFieldNames.has("tel"), true); + Assert.equal(autofillSavedFieldNames.has("email"), false); + Assert.equal(autofillSavedFieldNames.has("guid"), false); + Assert.equal(autofillSavedFieldNames.has("timeCreated"), false); + Assert.equal(autofillSavedFieldNames.has("timeLastUsed"), false); + Assert.equal(autofillSavedFieldNames.has("timeLastModified"), false); + Assert.equal(autofillSavedFieldNames.has("timesUsed"), false); +}); diff --git a/browser/extensions/formautofill/test/unit/test_storage_remove.js b/browser/extensions/formautofill/test/unit/test_storage_remove.js new file mode 100644 index 0000000000..0c33440ea9 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_storage_remove.js @@ -0,0 +1,88 @@ +/** + * Tests removing all address/creditcard records. + */ + +"use strict"; + +let FormAutofillStorage; +add_setup(async () => { + ({ FormAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + )); +}); + +const TEST_STORE_FILE_NAME = "test-tombstones.json"; + +const TEST_ADDRESS_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+1 617 253 5702", + email: "timbl@w3.org", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +const TEST_CREDIT_CARD_1 = { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": 2017, +}; + +const TEST_CREDIT_CARD_2 = { + "cc-name": "Timothy Berners-Lee", + "cc-number": "4929001587121045", + "cc-exp-month": 12, + "cc-exp-year": 2022, +}; + +// Like add_task, but actually adds 2 - one for addresses and one for cards. +function add_storage_task(test_function) { + add_task(async function () { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + let address_records = [TEST_ADDRESS_1, TEST_ADDRESS_2]; + let cc_records = [TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2]; + + for (let [storage, record] of [ + [profileStorage.addresses, address_records], + [profileStorage.creditCards, cc_records], + ]) { + await test_function(storage, record); + } + }); +} + +add_storage_task(async function test_remove_everything(storage, records) { + info("check simple tombstone semantics"); + + let guid = await storage.add(records[0]); + Assert.equal((await storage.getAll()).length, 1); + + storage.pullSyncChanges(); // force sync metadata, which triggers tombstone behaviour. + + storage.remove(guid); + + await storage.add(records[1]); + // getAll() is still 1 as we deleted the first. + Assert.equal((await storage.getAll()).length, 1); + + // check we have the tombstone. + Assert.equal((await storage.getAll({ includeDeleted: true })).length, 2); + + storage.removeAll(); + + // should have deleted both the existing and deleted records. + Assert.equal((await storage.getAll({ includeDeleted: true })).length, 0); +}); diff --git a/browser/extensions/formautofill/test/unit/test_storage_syncfields.js b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js new file mode 100644 index 0000000000..9325a877cf --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js @@ -0,0 +1,496 @@ +/** + * Tests FormAutofillStorage objects support for sync related fields. + */ + +"use strict"; + +// The duplication of some of these fixtures between tests is unfortunate. +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const TEST_ADDRESS_1 = { + name: "Timothy John Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+1 617 253 5702", + email: "timbl@w3.org", + "unknown-1": "an unknown field we roundtrip", +}; + +const TEST_ADDRESS_2 = { + "street-address": "Some Address", + country: "US", +}; + +const TEST_ADDRESS_3 = { + "street-address": "Other Address", + "postal-code": "12345", +}; + +// storage.get() doesn't support getting deleted items. However, this test +// wants to do that, so rather than making .get() support that just for this +// test, we use this helper. +async function findGUID(storage, guid, options) { + let all = await storage.getAll(options); + let records = all.filter(r => r.guid == guid); + equal(records.length, 1); + return records[0]; +} + +add_task(async function test_changeCounter() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + ]); + + let [address] = await profileStorage.addresses.getAll(); + // new records don't get the sync metadata. + equal(getSyncChangeCounter(profileStorage.addresses, address.guid), -1); + // But we can force one. + profileStorage.addresses.pullSyncChanges(); + equal(getSyncChangeCounter(profileStorage.addresses, address.guid), 1); +}); + +add_task(async function test_pushChanges() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + profileStorage.addresses.pullSyncChanges(); // force sync metadata for all items + + let [, address] = await profileStorage.addresses.getAll(); + let guid = address.guid; + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + + // Pretend we're doing a sync now, and an update occured mid-sync. + let changes = { + [guid]: { + profile: address, + counter: changeCounter, + modified: address.timeLastModified, + synced: true, + }, + }; + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "update" + ); + await profileStorage.addresses.update(guid, TEST_ADDRESS_3); + await onChanged; + + changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + Assert.equal(changeCounter, 2); + + profileStorage.addresses.pushSyncChanges(changes); + address = await profileStorage.addresses.get(guid); + changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + + // Counter should still be 1, since our sync didn't record the mid-sync change + Assert.equal( + changeCounter, + 1, + "Counter shouldn't be zero because it didn't record update" + ); + + // now, push a new set of changes, which should make the changeCounter 0 + profileStorage.addresses.pushSyncChanges({ + [guid]: { + profile: address, + counter: changeCounter, + modified: address.timeLastModified, + synced: true, + }, + }); + + changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + Assert.equal(changeCounter, 0); +}); + +async function checkingSyncChange(action, callback) { + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == action + ); + await callback(); + let [subject] = await onChanged; + ok( + subject.wrappedJSObject.sourceSync, + "change notification should have source sync" + ); +} + +add_task(async function test_add_sourceSync() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + // Hardcode a guid so that we don't need to generate a dynamic regex + let guid = "aaaaaaaaaaaa"; + let testAddr = Object.assign({ guid, version: 1 }, TEST_ADDRESS_1); + + await checkingSyncChange("add", async () => + profileStorage.addresses.add(testAddr, { sourceSync: true }) + ); + + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, 0); + + await Assert.rejects( + profileStorage.addresses.add({ guid, deleted: true }, { sourceSync: true }), + /Record aaaaaaaaaaaa already exists/ + ); +}); + +add_task(async function test_add_tombstone_sourceSync() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let guid = profileStorage.addresses._generateGUID(); + let testAddr = { guid, deleted: true }; + await checkingSyncChange("add", async () => + profileStorage.addresses.add(testAddr, { sourceSync: true }) + ); + + let added = await findGUID(profileStorage.addresses, guid, { + includeDeleted: true, + }); + ok(added); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); + ok(added.deleted); + + // Adding same record again shouldn't throw (or change anything) + await checkingSyncChange("add", async () => + profileStorage.addresses.add(testAddr, { sourceSync: true }) + ); + + added = await findGUID(profileStorage.addresses, guid, { + includeDeleted: true, + }); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); + ok(added.deleted); +}); + +add_task(async function test_add_resurrects_tombstones() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let guid = profileStorage.addresses._generateGUID(); + + // Add a tombstone. + await profileStorage.addresses.add({ guid, deleted: true }); + + // You can't re-add an item with an explicit GUID. + let resurrected = Object.assign({}, TEST_ADDRESS_1, { guid, version: 1 }); + await Assert.rejects( + profileStorage.addresses.add(resurrected), + /"(guid|version)" is not a valid field/ + ); + + // But Sync can! + let guid3 = await profileStorage.addresses.add(resurrected, { + sourceSync: true, + }); + equal(guid, guid3); + + let got = await profileStorage.addresses.get(guid); + equal(got.name, TEST_ADDRESS_1.name); +}); + +add_task(async function test_remove_sourceSync_localChanges() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + ]); + profileStorage.addresses.pullSyncChanges(); // force sync metadata + + let [{ guid }] = await profileStorage.addresses.getAll(); + + equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); + // try and remove a record stored locally with local changes + await checkingSyncChange("remove", async () => + profileStorage.addresses.remove(guid, { sourceSync: true }) + ); + + let record = await profileStorage.addresses.get(guid); + ok(record); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 1); +}); + +add_task(async function test_remove_sourceSync_unknown() { + // remove a record not stored locally + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let guid = profileStorage.addresses._generateGUID(); + await checkingSyncChange("remove", async () => + profileStorage.addresses.remove(guid, { sourceSync: true }) + ); + + let tombstone = await findGUID(profileStorage.addresses, guid, { + includeDeleted: true, + }); + ok(tombstone.deleted); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); +}); + +add_task(async function test_remove_sourceSync_unchanged() { + // Remove a local record without a change counter. + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let guid = profileStorage.addresses._generateGUID(); + let addr = Object.assign({ guid, version: 1 }, TEST_ADDRESS_1); + // add a record with sourceSync to guarantee changeCounter == 0 + await checkingSyncChange("add", async () => + profileStorage.addresses.add(addr, { sourceSync: true }) + ); + + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); + + await checkingSyncChange("remove", async () => + profileStorage.addresses.remove(guid, { sourceSync: true }) + ); + + let tombstone = await findGUID(profileStorage.addresses, guid, { + includeDeleted: true, + }); + ok(tombstone.deleted); + equal(getSyncChangeCounter(profileStorage.addresses, guid), 0); +}); + +add_task(async function test_pullSyncChanges() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let startAddresses = await profileStorage.addresses.getAll(); + equal(startAddresses.length, 2); + // All should start without sync metadata + for (let { guid } of profileStorage.addresses._store.data.addresses) { + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, -1); + } + profileStorage.addresses.pullSyncChanges(); // force sync metadata + + let addedDirectGUID = profileStorage.addresses._generateGUID(); + let testAddr = Object.assign( + { guid: addedDirectGUID, version: 1 }, + TEST_ADDRESS_1, + TEST_ADDRESS_3 + ); + + await checkingSyncChange("add", async () => + profileStorage.addresses.add(testAddr, { sourceSync: true }) + ); + + let tombstoneGUID = profileStorage.addresses._generateGUID(); + await checkingSyncChange("add", async () => + profileStorage.addresses.add( + { guid: tombstoneGUID, deleted: true }, + { sourceSync: true } + ) + ); + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "remove" + ); + + profileStorage.addresses.remove(startAddresses[0].guid); + await onChanged; + + let addresses = await profileStorage.addresses.getAll({ + includeDeleted: true, + }); + + // Should contain changes with a change counter + let changes = profileStorage.addresses.pullSyncChanges(); + equal(Object.keys(changes).length, 2); + + ok(changes[startAddresses[0].guid].profile.deleted); + equal(changes[startAddresses[0].guid].counter, 2); + + ok(!changes[startAddresses[1].guid].profile.deleted); + equal(changes[startAddresses[1].guid].counter, 1); + + ok( + !changes[tombstoneGUID], + "Missing because it's a tombstone from sourceSync" + ); + ok(!changes[addedDirectGUID], "Missing because it was added with sourceSync"); + + for (let address of addresses) { + let change = changes[address.guid]; + if (!change) { + continue; + } + equal(change.profile.guid, address.guid); + let changeCounter = getSyncChangeCounter( + profileStorage.addresses, + change.profile.guid + ); + equal(change.counter, changeCounter); + ok(!change.synced); + } +}); + +add_task(async function test_pullPushChanges() { + // round-trip changes between pull and push + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + let psa = profileStorage.addresses; + + let guid1 = await psa.add(TEST_ADDRESS_1); + let guid2 = await psa.add(TEST_ADDRESS_2); + let guid3 = await psa.add(TEST_ADDRESS_3); + + let changes = psa.pullSyncChanges(); + + equal(getSyncChangeCounter(psa, guid1), 1); + equal(getSyncChangeCounter(psa, guid2), 1); + equal(getSyncChangeCounter(psa, guid3), 1); + + // between the pull and the push we change the second. + await psa.update(guid2, Object.assign({}, TEST_ADDRESS_2, { country: "AU" })); + equal(getSyncChangeCounter(psa, guid2), 2); + // and update the changeset to indicated we did update the first 2, but failed + // to update the 3rd for some reason. + changes[guid1].synced = true; + changes[guid2].synced = true; + + psa.pushSyncChanges(changes); + + // first was synced correctly. + equal(getSyncChangeCounter(psa, guid1), 0); + // second was synced correctly, but it had a change while syncing. + equal(getSyncChangeCounter(psa, guid2), 1); + // 3rd wasn't marked as having synced. + equal(getSyncChangeCounter(psa, guid3), 1); +}); + +add_task(async function test_changeGUID() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []); + + let newguid = () => profileStorage.addresses._generateGUID(); + + let guid_synced = await profileStorage.addresses.add(TEST_ADDRESS_1); + + // pullSyncChanges so guid_synced is flagged as syncing. + profileStorage.addresses.pullSyncChanges(); + + // and 2 items that haven't been synced. + let guid_u1 = await profileStorage.addresses.add(TEST_ADDRESS_2); + let guid_u2 = await profileStorage.addresses.add(TEST_ADDRESS_3); + + // Change a non-existing guid + Assert.throws( + () => profileStorage.addresses.changeGUID(newguid(), newguid()), + /changeGUID: no source record/ + ); + // Change to a guid that already exists. + Assert.throws( + () => profileStorage.addresses.changeGUID(guid_u1, guid_u2), + /changeGUID: record with destination id exists already/ + ); + // Try and change a guid that's already been synced. + Assert.throws( + () => profileStorage.addresses.changeGUID(guid_synced, newguid()), + /changeGUID: existing record has already been synced/ + ); + + // Change an item to itself makes no sense. + Assert.throws( + () => profileStorage.addresses.changeGUID(guid_u1, guid_u1), + /changeGUID: old and new IDs are the same/ + ); + + // and one that works. + equal( + (await profileStorage.addresses.getAll({ includeDeleted: true })).length, + 3 + ); + let targetguid = newguid(); + profileStorage.addresses.changeGUID(guid_u1, targetguid); + equal( + (await profileStorage.addresses.getAll({ includeDeleted: true })).length, + 3 + ); + + ok( + await profileStorage.addresses.get(guid_synced), + "synced item still exists." + ); + ok( + await profileStorage.addresses.get(guid_u2), + "guid we didn't touch still exists." + ); + ok(await profileStorage.addresses.get(targetguid), "target guid exists."); + ok( + !(await profileStorage.addresses.get(guid_u1)), + "old guid no longer exists." + ); +}); + +add_task(async function test_findDuplicateGUID() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + ]); + + let [record] = await profileStorage.addresses.getAll({ rawData: true }); + await Assert.rejects( + profileStorage.addresses.findDuplicateGUID(record), + /Record \w+ already exists/, + "Should throw if the GUID already exists" + ); + + // Add a malformed record, passing `sourceSync` to work around the record + // normalization logic that would prevent this. + let timeLastModified = Date.now(); + let timeCreated = timeLastModified - 60 * 1000; + + await profileStorage.addresses.add( + { + guid: profileStorage.addresses._generateGUID(), + version: 1, + timeCreated, + timeLastModified, + }, + { sourceSync: true } + ); + + strictEqual( + await profileStorage.addresses.findDuplicateGUID({ + guid: profileStorage.addresses._generateGUID(), + version: 1, + timeCreated, + timeLastModified, + }), + null, + "Should ignore internal fields and malformed records" + ); +}); + +add_task(async function test_reset() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [ + TEST_ADDRESS_1, + TEST_ADDRESS_2, + ]); + + let addresses = await profileStorage.addresses.getAll(); + // All should start without sync metadata + for (let { guid } of addresses) { + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, -1); + } + // pullSyncChanges should create the metadata. + profileStorage.addresses.pullSyncChanges(); + addresses = await profileStorage.addresses.getAll(); + for (let { guid } of addresses) { + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, 1); + } + // and resetSync should wipe it. + profileStorage.addresses.resetSync(); + addresses = await profileStorage.addresses.getAll(); + for (let { guid } of addresses) { + let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid); + equal(changeCounter, -1); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_storage_tombstones.js b/browser/extensions/formautofill/test/unit/test_storage_tombstones.js new file mode 100644 index 0000000000..584dac8043 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_storage_tombstones.js @@ -0,0 +1,190 @@ +/** + * Tests tombstones in address/creditcard records. + */ + +"use strict"; + +let FormAutofillStorage; +add_setup(async () => { + ({ FormAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + )); +}); + +const TEST_STORE_FILE_NAME = "test-tombstones.json"; + +const TEST_ADDRESS_1 = { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+1 617 253 5702", + email: "timbl@w3.org", +}; + +const TEST_CC_1 = { + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": 2017, +}; + +let do_check_tombstone_record = profile => { + Assert.ok(profile.deleted); + Assert.deepEqual( + Object.keys(profile).sort(), + ["guid", "timeLastModified", "deleted"].sort() + ); +}; + +// Like add_task, but actually adds 2 - one for addresses and one for cards. +function add_storage_task(test_function) { + add_task(async function () { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + let profileStorage = new FormAutofillStorage(path); + let testCC1 = Object.assign({}, TEST_CC_1); + await profileStorage.initialize(); + + for (let [storage, record] of [ + [profileStorage.addresses, TEST_ADDRESS_1], + [profileStorage.creditCards, testCC1], + ]) { + await test_function(storage, record); + } + }); +} + +add_storage_task(async function test_simple_tombstone(storage, record) { + info("check simple tombstone semantics"); + + let guid = await storage.add(record); + Assert.equal((await storage.getAll()).length, 1); + + storage.remove(guid); + + // should be unable to get it normally. + Assert.equal(await storage.get(guid), null); + // and getAll should also not return it. + Assert.equal((await storage.getAll()).length, 0); + + // but getAll allows us to access deleted items - but we didn't create + // a tombstone here, so even that will not get it. + let all = await storage.getAll({ includeDeleted: true }); + Assert.equal(all.length, 0); +}); + +add_storage_task(async function test_simple_synctombstone(storage, record) { + info("check simple tombstone semantics for synced records"); + + let guid = await storage.add(record); + Assert.equal((await storage.getAll()).length, 1); + + storage.pullSyncChanges(); // force sync metadata, which triggers tombstone behaviour. + + storage.remove(guid); + + // should be unable to get it normally. + Assert.equal(await storage.get(guid), null); + // and getAll should also not return it. + Assert.equal((await storage.getAll()).length, 0); + + // but getAll allows us to access deleted items. + let all = await storage.getAll({ includeDeleted: true }); + Assert.equal(all.length, 1); + + do_check_tombstone_record(all[0]); + + // a tombstone got from API should look exactly the same as it got from the + // disk (besides "_sync"). + let tombstoneInDisk = Object.assign( + {}, + storage._store.data[storage._collectionName][0] + ); + delete tombstoneInDisk._sync; + do_check_tombstone_record(tombstoneInDisk); +}); + +add_storage_task(async function test_add_tombstone(storage, record) { + info("Should be able to add a new tombstone"); + let guid = await storage.add({ guid: "test-guid-1", deleted: true }); + + // should be unable to get it normally. + Assert.equal(await storage.get(guid), null); + // and getAll should also not return it. + Assert.equal((await storage.getAll()).length, 0); + + // but getAll allows us to access deleted items. + let all = await storage.getAll({ rawData: true, includeDeleted: true }); + Assert.equal(all.length, 1); + + do_check_tombstone_record(all[0]); + + // a tombstone got from API should look exactly the same as it got from the + // disk (besides "_sync"). + let tombstoneInDisk = Object.assign( + {}, + storage._store.data[storage._collectionName][0] + ); + delete tombstoneInDisk._sync; + do_check_tombstone_record(tombstoneInDisk); +}); + +add_storage_task(async function test_add_tombstone_without_guid( + storage, + record +) { + info("Should not be able to add a new tombstone without specifying the guid"); + await Assert.rejects(storage.add({ deleted: true }), /Record missing GUID/); + Assert.equal((await storage.getAll({ includeDeleted: true })).length, 0); +}); + +add_storage_task(async function test_add_tombstone_existing_guid( + storage, + record +) { + info( + "Should not be able to add a new tombstone when a record with that ID exists" + ); + let guid = await storage.add(record); + await Assert.rejects( + storage.add({ guid, deleted: true }), + /a record with this GUID already exists/ + ); + + // same if the existing item is already a tombstone. + await storage.add({ guid: "test-guid-1", deleted: true }); + await Assert.rejects( + storage.add({ guid: "test-guid-1", deleted: true }), + /a record with this GUID already exists/ + ); +}); + +add_storage_task(async function test_update_tombstone(storage, record) { + info("Updating a tombstone should fail"); + let guid = await storage.add({ guid: "test-guid-1", deleted: true }); + await Assert.rejects(storage.update(guid, {}), /No matching record./); +}); + +add_storage_task(async function test_remove_existing_tombstone( + storage, + record +) { + info("Removing a record that's already a tombstone should be a no-op"); + let guid = await storage.add({ + guid: "test-guid-1", + deleted: true, + timeLastModified: 1234, + }); + + storage.remove(guid); + let all = await storage.getAll({ rawData: true, includeDeleted: true }); + Assert.equal(all.length, 1); + + do_check_tombstone_record(all[0]); + equal(all[0].timeLastModified, 1234); // should not be updated to now(). +}); diff --git a/browser/extensions/formautofill/test/unit/test_sync.js b/browser/extensions/formautofill/test/unit/test_sync.js new file mode 100644 index 0000000000..e2c04c78e5 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_sync.js @@ -0,0 +1,1008 @@ +/** + * Tests sync functionality. + */ + +/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */ +/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */ +/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */ +/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */ + +"use strict"; + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); +const { SCORE_INCREMENT_XLARGE } = ChromeUtils.importESModule( + "resource://services-sync/constants.sys.mjs" +); + +const { sanitizeStorageObject, AutofillRecord, AddressesEngine } = + ChromeUtils.importESModule("resource://autofill/FormAutofillSync.sys.mjs"); + +Services.prefs.setCharPref("extensions.formautofill.loglevel", "Trace"); +initTestLogging("Trace"); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const TEST_PROFILE_1 = { + name: "Timothy John Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+16172535702", + email: "timbl@w3.org", + // A field this client doesn't "understand" from another client + "unknown-1": "some unknown data from another client", +}; + +const TEST_PROFILE_2 = { + "street-address": "Some Address", + country: "US", +}; + +async function expectLocalProfiles(profileStorage, expected) { + let profiles = await profileStorage.addresses.getAll({ + rawData: true, + includeDeleted: true, + }); + expected.sort((a, b) => a.guid.localeCompare(b.guid)); + profiles.sort((a, b) => a.guid.localeCompare(b.guid)); + try { + deepEqual( + profiles.map(p => p.guid), + expected.map(p => p.guid) + ); + for (let i = 0; i < expected.length; i++) { + let thisExpected = expected[i]; + let thisGot = profiles[i]; + // always check "deleted". + equal(thisExpected.deleted, thisGot.deleted); + ok(objectMatches(thisGot, thisExpected)); + } + } catch (ex) { + info("Comparing expected profiles:"); + info(JSON.stringify(expected, undefined, 2)); + info("against actual profiles:"); + info(JSON.stringify(profiles, undefined, 2)); + throw ex; + } +} + +async function setup() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + // should always start with no profiles. + Assert.equal( + (await profileStorage.addresses.getAll({ includeDeleted: true })).length, + 0 + ); + + Services.prefs.setCharPref( + "services.sync.log.logger.engine.addresses", + "Trace" + ); + let engine = new AddressesEngine(Service); + await engine.initialize(); + // Avoid accidental automatic sync due to our own changes + Service.scheduler.syncThreshold = 10000000; + let syncID = await engine.resetLocalSyncID(); + let server = serverForUsers( + { foo: "password" }, + { + meta: { + global: { engines: { addresses: { version: engine.version, syncID } } }, + }, + addresses: {}, + } + ); + + Service.engineManager._engines.addresses = engine; + engine.enabled = true; + engine._store._storage = profileStorage.addresses; + + generateNewKeys(Service.collectionKeys); + + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("addresses"); + + return { profileStorage, server, collection, engine }; +} + +async function cleanup(server) { + let promiseStartOver = promiseOneObserver("weave:service:start-over:finish"); + await Service.startOver(); + await promiseStartOver; + await promiseStopServer(server); +} + +add_task(async function test_log_sanitization() { + let sanitized = sanitizeStorageObject(TEST_PROFILE_1); + // all strings have been mangled. + for (let key of Object.keys(TEST_PROFILE_1)) { + let val = TEST_PROFILE_1[key]; + if (typeof val == "string") { + notEqual(sanitized[key], val); + } + } + // And check that stringifying a sync record is sanitized. + let record = new AutofillRecord("collection", "some-id"); + record.entry = TEST_PROFILE_1; + let serialized = record.toString(); + // None of the string values should appear in the output. + for (let key of Object.keys(TEST_PROFILE_1)) { + let val = TEST_PROFILE_1[key]; + if (typeof val == "string") { + ok(!serialized.includes(val), `"${val}" shouldn't be in: ${serialized}`); + } + } +}); + +add_task(async function test_outgoing() { + let { profileStorage, server, collection, engine } = await setup(); + try { + equal(engine._tracker.score, 0); + let existingGUID = await profileStorage.addresses.add(TEST_PROFILE_1); + // And a deleted item. + let deletedGUID = profileStorage.addresses._generateGUID(); + await profileStorage.addresses.add({ guid: deletedGUID, deleted: true }); + + await expectLocalProfiles(profileStorage, [ + { + guid: existingGUID, + }, + { + guid: deletedGUID, + deleted: true, + }, + ]); + + await engine._tracker.asyncObserver.promiseObserversComplete(); + // The tracker should have a score recorded for the 2 additions we had. + equal(engine._tracker.score, SCORE_INCREMENT_XLARGE * 2); + + await engine.setLastSync(0); + await engine.sync(); + + Assert.equal(collection.count(), 2); + Assert.ok(collection.wbo(existingGUID)); + Assert.ok(collection.wbo(deletedGUID)); + + await expectLocalProfiles(profileStorage, [ + { + guid: existingGUID, + }, + { + guid: deletedGUID, + deleted: true, + }, + ]); + + strictEqual( + getSyncChangeCounter(profileStorage.addresses, existingGUID), + 0 + ); + strictEqual(getSyncChangeCounter(profileStorage.addresses, deletedGUID), 0); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_incoming_new() { + let { profileStorage, server, engine } = await setup(); + try { + let profileID = Utils.makeGUID(); + let deletedID = Utils.makeGUID(); + + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + profileID, + encryptPayload({ + id: profileID, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + getDateForSync() + ) + ); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + deletedID, + encryptPayload({ + id: deletedID, + deleted: true, + }), + getDateForSync() + ) + ); + + // The tracker should start with no score. + equal(engine._tracker.score, 0); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: profileID, + }, + { + guid: deletedID, + deleted: true, + }, + ]); + + strictEqual(getSyncChangeCounter(profileStorage.addresses, profileID), 0); + strictEqual(getSyncChangeCounter(profileStorage.addresses, deletedID), 0); + + // Validate incoming records with unknown fields get stored + let localRecord = await profileStorage.addresses.get(profileID); + equal(localRecord["unknown-1"], TEST_PROFILE_1["unknown-1"]); + + // The sync applied new records - ensure our tracker knew it came from + // sync and didn't bump the score. + equal(engine._tracker.score, 0); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_incoming_existing() { + let { profileStorage, server, engine } = await setup(); + try { + let guid1 = await profileStorage.addresses.add(TEST_PROFILE_1); + let guid2 = await profileStorage.addresses.add(TEST_PROFILE_2); + + // an initial sync so we don't think they are locally modified. + await engine.setLastSync(0); + await engine.sync(); + + // now server records that modify the existing items. + let modifiedEntry1 = Object.assign({}, TEST_PROFILE_1, { + version: 1, + name: "NewName", + }); + + let lastSync = await engine.getLastSync(); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid1, + encryptPayload({ + id: guid1, + entry: modifiedEntry1, + }), + lastSync + 10 + ) + ); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid2, + encryptPayload({ + id: guid2, + deleted: true, + }), + lastSync + 10 + ) + ); + + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + Object.assign({}, modifiedEntry1, { guid: guid1 }), + { guid: guid2, deleted: true }, + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_tombstones() { + let { profileStorage, server, collection, engine } = await setup(); + try { + let existingGUID = await profileStorage.addresses.add(TEST_PROFILE_1); + + await engine.setLastSync(0); + await engine.sync(); + + Assert.equal(collection.count(), 1); + let payload = collection.payloads()[0]; + equal(payload.id, existingGUID); + equal(payload.deleted, undefined); + + profileStorage.addresses.remove(existingGUID); + await engine.sync(); + + // should still exist, but now be a tombstone. + Assert.equal(collection.count(), 1); + payload = collection.payloads()[0]; + equal(payload.id, existingGUID); + equal(payload.deleted, true); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_both_deleted() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + await engine.setLastSync(0); + await engine.sync(); + + // Delete synced record locally. + profileStorage.addresses.remove(guid); + + // Delete same record remotely. + let lastSync = await engine.getLastSync(); + let collection = server.user("foo").collection("addresses"); + collection.insert( + guid, + encryptPayload({ + id: guid, + deleted: true, + }), + lastSync + 10 + ); + + await engine.sync(); + + ok( + !(await await profileStorage.addresses.get(guid)), + "Should not return record for locally deleted item" + ); + + let localRecords = await profileStorage.addresses.getAll({ + includeDeleted: true, + }); + equal(localRecords.length, 1, "Only tombstone should exist locally"); + + equal(collection.count(), 1, "Only tombstone should exist on server"); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_nonexistent_tombstone() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = profileStorage.addresses._generateGUID(); + let collection = server.user("foo").collection("addresses"); + collection.insert( + guid, + encryptPayload({ + id: guid, + deleted: true, + }), + getDateForSync() + ); + + await engine.setLastSync(0); + await engine.sync(); + + ok( + !(await profileStorage.addresses.get(guid)), + "Should not return record for uknown deleted item" + ); + let localTombstone = ( + await profileStorage.addresses.getAll({ + includeDeleted: true, + }) + ).find(record => record.guid == guid); + ok(localTombstone, "Should store tombstone for unknown item"); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_incoming_deleted() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + await engine.setLastSync(0); + await engine.sync(); + + // Delete the record remotely. + let lastSync = await engine.getLastSync(); + let collection = server.user("foo").collection("addresses"); + collection.insert( + guid, + encryptPayload({ + id: guid, + deleted: true, + }), + lastSync + 10 + ); + + await engine.sync(); + + ok( + !(await profileStorage.addresses.get(guid)), + "Should delete unmodified item locally" + ); + + let localTombstone = ( + await profileStorage.addresses.getAll({ + includeDeleted: true, + }) + ).find(record => record.guid == guid); + ok(localTombstone, "Should keep local tombstone for remotely deleted item"); + strictEqual( + getSyncChangeCounter(profileStorage.addresses, guid), + 0, + "Local tombstone should be marked as syncing" + ); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_incoming_restored() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // Upload the record to the server. + await engine.setLastSync(0); + await engine.sync(); + + // Removing a synced record should write a tombstone. + profileStorage.addresses.remove(guid); + + // Modify the deleted record remotely. + let collection = server.user("foo").collection("addresses"); + let serverPayload = JSON.parse( + JSON.parse(collection.payload(guid)).ciphertext + ); + serverPayload.entry["street-address"] = "I moved!"; + let lastSync = await engine.getLastSync(); + collection.insert(guid, encryptPayload(serverPayload), lastSync + 10); + + // Sync again. + await engine.sync(); + + // We should replace our tombstone with the server's version. + let localRecord = await profileStorage.addresses.get(guid); + ok( + objectMatches(localRecord, { + name: "Timothy John Berners-Lee", + "street-address": "I moved!", + }) + ); + + let maybeNewServerPayload = JSON.parse( + JSON.parse(collection.payload(guid)).ciphertext + ); + deepEqual( + maybeNewServerPayload, + serverPayload, + "Should not change record on server" + ); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_applyIncoming_outgoing_restored() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // Upload the record to the server. + await engine.setLastSync(0); + await engine.sync(); + + // Modify the local record. + let localCopy = Object.assign({}, TEST_PROFILE_1); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + // Replace the record with a tombstone on the server. + let lastSync = await engine.getLastSync(); + let collection = server.user("foo").collection("addresses"); + collection.insert( + guid, + encryptPayload({ + id: guid, + deleted: true, + }), + lastSync + 10 + ); + + // Sync again. + await engine.sync(); + + // We should resurrect the record on the server. + let serverPayload = JSON.parse( + JSON.parse(collection.payload(guid)).ciphertext + ); + ok(!serverPayload.deleted, "Should resurrect record on server"); + ok( + objectMatches(serverPayload.entry, { + name: "Timothy John Berners-Lee", + "street-address": "I moved!", + // resurrection also beings back any unknown fields we had + "unknown-1": "some unknown data from another client", + }) + ); + + let localRecord = await profileStorage.addresses.get(guid); + ok(localRecord, "Modified record should not be deleted locally"); + } finally { + await cleanup(server); + } +}); + +// Unlike most sync engines, we want "both modified" to inspect the records, +// and if materially different, create a duplicate. +add_task(async function test_reconcile_both_modified_identical() { + let { profileStorage, server, engine } = await setup(); + try { + // create a record locally. + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // and an identical record on the server. + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid, + encryptPayload({ + id: guid, + entry: TEST_PROFILE_1, + }), + getDateForSync() + ) + ); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [{ guid }]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_incoming_dupes() { + let { profileStorage, server, engine } = await setup(); + try { + // Create a profile locally, then sync to upload the new profile to the + // server. + let guid1 = await profileStorage.addresses.add(TEST_PROFILE_1); + + await engine.setLastSync(0); + await engine.sync(); + + // Create another profile locally, but don't sync it yet. + await profileStorage.addresses.add(TEST_PROFILE_2); + + // Now create two records on the server with the same contents as our local + // profiles, but different GUIDs. + let lastSync = await engine.getLastSync(); + let guid1_dupe = Utils.makeGUID(); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid1_dupe, + encryptPayload({ + id: guid1_dupe, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + lastSync + 10 + ) + ); + let guid2_dupe = Utils.makeGUID(); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + guid2_dupe, + encryptPayload({ + id: guid2_dupe, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_2 + ), + }), + lastSync + 10 + ) + ); + + // Sync again. We should download `guid1_dupe` and `guid2_dupe`, then + // reconcile changes. + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + // We uploaded `guid1` during the first sync. Even though its contents + // are the same as `guid1_dupe`, we keep both. + Object.assign({}, TEST_PROFILE_1, { guid: guid1 }), + Object.assign({}, TEST_PROFILE_1, { guid: guid1_dupe }), + // However, we didn't upload `guid2` before downloading `guid2_dupe`, so + // we *should* dedupe `guid2` to `guid2_dupe`. + Object.assign({}, TEST_PROFILE_2, { guid: guid2_dupe }), + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_dedupe_identical_unsynced() { + let { profileStorage, server, engine } = await setup(); + try { + // create a record locally. + let localGuid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // and an identical record on the server but different GUID. + let remoteGuid = Utils.makeGUID(); + notEqual(localGuid, remoteGuid); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + remoteGuid, + encryptPayload({ + id: remoteGuid, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + getDateForSync() + ) + ); + + await engine.setLastSync(0); + await engine.sync(); + + // Should have 1 item locally with GUID changed to the remote one. + // There's no tombstone as the original was unsynced. + await expectLocalProfiles(profileStorage, [ + { + guid: remoteGuid, + }, + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_dedupe_identical_synced() { + let { profileStorage, server, engine } = await setup(); + try { + // create a record locally. + let localGuid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // sync it - it will no longer be a candidate for de-duping. + await engine.setLastSync(0); + await engine.sync(); + + // and an identical record on the server but different GUID. + let lastSync = await engine.getLastSync(); + let remoteGuid = Utils.makeGUID(); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + remoteGuid, + encryptPayload({ + id: remoteGuid, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + lastSync + 10 + ) + ); + + await engine.sync(); + + // Should have 2 items locally, since the first was synced. + await expectLocalProfiles(profileStorage, [ + { guid: localGuid }, + { guid: remoteGuid }, + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_dedupe_multiple_candidates() { + let { profileStorage, server, engine } = await setup(); + try { + // It's possible to have duplicate local profiles, with the same fields but + // different GUIDs. After a node reassignment, or after disconnecting and + // reconnecting to Sync, we might dedupe a local record A to a remote record + // B, if we see B before we download and apply A. Since A and B are dupes, + // that's OK. We'll write a tombstone for A when we dedupe A to B, and + // overwrite that tombstone when we see A. + + let localRecord = { + name: "Mark Hammond", + organization: "Mozilla", + country: "AU", + tel: "+12345678910", + }; + let serverRecord = Object.assign( + { + version: 1, + }, + localRecord + ); + + // We don't pass `sourceSync` so that the records are marked as NEW. + let aGuid = await profileStorage.addresses.add(localRecord); + let bGuid = await profileStorage.addresses.add(localRecord); + + // Insert B before A. + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + bGuid, + encryptPayload({ + id: bGuid, + entry: serverRecord, + }), + getDateForSync() + ) + ); + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + aGuid, + encryptPayload({ + id: aGuid, + entry: serverRecord, + }), + getDateForSync() + ) + ); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: aGuid, + name: "Mark Hammond", + organization: "Mozilla", + country: "AU", + tel: "+12345678910", + }, + { + guid: bGuid, + name: "Mark Hammond", + organization: "Mozilla", + country: "AU", + tel: "+12345678910", + }, + ]); + // Make sure these are both syncing. + strictEqual( + getSyncChangeCounter(profileStorage.addresses, aGuid), + 0, + "A should be marked as syncing" + ); + strictEqual( + getSyncChangeCounter(profileStorage.addresses, bGuid), + 0, + "B should be marked as syncing" + ); + } finally { + await cleanup(server); + } +}); + +// Unlike most sync engines, we want "both modified" to inspect the records, +// and if materially different, create a duplicate. +add_task(async function test_reconcile_both_modified_conflict() { + let { profileStorage, server, engine } = await setup(); + try { + // create a record locally. + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + // Upload the record to the server. + await engine.setLastSync(0); + await engine.sync(); + + strictEqual( + getSyncChangeCounter(profileStorage.addresses, guid), + 0, + "Original record should be marked as syncing" + ); + + // Change the same field locally and on the server. + let localCopy = Object.assign({}, TEST_PROFILE_1); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + let lastSync = await engine.getLastSync(); + let collection = server.user("foo").collection("addresses"); + let serverPayload = JSON.parse( + JSON.parse(collection.payload(guid)).ciphertext + ); + serverPayload.entry["street-address"] = "I moved, too!"; + collection.insert(guid, encryptPayload(serverPayload), lastSync + 10); + + // Sync again. + await engine.sync(); + + // Since we wait to pull changes until we're ready to upload, both records + // should now exist on the server; we don't need a follow-up sync. + let serverPayloads = collection.payloads(); + equal(serverPayloads.length, 2, "Both records should exist on server"); + + let forkedPayload = serverPayloads.find(payload => payload.id != guid); + ok(forkedPayload, "Forked record should exist on server"); + + await expectLocalProfiles(profileStorage, [ + { + guid, + name: "Timothy John Berners-Lee", + "street-address": "I moved, too!", + }, + { + guid: forkedPayload.id, + name: "Timothy John Berners-Lee", + "street-address": "I moved!", + }, + ]); + + let changeCounter = getSyncChangeCounter( + profileStorage.addresses, + forkedPayload.id + ); + strictEqual(changeCounter, 0, "Forked record should be marked as syncing"); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_wipe() { + let { profileStorage, server, engine } = await setup(); + try { + let guid = await profileStorage.addresses.add(TEST_PROFILE_1); + + await expectLocalProfiles(profileStorage, [{ guid }]); + + let promiseObserved = promiseOneObserver("formautofill-storage-changed"); + + await engine._wipeClient(); + + let { subject, data } = await promiseObserved; + Assert.equal( + subject.wrappedJSObject.sourceSync, + true, + "it should be noted this came from sync" + ); + Assert.equal( + subject.wrappedJSObject.collectionName, + "addresses", + "got the correct collection" + ); + Assert.equal(data, "removeAll", "a removeAll should be noted"); + + await expectLocalProfiles(profileStorage, []); + } finally { + await cleanup(server); + } +}); + +// Other clients might have data that we aren't able to process/understand yet +// We should keep that data and ensure when we sync we don't lose that data +add_task(async function test_full_roundtrip_unknown_data() { + let { profileStorage, server, engine } = await setup(); + try { + let profileID = Utils.makeGUID(); + + info("Incoming records with unknown fields are properly stored"); + // Insert a record onto the server + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + profileID, + encryptPayload({ + id: profileID, + entry: Object.assign( + { + version: 1, + }, + TEST_PROFILE_1 + ), + }), + getDateForSync() + ) + ); + + // The tracker should start with no score. + equal(engine._tracker.score, 0); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: profileID, + }, + ]); + + strictEqual(getSyncChangeCounter(profileStorage.addresses, profileID), 0); + + // The sync applied new records - ensure our tracker knew it came from + // sync and didn't bump the score. + equal(engine._tracker.score, 0); + + // Validate incoming records with unknown fields are correctly stored + let localRecord = await profileStorage.addresses.get(profileID); + equal(localRecord["unknown-1"], TEST_PROFILE_1["unknown-1"]); + + let onChanged = TestUtils.topicObserved( + "formautofill-storage-changed", + (subject, data) => data == "update" + ); + + // Validate we can update the records locally and not drop any unknown fields + info("Unknown fields are sent back up to the server"); + + // Modify the local copy + let localCopy = Object.assign({}, TEST_PROFILE_1); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(profileID, localCopy); + await onChanged; + await profileStorage._saveImmediately(); + + let updatedCopy = await profileStorage.addresses.get(profileID); + equal(updatedCopy["street-address"], "I moved!"); + + // Sync our changes to the server + await engine.setLastSync(0); + await engine.sync(); + + let collection = server.user("foo").collection("addresses"); + + Assert.ok(collection.wbo(profileID)); + let serverPayload = JSON.parse( + JSON.parse(collection.payload(profileID)).ciphertext + ); + + // The server has the updated field as well as any unknown fields + equal( + serverPayload.entry["unknown-1"], + "some unknown data from another client" + ); + equal(serverPayload.entry["street-address"], "I moved!"); + } finally { + await cleanup(server); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_sync_deprecate_address_x_name_fields.js b/browser/extensions/formautofill/test/unit/test_sync_deprecate_address_x_name_fields.js new file mode 100644 index 0000000000..f30a708709 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_sync_deprecate_address_x_name_fields.js @@ -0,0 +1,1100 @@ +/** + * Tests sync functionality. + */ + +/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */ +/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */ +/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */ +/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */ + +"use strict"; + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const { AddressesEngine } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillSync.sys.mjs" +); + +Services.prefs.setCharPref("extensions.formautofill.loglevel", "Debug"); +initTestLogging("Trace"); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +function expectProfiles(profiles, expected) { + expected.sort((a, b) => a.guid.localeCompare(b.guid)); + profiles.sort((a, b) => a.guid.localeCompare(b.guid)); + try { + deepEqual( + profiles.map(p => p.guid), + expected.map(p => p.guid) + ); + for (let i = 0; i < expected.length; i++) { + let thisExpected = expected[i]; + let thisGot = profiles[i]; + // always check "deleted". + equal(thisExpected.deleted, thisGot.deleted); + ok(objectMatches(thisGot, thisExpected)); + } + } catch (ex) { + info("Comparing expected profiles:"); + info(JSON.stringify(expected, undefined, 2)); + info("against actual profiles:"); + info(JSON.stringify(profiles, undefined, 2)); + throw ex; + } +} + +async function expectServerProfiles(collection, expected) { + const profiles = collection + .payloads() + .map(payload => Object.assign({ guid: payload.id }, payload.entry)); + expectProfiles(profiles, expected); +} + +async function expectLocalProfiles(profileStorage, expected) { + const profiles = await profileStorage.addresses.getAll({ + rawData: true, + includeDeleted: true, + }); + expectProfiles(profiles, expected); +} + +async function setup() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + // should always start with no profiles. + Assert.equal( + (await profileStorage.addresses.getAll({ includeDeleted: true })).length, + 0 + ); + + Services.prefs.setCharPref( + "services.sync.log.logger.engine.addresses", + "Trace" + ); + let engine = new AddressesEngine(Service); + await engine.initialize(); + // Avoid accidental automatic sync due to our own changes + Service.scheduler.syncThreshold = 10000000; + let syncID = await engine.resetLocalSyncID(); + let server = serverForUsers( + { foo: "password" }, + { + meta: { + global: { + engines: { addresses: { version: engine.version, syncID } }, + }, + }, + addresses: {}, + } + ); + + Service.engineManager._engines.addresses = engine; + engine.enabled = true; + engine._store._storage = profileStorage.addresses; + + generateNewKeys(Service.collectionKeys); + + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("addresses"); + + return { profileStorage, server, collection, engine }; +} + +async function cleanup(server) { + let promiseStartOver = promiseOneObserver("weave:service:start-over:finish"); + await Service.startOver(); + await promiseStartOver; + await promiseStopServer(server); +} + +function setupServerRecords(server, records) { + for (const record of records) { + server.insertWBO( + "foo", + "addresses", + new ServerWBO( + record.guid, + encryptPayload({ + id: record.guid, + entry: Object.assign({}, record), + }), + getDateForSync() + ) + ); + } +} + +function assertNumberRecordUploadedBySync(engine, expectedNumber) { + const uploadOutgoing = engine._uploadOutgoing; + engine._uploadOutgoing = async function () { + engine._uploadOutgoing = uploadOutgoing; + try { + await uploadOutgoing.call(this); + } finally { + Assert.equal(this._modified.ids().length, expectedNumber); + } + }; +} + +/** + * The following tests test uploading. Ensure `given-name`, `additional-name`, and `family-name` fields are included + * in the sync payload. + */ + +add_task(async function test_local_upload() { + const { collection, profileStorage, server, engine } = await setup(); + + try { + const localGuid = await profileStorage.addresses.add({ + name: "Mr John William Doe", + "street-address": "Some Address", + }); + + await engine.setLastSync(0); + await engine.sync(); + + await expectServerProfiles(collection, [ + { + guid: localGuid, + version: 1, + name: "Mr John William Doe", + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + "street-address": "Some Address", + }, + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_local_upload_no_addtional_name() { + const { collection, profileStorage, server, engine } = await setup(); + + try { + const localGuid = await profileStorage.addresses.add({ + name: "Timothy Berners-Lee", + "street-address": "Some Address", + }); + + await engine.setLastSync(0); + await engine.sync(); + + await expectServerProfiles(collection, [ + { + guid: localGuid, + version: 1, + name: "Timothy Berners-Lee", + "given-name": "Timothy", + "additional-name": undefined, + "family-name": "Berners-Lee", + "street-address": "Some Address", + }, + ]); + } finally { + await cleanup(server); + } +}); + +add_task(async function test_local_upload_no_name() { + const { collection, profileStorage, server, engine } = await setup(); + + try { + const localGuid = await profileStorage.addresses.add({ + "street-address": "Some Address", + }); + + await engine.setLastSync(0); + await engine.sync(); + + await expectServerProfiles(collection, [ + { + guid: localGuid, + version: 1, + name: undefined, + "given-name": undefined, + "additional-name": undefined, + "family-name": undefined, + "street-address": "Some Address", + }, + ]); + } finally { + await cleanup(server); + } +}); + +/** + * The following tasks test cases where no matching local record is found while applying an + * incoming record. We test: + * 1. An incoming record with `name` field and `*-name` fields, indicating this is a new record. + * 2. An incoming record with only `*-name` fields, indicating this is an old record. + * 3. An incoming record without any name fields, leaving it unknown whether this record comes + * from a new or an old device. + * + * The expected result for each task is: + * 1. The `name` field should NOT be rebuilt based on the deprecated `*-name` fields. + * 2. The `name` field should be rebuilt based on the deprecated `*-name` fields. + * 3. All the name related fields should remain empty. + */ + +// 1. Remote record is a new record +add_task(async function test_apply_incoming() { + const { profileStorage, server, engine } = await setup(); + + try { + setupServerRecords(server, [ + { + guid: "86d961c7717a", + version: 1, + name: "Mr. John William Doe", + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + organization: "Mozilla", + }, + ]); + + assertNumberRecordUploadedBySync(engine, 0); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: "86d961c7717a", + version: 1, + name: "Mr. John William Doe", // Prefix `Mr.` remains + organization: "Mozilla", + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 2. Remote record is an old record +add_task(async function test_apply_incoming_legacy() { + const { profileStorage, server, engine } = await setup(); + + try { + setupServerRecords(server, [ + { + guid: "86d961c7717a", + version: 1, + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + organization: "Mozilla", + }, + ]); + + assertNumberRecordUploadedBySync(engine, 0); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: "86d961c7717a", + version: 1, + name: "John William Doe", // rebuild name field based on `*-name` fields + organization: "Mozilla", + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 3. Remote record does not have `name` +add_task(async function test_apply_incoming_without_name() { + const { profileStorage, server, engine } = await setup(); + + try { + setupServerRecords(server, [ + { + guid: "86d961c7717a", + version: 1, + "street-address": "Some Address", + }, + ]); + + assertNumberRecordUploadedBySync(engine, 0); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: "86d961c7717a", + version: 1, + "street-address": "Some Address", + name: undefined, + "given-name": undefined, + "additional-name": undefined, + "family-name": undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +/** + * The following tasks test cases where a matching local record is found by matching GUID while applying an + * incoming record. We test: + * 1. An incoming record with `name` field and `*-name` fields, indicating this is a new record. + * 2. An incoming record with only `*-name` fields, indicating this is an old record, `*-name` is not updated + * 3. An incoming record with only `*-name` fields, indicating this is an old record, `*-name` is updated + * 4. An incoming record with only `*-name` fields, indicating this is an old record. Local record does not have `name`. + * 5. An incoming record without any name fields, leaving it unknown whether this record comes + * from a new or an old device. + * + * The expected result for each task is: + * 1. The `name` field of the local record should be replaced by the remote record. + * 2. The `name` field of the local record should NOT be replaced by the remote record, `name` remains the same. + * 3. The `name` field of the local record should be replaced by the remote record. + * 4. The `name` field of the local record should be replaced by the remote record. + * 5. The `name` field of the local record should be replaced by the remote record. + */ + +// 1. Remote record is a new record +add_task(async function test_apply_incoming_repalce() { + const { profileStorage, server, engine } = await setup(); + + try { + // Setup the local record and sync the record to sync + const localGuid = await profileStorage.addresses.add({ + name: "Mr John William Doe", + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }); + await engine.setLastSync(0); + await engine.sync(); + + // Remote record is updated by a new device + setupServerRecords(server, [ + { + guid: localGuid, + version: 1, + name: "Dr Timothy Berners Lee", + "given-name": "Timothy", + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "32 Vassar Street", // updated + organization: "Mozilla", // added + email: undefined, // removed + }, + ]); + + assertNumberRecordUploadedBySync(engine, 0); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: localGuid, + version: 1, + name: "Dr Timothy Berners Lee", // Should be replaced! + "street-address": "32 Vassar Street", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 2. Remote record is an old record, `*-name` is not updated +add_task(async function test_apply_incoming_legacy_replace_name_is_updated() { + const { profileStorage, server, engine } = await setup(); + + try { + // Setup the local record and sync the record to sync + const localGuid = await profileStorage.addresses.add({ + name: "Mr John William Doe", + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }); + await engine.setLastSync(0); + await engine.sync(); + + // Remote record is updated by an old device + setupServerRecords(server, [ + { + guid: localGuid, + version: 1, + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + "street-address": "32 Vassar Street", // updated + organization: "Mozilla", // added + email: undefined, // removed + }, + ]); + + assertNumberRecordUploadedBySync(engine, 0); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: localGuid, + version: 1, + name: "Mr John William Doe", // Shoult not be replaced! + "street-address": "32 Vassar Street", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 3. Remote record is an old record, `*-name` is updated +add_task( + async function test_apply_incoming_legacy_replace_name_is_not_updated() { + const { profileStorage, server, engine } = await setup(); + + try { + // Setup the local record and sync the record to sync + const localGuid = await profileStorage.addresses.add({ + name: "Mr John William Doe", + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }); + await engine.setLastSync(0); + await engine.sync(); + + // Remote record is updated by an old device + setupServerRecords(server, [ + { + guid: localGuid, + version: 1, + "given-name": "Timothy", + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "32 Vassar Street", // updated + organization: "Mozilla", // added + email: undefined, // removed + }, + ]); + + assertNumberRecordUploadedBySync(engine, 0); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: localGuid, + version: 1, + name: "Timothy Berners Lee", // Shoult be replaced! + "street-address": "32 Vassar Street", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } + } +); + +// 4. Remote record is an old record. Local record does not have `name` +add_task( + async function test_apply_incoming_legacy_repalce_local_without_name() { + const { profileStorage, server, engine } = await setup(); + + try { + // Setup the local record and sync the record to sync + const localGuid = await profileStorage.addresses.add({ + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }); + await engine.setLastSync(0); + await engine.sync(); + + // Remote record is updated by an old device + setupServerRecords(server, [ + { + guid: localGuid, + version: 1, + "given-name": "Timothy", + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "32 Vassar Street", // updated + organization: "Mozilla", // added + email: undefined, // removed + }, + ]); + + assertNumberRecordUploadedBySync(engine, 0); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: localGuid, + version: 1, + name: "Timothy Berners Lee", // Shoult be replaced! + "street-address": "32 Vassar Street", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } + } +); + +// 5. Remote record doesn't have `name` +add_task(async function test_apply_incoming_without_name_replace() { + const { profileStorage, server, engine } = await setup(); + + try { + // Setup the local record and sync the record to sync + const localGuid = await profileStorage.addresses.add({ + name: "Mr. John William Doe", + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }); + await engine.setLastSync(0); + await engine.sync(); + + // Remote record is updated by a device + setupServerRecords(server, [ + { + guid: localGuid, + version: 1, + "street-address": "32 Vassar Street", // updated + organization: "Mozilla", // added + email: undefined, // removed + }, + ]); + + assertNumberRecordUploadedBySync(engine, 0); + await engine.setLastSync(0); + await engine.sync(); + + // `name` field should be removed + await expectLocalProfiles(profileStorage, [ + { + guid: localGuid, + version: 1, + name: undefined, // Should be replaced! + "street-address": "32 Vassar Street", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +/** + * The following tasks test cases where a matching local record is found by matching GUID while applying an + * incoming record. And both the local and remote records are modified. + * + * We test: + * 1. An incoming record with `name` field and `*-name` fields, indicating this is a new record. + * 2. An incoming record with only `*-name` fields, indicating this is an old record, `*-name` is not updated + * 3. An incoming record with only `*-name` fields, indicating this is an old record, `*-name` is updated + * 4. An incoming record with only `*-name` fields, indicating this is an old record. Local record does not have `name`. + * 5. An incoming record without any name fields, leaving it unknown whether this record comes + * from a new or an old device. + * + * The expected result for each task is: + * 1. The `name` field of the local record should be replaced by the remote record + * 2. The `name` field of the local record should NOT be replaced by the remote record, `name` remains the same. + * 3. The `name` field of the local record should be replaced by the remote record. + * 4. The `name` field of the local record should be replaced by the remote record. + * 5. The `name` field of the local record should be replaced by the remote record + */ + +// 1. Remote record is a new record +add_task(async function test_apply_incoming_merge() { + const { collection, profileStorage, server, engine } = await setup(); + const LOCAL_ENTRY = { + name: "Mr John William Doe", + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }; + + try { + const guid = await profileStorage.addresses.add(LOCAL_ENTRY); + await engine.setLastSync(0); + await engine.sync(); + + // local modifies "street-address" + let localCopy = Object.assign({}, LOCAL_ENTRY); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + setupServerRecords(server, [ + { + guid, + version: 1, + name: "Dr Timothy Berners Lee", // `name` is modified + "given-name": "Timothy", + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "Some Address", + organization: "Mozilla", // `organization` is added + email: undefined, // `email` is removed + }, + ]); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid, + version: 1, + name: "Dr Timothy Berners Lee", // Name should be replaced! + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + await expectServerProfiles(collection, [ + { + guid, + version: 1, + name: "Dr Timothy Berners Lee", // Name should be replaced! + "given-name": "Timothy", + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 2. Remote record is an old record, `*-name` is not updated +add_task(async function test_apply_incoming_legacy_merge_name_is_not_updated() { + const { collection, profileStorage, server, engine } = await setup(); + const LOCAL_ENTRY = { + name: "Mr John William Doe", + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }; + + try { + const guid = await profileStorage.addresses.add(LOCAL_ENTRY); + await engine.setLastSync(0); + await engine.sync(); + + // local modifies "street-address" + let localCopy = Object.assign({}, LOCAL_ENTRY); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + setupServerRecords(server, [ + { + guid, + version: 1, + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + "street-address": "Some Address", + organization: "Mozilla", // `organization` is added + email: undefined, // `email` is removed + }, + ]); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid, + version: 1, + name: "Mr John William Doe", // `name` should NOT be replaced! + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + await expectServerProfiles(collection, [ + { + guid, + version: 1, + name: "Mr John William Doe", + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 3. Remote record is an old record, `*-name` is updated +add_task(async function test_apply_incoming_legacy_merge_name_is_updated() { + const { collection, profileStorage, server, engine } = await setup(); + const LOCAL_ENTRY = { + name: "Mr John William Doe", + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }; + + try { + const guid = await profileStorage.addresses.add(LOCAL_ENTRY); + await engine.setLastSync(0); + await engine.sync(); + + // local modifies "street-address" + let localCopy = Object.assign({}, LOCAL_ENTRY); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + setupServerRecords(server, [ + { + guid, + version: 1, + "given-name": "Timothy", // `name` is modified + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "Some Address", + organization: "Mozilla", // `organization` is added + email: undefined, // `email` is removed + }, + ]); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid, + version: 1, + name: "Timothy Berners Lee", // `name` should be replaced! + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + await expectServerProfiles(collection, [ + { + guid, + version: 1, + name: "Timothy Berners Lee", + "given-name": "Timothy", + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 4. Remote record is an old record. Local record does not have `name` +add_task(async function test_apply_incoming_legacy_merge_local_without_name() { + const { collection, profileStorage, server, engine } = await setup(); + const LOCAL_ENTRY = { + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }; + + try { + const guid = await profileStorage.addresses.add(LOCAL_ENTRY); + await engine.setLastSync(0); + await engine.sync(); + + // local modifies "street-address" + let localCopy = Object.assign({}, LOCAL_ENTRY); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + setupServerRecords(server, [ + { + guid, + version: 1, + "given-name": "Timothy", // `name` is modified + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "Some Address", + organization: "Mozilla", // `organization` is added + email: undefined, // `email` is removed + }, + ]); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid, + version: 1, + name: "Timothy Berners Lee", // `name` should be replaced! + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + await expectServerProfiles(collection, [ + { + guid, + version: 1, + name: "Timothy Berners Lee", + "given-name": "Timothy", + "additional-name": "Berners", + "family-name": "Lee", + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 5. Remote record does not have `name` +add_task(async function test_apply_incoming_without_name_merge() { + const { collection, profileStorage, server, engine } = await setup(); + const LOCAL_ENTRY = { + "street-address": "Some Address", + email: "john.doe@mozilla.org", + }; + + try { + const guid = await profileStorage.addresses.add(LOCAL_ENTRY); + await engine.setLastSync(0); + await engine.sync(); + + // local modifies "street-address" + let localCopy = Object.assign({}, LOCAL_ENTRY); + localCopy["street-address"] = "I moved!"; + await profileStorage.addresses.update(guid, localCopy); + + setupServerRecords(server, [ + { + guid, + version: 1, + "street-address": "Some Address", + organization: "Mozilla", + }, + ]); + + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid, + version: 1, + "street-address": "I moved!", + organization: "Mozilla", // `organization` is added + email: undefined, // `email` is removed + }, + ]); + await expectServerProfiles(collection, [ + { + guid, + version: 1, + "street-address": "I moved!", + organization: "Mozilla", + email: undefined, + }, + ]); + } finally { + await cleanup(server); + } +}); + +/** + * The following tasks test cases where a matching local record is found by running dedupe algorithm while applying an + * incoming record. We test: + * 1. An incoming record with `name` field and `*-name` fields, indicating this is a new record. + * 2. An incoming record with only `*-name` fields, indicating this is an old record. + * 3. An incoming record without any name fields, leaving it unknown whether this record comes + * from a new or an old device. + * + * The expected result is still one record in the local after merging the incoming record + */ + +// 1. Remote record is a new record +add_task(async function test_apply_incoming_dedupe() { + const { collection, profileStorage, server, engine } = await setup(); + const LOCAL_ENTRY = { + name: "Mr John William Doe", + "street-address": "Some Address", + country: "US", + }; + + try { + const localGuid = await profileStorage.addresses.add(LOCAL_ENTRY); + + const remoteGuid = Utils.makeGUID(); + notEqual(localGuid, remoteGuid); + + setupServerRecords(server, [ + { + guid: remoteGuid, + version: 1, + name: "Mr John William Doe", + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + "street-address": "Some Address", + country: "US", + }, + ]); + + // Local duplicated record has not been synced before, so will trigger + // sync upload after merging. + assertNumberRecordUploadedBySync(engine, 1); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: remoteGuid, + version: 1, + name: "Mr John William Doe", + "street-address": "Some Address", + country: "US", + }, + ]); + await expectServerProfiles(collection, [ + { + guid: remoteGuid, + version: 1, + name: "Mr John William Doe", + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + "street-address": "Some Address", + country: "US", + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 2. Remote record is an old record +add_task(async function test_apply_incoming_legacy_dedupe() { + const { collection, profileStorage, server, engine } = await setup(); + const LOCAL_ENTRY = { + name: "John William Doe", + "street-address": "Some Address", + country: "US", + }; + + try { + const localGuid = await profileStorage.addresses.add(LOCAL_ENTRY); + + const remoteGuid = Utils.makeGUID(); + notEqual(localGuid, remoteGuid); + + setupServerRecords(server, [ + { + guid: remoteGuid, + version: 1, + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + "street-address": "Some Address", + country: "US", + }, + ]); + + // Local duplicated record has not been synced before, so will trigger + // sync upload after merging. + assertNumberRecordUploadedBySync(engine, 1); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: remoteGuid, + version: 1, + name: "John William Doe", + "street-address": "Some Address", + country: "US", + }, + ]); + await expectServerProfiles(collection, [ + { + guid: remoteGuid, + version: 1, + name: "John William Doe", + "given-name": "John", + "additional-name": "William", + "family-name": "Doe", + "street-address": "Some Address", + country: "US", + }, + ]); + } finally { + await cleanup(server); + } +}); + +// 3. Remote record does not have `name` +add_task(async function test_apply_incoming_without_name_dedupe() { + const { collection, profileStorage, server, engine } = await setup(); + const LOCAL_ENTRY = { + "street-address": "Some Address", + country: "US", + }; + + try { + const localGuid = await profileStorage.addresses.add(LOCAL_ENTRY); + + const remoteGuid = Utils.makeGUID(); + notEqual(localGuid, remoteGuid); + + setupServerRecords(server, [ + { + guid: remoteGuid, + version: 1, + "street-address": "Some Address", + country: "US", + }, + ]); + + assertNumberRecordUploadedBySync(engine, 1); + await engine.setLastSync(0); + await engine.sync(); + + await expectLocalProfiles(profileStorage, [ + { + guid: remoteGuid, + version: 1, + "street-address": "Some Address", + country: "US", + }, + ]); + await expectServerProfiles(collection, [ + { + guid: remoteGuid, + version: 1, + "street-address": "Some Address", + country: "US", + }, + ]); + } finally { + await cleanup(server); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js b/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js new file mode 100644 index 0000000000..2362796b0c --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js @@ -0,0 +1,248 @@ +/** + * Tests sync functionality. + */ + +/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */ +/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */ +/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */ +/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */ + +"use strict"; + +const { Service } = ChromeUtils.importESModule( + "resource://services-sync/service.sys.mjs" +); + +const { CreditCardsEngine } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillSync.sys.mjs" +); + +Services.prefs.setCharPref("extensions.formautofill.loglevel", "Trace"); +initTestLogging("Trace"); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const TEST_CREDIT_CARD_1 = { + guid: "86d961c7717a", + "cc-name": "John Doe", + "cc-number": "4111111111111111", + "cc-exp-month": 4, + "cc-exp-year": new Date().getFullYear(), +}; + +const TEST_CREDIT_CARD_2 = { + guid: "cf57a7ac3539", + "cc-name": "Timothy Berners-Lee", + "cc-number": "4929001587121045", + "cc-exp-month": 12, + "cc-exp-year": new Date().getFullYear() + 10, +}; + +function expectProfiles(profiles, expected) { + expected.sort((a, b) => a.guid.localeCompare(b.guid)); + profiles.sort((a, b) => a.guid.localeCompare(b.guid)); + try { + deepEqual( + profiles.map(p => p.guid), + expected.map(p => p.guid) + ); + for (let i = 0; i < expected.length; i++) { + let thisExpected = expected[i]; + let thisGot = profiles[i]; + // always check "deleted". + equal(thisExpected.deleted, thisGot.deleted); + ok(objectMatches(thisGot, thisExpected)); + } + } catch (ex) { + info("Comparing expected profiles:"); + info(JSON.stringify(expected, undefined, 2)); + info("against actual profiles:"); + info(JSON.stringify(profiles, undefined, 2)); + throw ex; + } +} + +async function expectServerProfiles(collection, expected) { + const profiles = collection + .payloads() + .map(payload => Object.assign({ guid: payload.id }, payload.entry)); + expectProfiles(profiles, expected); +} + +async function expectLocalProfiles(profileStorage, expected) { + const profiles = await profileStorage.creditCards.getAll({ + rawData: true, + includeDeleted: true, + }); + expectProfiles(profiles, expected); +} + +async function setup() { + let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME); + // should always start with no profiles. + Assert.equal( + (await profileStorage.creditCards.getAll({ includeDeleted: true })).length, + 0 + ); + + Services.prefs.setCharPref( + "services.sync.log.logger.engine.CreditCards", + "Trace" + ); + let engine = new CreditCardsEngine(Service); + await engine.initialize(); + // Avoid accidental automatic sync due to our own changes + Service.scheduler.syncThreshold = 10000000; + let syncID = await engine.resetLocalSyncID(); + let server = serverForUsers( + { foo: "password" }, + { + meta: { + global: { + engines: { creditcards: { version: engine.version, syncID } }, + }, + }, + creditcards: {}, + } + ); + + Service.engineManager._engines.creditcards = engine; + engine.enabled = true; + engine._store._storage = profileStorage.creditCards; + + generateNewKeys(Service.collectionKeys); + + await SyncTestingInfrastructure(server); + + let collection = server.user("foo").collection("creditcards"); + + return { profileStorage, server, collection, engine }; +} + +async function cleanup(server) { + let promiseStartOver = promiseOneObserver("weave:service:start-over:finish"); + await Service.startOver(); + await promiseStartOver; + await promiseStopServer(server); +} + +function getTestRecords(profileStorage, version) { + return [ + Object.assign({ version }, TEST_CREDIT_CARD_1), + Object.assign({ version }, TEST_CREDIT_CARD_2), + ]; +} + +function setupServerRecords(server, records) { + for (const record of records) { + server.insertWBO( + "foo", + "creditcards", + new ServerWBO( + record.guid, + encryptPayload({ + id: record.guid, + entry: Object.assign({}, record), + }), + getDateForSync() + ) + ); + } +} + +/** + * We want to setup old records and run init() to migrate records. + * However, We don't have an easy way to setup an older version record with + * init() function now. + * So as a workaround, we simulate the behavior by directly setting data and then + * run migration. + */ +async function setupLocalProfilesAndRunMigration(profileStorage, records) { + for (const record of records) { + profileStorage._store.data.creditCards.push(Object.assign({}, record)); + } + await Promise.all( + profileStorage.creditCards._data.map(async (record, index) => + profileStorage.creditCards._migrateRecord(record, index) + ) + ); +} + +// local v3, server v4 +add_task(async function test_local_v3_server_v4() { + let { collection, profileStorage, server, engine } = await setup(); + + const V3_RECORDS = getTestRecords(profileStorage, 3); + const V4_RECORDS = getTestRecords(profileStorage, 4); + + await setupLocalProfilesAndRunMigration(profileStorage, V3_RECORDS); + setupServerRecords(server, V4_RECORDS); + + await engine.setLastSync(0); + await engine.sync(); + + await expectServerProfiles(collection, V3_RECORDS); + await expectLocalProfiles(profileStorage, V3_RECORDS); + + await cleanup(server); +}); + +// local v4, server empty +add_task(async function test_local_v4_server_empty() { + let { collection, profileStorage, server, engine } = await setup(); + const V3_RECORDS = getTestRecords(profileStorage, 3); + const V4_RECORDS = getTestRecords(profileStorage, 4); + + await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS); + + await engine.setLastSync(0); + await engine.sync(); + + await expectServerProfiles(collection, V3_RECORDS); + await expectLocalProfiles(profileStorage, V3_RECORDS); + + await cleanup(server); +}); + +// local v4, server v3 +add_task(async function test_local_v4_server_v3() { + let { collection, profileStorage, server, engine } = await setup(); + const V3_RECORDS = getTestRecords(profileStorage, 3); + const V4_RECORDS = getTestRecords(profileStorage, 4); + + await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS); + setupServerRecords(server, V3_RECORDS); + + // local should be v3 before syncing. + await expectLocalProfiles(profileStorage, V3_RECORDS); + + await engine.setLastSync(0); + await engine.sync(); + + await expectServerProfiles(collection, V3_RECORDS); + await expectLocalProfiles(profileStorage, V3_RECORDS); + + await cleanup(server); +}); + +// local v4, server v4 +add_task(async function test_local_v4_server_v4() { + let { collection, profileStorage, server, engine } = await setup(); + const V3_RECORDS = getTestRecords(profileStorage, 3); + const V4_RECORDS = getTestRecords(profileStorage, 4); + + await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS); + setupServerRecords(server, V4_RECORDS); + + // local should be v3 before syncing and then we ignore + // incoming v4 from server + await expectLocalProfiles(profileStorage, V3_RECORDS); + + await engine.setLastSync(0); + await engine.sync(); + + await expectServerProfiles(collection, V3_RECORDS); + await expectLocalProfiles(profileStorage, V3_RECORDS); + + await cleanup(server); +}); diff --git a/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js b/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js new file mode 100644 index 0000000000..ee04c8d1d5 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js @@ -0,0 +1,64 @@ +"use strict"; + +var FormAutofillUtils; +add_setup(async () => { + ({ FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" + )); +}); + +add_task(async function test_getCategoriesFromFieldNames() { + const TEST_CASES = [ + { + strings: ["A", "B", "C", "D"], + expectedValue: "A B C D", + }, + { + strings: ["A", "B", "", "D"], + expectedValue: "A B D", + }, + { + strings: ["", "B", "", "D"], + expectedValue: "B D", + }, + { + strings: [null, "B", " ", "D"], + expectedValue: "B D", + }, + { + strings: "A B C", + expectedValue: "A B C", + }, + { + strings: "A\nB\n\n\nC", + expectedValue: "A B C", + }, + { + strings: "A B \nC", + expectedValue: "A B C", + }, + { + strings: "A-B-C", + expectedValue: "A B C", + delimiter: "-", + }, + { + strings: "A B\n \nC", + expectedValue: "A B C", + }, + { + strings: null, + expectedValue: "", + }, + ]; + + for (let tc of TEST_CASES) { + let result; + if (tc.delimiter) { + result = FormAutofillUtils.toOneLineAddress(tc.strings, tc.delimiter); + } else { + result = FormAutofillUtils.toOneLineAddress(tc.strings); + } + Assert.equal(result, tc.expectedValue); + } +}); diff --git a/browser/extensions/formautofill/test/unit/test_transformFields.js b/browser/extensions/formautofill/test/unit/test_transformFields.js new file mode 100644 index 0000000000..552407f07f --- /dev/null +++ b/browser/extensions/formautofill/test/unit/test_transformFields.js @@ -0,0 +1,1013 @@ +/** + * Tests the transform algorithm in profileStorage. + */ + +"use strict"; + +let FormAutofillStorage; +add_setup(async () => { + ({ FormAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + )); +}); + +const TEST_STORE_FILE_NAME = "test-profile.json"; + +const ADDRESS_COMPUTE_TESTCASES = [ + // Name + { + description: "Has split names", + address: { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + }, + expectedResult: { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + name: "Timothy John Berners-Lee", + }, + }, + { + description: "Has split CJK names", + address: { + "given-name": "德明", + "family-name": "孫", + }, + expectedResult: { + "given-name": "德明", + "family-name": "孫", + name: "孫德明", + }, + }, + + // Address + { + description: '"street-address" with single line', + address: { + "street-address": "single line", + }, + expectedResult: { + "street-address": "single line", + "address-line1": "single line", + }, + }, + { + description: '"street-address" with multiple lines', + address: { + "street-address": "line1\nline2\nline3", + }, + expectedResult: { + "street-address": "line1\nline2\nline3", + "address-line1": "line1", + "address-line2": "line2", + "address-line3": "line3", + }, + }, + { + description: '"street-address" with multiple lines but line2 is omitted', + address: { + "street-address": "line1\n\nline3", + }, + expectedResult: { + "street-address": "line1\n\nline3", + "address-line1": "line1", + "address-line2": undefined, + "address-line3": "line3", + }, + }, + { + description: '"street-address" with 4 lines', + address: { + "street-address": "line1\nline2\nline3\nline4", + }, + expectedResult: { + "street-address": "line1\nline2\nline3\nline4", + "address-line1": "line1", + "address-line2": "line2", + "address-line3": "line3 line4", + }, + }, + { + description: '"street-address" with blank lines', + address: { + "street-address": "line1\n \nline3\n \nline5", + }, + expectedResult: { + "street-address": "line1\n \nline3\n \nline5", + "address-line1": "line1", + "address-line2": undefined, + "address-line3": "line3 line5", + }, + }, + + // Country + { + description: 'Has "country"', + address: { + country: "US", + name: "Timothy John Berners-Lee", + }, + expectedResult: { + country: "US", + name: "Timothy John Berners-Lee", + "country-name": "United States", + }, + }, + + // Tel + { + description: '"tel" with US country code', + address: { + tel: "+16172535702", + }, + expectedResult: { + tel: "+16172535702", + "tel-country-code": "+1", + "tel-national": "6172535702", + "tel-area-code": "617", + "tel-local": "2535702", + "tel-local-prefix": "253", + "tel-local-suffix": "5702", + }, + }, + { + description: '"tel" with TW country code (the components won\'t be parsed)', + address: { + tel: "+886212345678", + }, + expectedResult: { + tel: "+886212345678", + "tel-country-code": "+886", + "tel-national": "0212345678", + "tel-area-code": undefined, + "tel-local": undefined, + "tel-local-prefix": undefined, + "tel-local-suffix": undefined, + }, + }, + { + description: '"tel" without country code so use "US" as default resion', + address: { + tel: "6172535702", + }, + expectedResult: { + tel: "+16172535702", + "tel-country-code": "+1", + "tel-national": "6172535702", + "tel-area-code": "617", + "tel-local": "2535702", + "tel-local-prefix": "253", + "tel-local-suffix": "5702", + }, + }, + { + description: '"tel" without country code but "country" is "TW"', + address: { + tel: "0212345678", + country: "TW", + }, + expectedResult: { + tel: "+886212345678", + "tel-country-code": "+886", + "tel-national": "0212345678", + "tel-area-code": undefined, + "tel-local": undefined, + "tel-local-prefix": undefined, + "tel-local-suffix": undefined, + }, + }, + { + description: '"tel" can\'t be parsed so leave it as-is', + address: { + tel: "12345", + }, + expectedResult: { + tel: "12345", + "tel-country-code": undefined, + "tel-national": "12345", + "tel-area-code": undefined, + "tel-local": undefined, + "tel-local-prefix": undefined, + "tel-local-suffix": undefined, + }, + }, +]; + +const ADDRESS_NORMALIZE_TESTCASES = [ + // Name + { + description: 'Has "name", and the split names are omitted', + address: { + name: "Timothy John Berners-Lee", + }, + expectedResult: { + name: "Timothy John Berners-Lee", + }, + }, + { + description: 'Has both "name" and split names', + address: { + name: "Timothy John Berners-Lee", + "given-name": "John", + "family-name": "Doe", + }, + expectedResult: { + name: "Timothy John Berners-Lee", + }, + }, + { + description: 'Has "name", and some of split names are omitted', + address: { + name: "John Doe", + "given-name": "Timothy", + }, + expectedResult: { + name: "John Doe", + }, + }, + { + description: 'Does not have "name", and has split names', + address: { + "given-name": "Timothy", + "additional-name": "John", + "family-name": "Berners-Lee", + }, + expectedResult: { + name: "Timothy John Berners-Lee", + }, + }, + { + description: 'Does not have "name", and has some split names', + address: { + "given-name": "John", + "family-name": "Doe", + }, + expectedResult: { + name: "John Doe", + }, + }, + { + description: 'Does not have "name" and split names', + address: { + organization: "Mozilla", + }, + expectedResult: { + name: undefined, + organization: "Mozilla", + }, + }, + + // Address + { + description: 'Has "address-line1~3" and "street-address" is omitted', + address: { + "address-line1": "line1", + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "line1\nline2\nline3", + }, + }, + { + description: 'Has both "address-line1~3" and "street-address"', + address: { + "street-address": "street address", + "address-line1": "line1", + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "street address", + }, + }, + { + description: 'Has "address-line2~3" and single-line "street-address"', + address: { + "street-address": "street address", + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "street address\nline2\nline3", + }, + }, + { + description: 'Has "address-line2~3" and multiple-line "street-address"', + address: { + "street-address": "street address\nstreet address line 2", + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "street address\nstreet address line 2", + }, + }, + { + description: 'Has only "address-line1~2"', + address: { + "address-line1": "line1", + "address-line2": "line2", + }, + expectedResult: { + "street-address": "line1\nline2", + }, + }, + { + description: 'Has only "address-line1"', + address: { + "address-line1": "line1", + }, + expectedResult: { + "street-address": "line1", + }, + }, + { + description: 'Has only "address-line2~3"', + address: { + "address-line2": "line2", + "address-line3": "line3", + }, + expectedResult: { + "street-address": "\nline2\nline3", + }, + }, + { + description: 'Has only "address-line2"', + address: { + "address-line2": "line2", + }, + expectedResult: { + "street-address": "\nline2", + }, + }, + + // Country + { + description: 'Has "country" in lowercase', + address: { + country: "us", + name: "name", + }, + expectedResult: { + country: "US", + name: "name", + }, + }, + { + description: 'Has unknown "country"', + address: { + "given-name": "John", // Make sure it won't be an empty record. + country: "AA", + }, + expectedResult: { + country: "US", + }, + }, + { + description: 'Has "country-name"', + address: { + "country-name": "united states", + name: "name", + }, + expectedResult: { + country: "US", + "country-name": "United States", + name: "name", + }, + }, + { + description: 'Has alternative "country-name"', + address: { + "country-name": "america", + name: "name", + }, + expectedResult: { + country: "US", + "country-name": "United States", + name: "name", + }, + }, + { + description: 'Has "country-name" as a substring', + address: { + "country-name": "test america test", + name: "name", + }, + expectedResult: { + country: "US", + "country-name": "United States", + name: "name", + }, + }, + { + description: 'Has "country-name" as part of a word', + address: { + "country-name": "TRUST", + name: "name", // Make sure it won't be an empty record. + }, + expectedResult: { + country: "US", + "country-name": "United States", + name: "name", + }, + }, + { + description: 'Has unknown "country-name"', + address: { + "country-name": "unknown country name", + name: "name", // Make sure it won't be an empty record. + }, + expectedResult: { + country: "US", + "country-name": "United States", + name: "name", + }, + }, + { + description: 'Has "country" and unknown "country-name"', + address: { + country: "us", + "country-name": "unknown country name", + name: "name", + }, + expectedResult: { + country: "US", + "country-name": "United States", + name: "name", + }, + }, + { + description: 'Has "country-name" and unknown "country"', + address: { + country: "AA", + "country-name": "united states", + name: "name", + }, + expectedResult: { + country: "US", + "country-name": "United States", + name: "name", + }, + }, + { + description: 'Has unsupported "country"', + address: { + country: "XX", + name: "name", + }, + expectedResult: { + country: "US", + "country-name": "United States", + name: "name", + }, + }, + + // Tel + { + description: 'Has "tel" with country code', + address: { + tel: "+16172535702", + }, + expectedResult: { + tel: "+16172535702", + }, + }, + { + description: 'Has "tel" without country code but "country" is set', + address: { + tel: "0212345678", + country: "TW", + }, + expectedResult: { + tel: "+886212345678", + }, + }, + { + description: + 'Has "tel" without country code and "country" so use "US" as default region', + address: { + tel: "6172535702", + }, + expectedResult: { + tel: "+16172535702", + }, + }, + { + description: '"tel" can\'t be parsed so leave it as-is', + address: { + tel: "12345", + }, + expectedResult: { + tel: "12345", + }, + }, + { + description: 'Has a valid tel-local format "tel"', + address: { + tel: "1234567", + }, + expectedResult: { + tel: "1234567", + }, + }, + { + description: 'Has "tel-national" and "tel-country-code"', + address: { + "tel-national": "0212345678", + "tel-country-code": "+886", + }, + expectedResult: { + tel: "+886212345678", + }, + }, + { + description: 'Has "tel-national" and "country"', + address: { + "tel-national": "0212345678", + country: "TW", + }, + expectedResult: { + tel: "+886212345678", + }, + }, + { + description: 'Has "tel-national", "tel-country-code" and "country"', + address: { + "tel-national": "0212345678", + "tel-country-code": "+886", + country: "US", + }, + expectedResult: { + tel: "+886212345678", + }, + }, + { + description: 'Has "tel-area-code" and "tel-local"', + address: { + "tel-area-code": "617", + "tel-local": "2535702", + }, + expectedResult: { + tel: "+16172535702", + }, + }, + { + description: + 'Has "tel-area-code", "tel-local-prefix" and "tel-local-suffix"', + address: { + "tel-area-code": "617", + "tel-local-prefix": "253", + "tel-local-suffix": "5702", + }, + expectedResult: { + tel: "+16172535702", + }, + }, +]; + +const CREDIT_CARD_COMPUTE_TESTCASES = [ + // Name + { + description: 'Has "cc-name"', + creditCard: { + "cc-name": "Timothy John Berners-Lee", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-name": "Timothy John Berners-Lee", + "cc-number": "************1045", + "cc-given-name": "Timothy", + "cc-additional-name": "John", + "cc-family-name": "Berners-Lee", + }, + }, + + // Card Number + { + description: "Number should be encrypted and masked", + creditCard: { + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-number": "************1045", + }, + }, + + // Expiration Date + { + description: 'Has "cc-exp-year" and "cc-exp-month"', + creditCard: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-exp": "2022-12", + "cc-number": "************1045", + }, + }, + { + description: 'Has only "cc-exp-month"', + creditCard: { + "cc-exp-month": 12, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp": undefined, + "cc-number": "************1045", + }, + }, + { + description: 'Has only "cc-exp-year"', + creditCard: { + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-year": 2022, + "cc-exp": undefined, + "cc-number": "************1045", + }, + }, +]; + +const CREDIT_CARD_NORMALIZE_TESTCASES = [ + // Name + { + description: 'Has both "cc-name" and the split name fields', + creditCard: { + "cc-name": "Timothy John Berners-Lee", + "cc-given-name": "John", + "cc-family-name": "Doe", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-name": "Timothy John Berners-Lee", + "cc-number": "4929001587121045", + }, + }, + { + description: "Has only the split name fields", + creditCard: { + "cc-given-name": "John", + "cc-family-name": "Doe", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-name": "John Doe", + "cc-number": "4929001587121045", + }, + }, + + // Card Number + { + description: "Regular number", + creditCard: { + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-number": "4929001587121045", + }, + }, + { + description: "Number with spaces", + creditCard: { + "cc-number": "4111 1111 1111 1111", + }, + expectedResult: { + "cc-number": "4111111111111111", + }, + }, + { + description: "Number with hyphens", + creditCard: { + "cc-number": "4111-1111-1111-1111", + }, + expectedResult: { + "cc-number": "4111111111111111", + }, + }, + + // Expiration Date + { + description: 'Has "cc-exp" formatted "yyyy-mm"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "2022-12", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yyyy/mm"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "2022/12", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yyyy-m"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "2022-3", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yyyy/m"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "2022/3", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mm-yyyy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "12-2022", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mm/yyyy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "12/2022", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "m-yyyy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "3-2022", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "m/yyyy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "3/2022", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mm-yy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "12-22", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mm/yy"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "12/22", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yy-mm"', + creditCard: { + "cc-number": "4929001587121045", + "cc-exp": "22-12", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yy/mm"', + creditCard: { + "cc-exp": "22/12", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "mmyy"', + creditCard: { + "cc-exp": "1222", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" formatted "yymm"', + creditCard: { + "cc-exp": "2212", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has "cc-exp" with spaces', + creditCard: { + "cc-exp": " 2033-11 ", + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 11, + "cc-exp-year": 2033, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has invalid "cc-exp"', + creditCard: { + "cc-number": "4111111111111111", // Make sure it won't be an empty record. + "cc-exp": "99-9999", + }, + expectedResult: { + "cc-exp-month": undefined, + "cc-exp-year": undefined, + }, + }, + { + description: 'Has both "cc-exp-*" and "cc-exp"', + creditCard: { + "cc-exp": "2022-12", + "cc-exp-month": 3, + "cc-exp-year": 2030, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 3, + "cc-exp-year": 2030, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has only "cc-exp-year" and "cc-exp"', + creditCard: { + "cc-exp": "2022-12", + "cc-exp-year": 2030, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, + { + description: 'Has only "cc-exp-month" and "cc-exp"', + creditCard: { + "cc-exp": "2022-12", + "cc-exp-month": 3, + "cc-number": "4929001587121045", + }, + expectedResult: { + "cc-exp-month": 12, + "cc-exp-year": 2022, + "cc-number": "4929001587121045", + }, + }, +]; + +let do_check_record_matches = (expectedRecord, record) => { + for (let key in expectedRecord) { + Assert.equal(expectedRecord[key], record[key]); + } +}; + +add_task(async function test_computeAddressFields() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of ADDRESS_COMPUTE_TESTCASES) { + info("Verify testcase: " + testcase.description); + + let guid = await profileStorage.addresses.add(testcase.address); + let address = await profileStorage.addresses.get(guid); + do_check_record_matches(testcase.expectedResult, address); + + profileStorage.addresses.remove(guid); + } + + await profileStorage._finalize(); +}); + +add_task(async function test_normalizeAddressFields() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of ADDRESS_NORMALIZE_TESTCASES) { + info("Verify testcase: " + testcase.description); + + let guid = await profileStorage.addresses.add(testcase.address); + let address = await profileStorage.addresses.get(guid); + do_check_record_matches(testcase.expectedResult, address); + + profileStorage.addresses.remove(guid); + } + + await profileStorage._finalize(); +}); + +add_task(async function test_computeCreditCardFields() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of CREDIT_CARD_COMPUTE_TESTCASES) { + info("Verify testcase: " + testcase.description); + + let guid = await profileStorage.creditCards.add(testcase.creditCard); + let creditCard = await profileStorage.creditCards.get(guid); + do_check_record_matches(testcase.expectedResult, creditCard); + + profileStorage.creditCards.remove(guid); + } + + await profileStorage._finalize(); +}); + +add_task(async function test_normalizeCreditCardFields() { + let path = getTempFile(TEST_STORE_FILE_NAME).path; + + let profileStorage = new FormAutofillStorage(path); + await profileStorage.initialize(); + + for (let testcase of CREDIT_CARD_NORMALIZE_TESTCASES) { + info("Verify testcase: " + testcase.description); + + let guid = await profileStorage.creditCards.add(testcase.creditCard); + let creditCard = await profileStorage.creditCards.get(guid, { + rawData: true, + }); + do_check_record_matches(testcase.expectedResult, creditCard); + + profileStorage.creditCards.remove(guid); + } + + await profileStorage._finalize(); +}); diff --git a/browser/extensions/formautofill/test/unit/xpcshell.toml b/browser/extensions/formautofill/test/unit/xpcshell.toml new file mode 100644 index 0000000000..f63ac60e58 --- /dev/null +++ b/browser/extensions/formautofill/test/unit/xpcshell.toml @@ -0,0 +1,156 @@ +[DEFAULT] +skip-if = [ + "os == 'linux' && ccov", # bug 1821945 + "os == 'android'", # bug 1730213 +] +firefox-appdir = "browser" +head = "head.js" +support-files = ["../fixtures/**"] + +["test_activeStatus.js"] + +["test_addressComponent_city.js"] +head = "head_addressComponent.js" + +["test_addressComponent_country.js"] +head = "head_addressComponent.js" + +["test_addressComponent_email.js"] +head = "head_addressComponent.js" + +["test_addressComponent_name.js"] +head = "head_addressComponent.js" + +["test_addressComponent_organization.js"] +head = "head_addressComponent.js" + +["test_addressComponent_postal_code.js"] +head = "head_addressComponent.js" + +["test_addressComponent_state.js"] +head = "head_addressComponent.js" + +["test_addressComponent_street_address.js"] +head = "head_addressComponent.js" + +["test_addressComponent_tel.js"] +head = "head_addressComponent.js" + +["test_addressDataLoader.js"] + +["test_addressRecords.js"] +skip-if = ["apple_silicon"] # bug 1729554 + +["test_autofillFormFields.js"] +skip-if = [ + "tsan", # Times out, bug 1612707 + "apple_silicon", # bug 1729554 +] + +["test_clearPopulatedForm.js"] + +["test_collectFormFields.js"] + +["test_createRecords.js"] + +["test_creditCardRecords.js"] +skip-if = [ + "tsan", # Times out, bug 1612707 + "apple_silicon", # bug 1729554 +] + +["test_extractLabelStrings.js"] + +["test_findLabelElements.js"] + +["test_getAdaptedProfiles.js"] + +["test_getAdaptedProfiles_locales.js"] + +["test_getCategoriesFromFieldNames.js"] + +["test_getCreditCardLogo.js"] + +["test_getFormInputDetails.js"] + +["test_getInfo.js"] + +["test_getRecords.js"] +skip-if = [ + "tsan", # Times out, bug 1612707 + "apple_silicon", # bug 1729554 +] + +["test_isAddressAutofillAvailable.js"] + +["test_isCJKName.js"] + +["test_isCreditCardAutofillAvailable.js"] + +["test_isCreditCardOrAddressFieldType.js"] + +["test_known_strings.js"] + +["test_markAsAutofillField.js"] + +["test_migrateRecords.js"] +skip-if = ["tsan"] # Times out, bug 1612707 + +["test_nameUtils.js"] + +["test_onFormSubmitted.js"] +skip-if = ["tsan"] # Times out, bug 1612707 + +["test_parseAddressFormat.js"] + +["test_parseStreetAddress.js"] + +["test_phoneNumber.js"] + +["test_previewFormFields.js"] + +["test_profileAutocompleteResult.js"] + +["test_reconcile.js"] +skip-if = [ + "tsan", # Times out, bug 1612707 + "apple_silicon", # bug 1729554 +] + +["test_savedFieldNames.js"] + +["test_storage_remove.js"] +skip-if = [ + "tsan", # Times out, bug 1612707 + "apple_silicon", # bug 1729554 +] + +["test_storage_syncfields.js"] + +["test_storage_tombstones.js"] +skip-if = [ + "tsan", # Times out, bug 1612707 + "apple_silicon", # bug 1729554 +] + +["test_sync.js"] +head = "head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js" +skip-if = ["tsan"] # Times out, bug 1612707 + +["test_sync_deprecate_address_x_name_fields.js"] +head = "head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js" + +["test_sync_deprecate_credit_card_v4.js"] +head = "head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js" +skip-if = [ + "tsan", # Times out, bug 1612707 + "apple_silicon", # bug 1729554 +] + +["test_toOneLineAddress.js"] + +["test_transformFields.js"] +skip-if = [ + "tsan", # Times out, bug 1612707 + "apple_silicon", # bug 1729554 +] -- cgit v1.2.3