Bug 1476204 - Check Luhn algorithm in the basic-card-form and in storage and disable save button when invalid. r=jaws
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Thu, 26 Jul 2018 13:40:22 -0700
changeset 428632 c63340378a529cb5140d0b1db81988ba4d13a89d
parent 428631 d717ad76e6169d7d48c1c89ee02603af4eaa21e4
child 428633 9ad00680f9aa23c742403f5907232158f65a432b
push id34338
push userdluca@mozilla.com
push dateFri, 27 Jul 2018 09:54:52 +0000
treeherdermozilla-central@e56f7601927e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1476204
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1476204 - Check Luhn algorithm in the basic-card-form and in storage and disable save button when invalid. r=jaws * Provide an cc-exp-year option to match cc-exp-month * Make cc-number and cc-name required in the basic-card-form * Disable the basic-card-page save button when the form is invalid. MozReview-Commit-ID: LjzsnAKJp6R
browser/components/payments/res/containers/basic-card-form.js
browser/components/payments/res/paymentRequest.css
browser/components/payments/res/unprivileged-fallbacks.js
browser/components/payments/test/PaymentTestUtils.jsm
browser/components/payments/test/browser/browser_card_edit.js
browser/components/payments/test/browser/browser_payments_onboarding_wizard.js
browser/components/payments/test/browser/head.js
browser/components/payments/test/mochitest/test_basic_card_form.html
browser/extensions/formautofill/FormAutofillStorage.jsm
browser/extensions/formautofill/FormAutofillUtils.jsm
browser/extensions/formautofill/content/autofillEditForms.js
browser/extensions/formautofill/content/editCreditCard.xhtml
browser/extensions/formautofill/content/editDialog.js
browser/extensions/formautofill/locales/en-US/formautofill.properties
browser/extensions/formautofill/test/browser/browser_editCreditCardDialog.js
browser/extensions/formautofill/test/browser/head.js
browser/extensions/formautofill/test/unit/test_autofillFormFields.js
browser/extensions/formautofill/test/unit/test_createRecords.js
browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
browser/extensions/formautofill/test/unit/test_transformFields.js
--- a/browser/components/payments/res/containers/basic-card-form.js
+++ b/browser/components/payments/res/containers/basic-card-form.js
@@ -17,16 +17,18 @@ import paymentRequest from "../paymentRe
  * as it will be much easier to share the logic once we switch to Fluent.
  */
 
 export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRequestPage) {
   constructor() {
     super();
 
     this.genericErrorText = document.createElement("div");
+    this.genericErrorText.setAttribute("aria-live", "polite");
+    this.genericErrorText.classList.add("page-error");
 
     this.addressAddLink = document.createElement("a");
     this.addressAddLink.className = "add-link";
     this.addressAddLink.href = "javascript:void(0)";
     this.addressAddLink.addEventListener("click", this);
     this.addressEditLink = document.createElement("a");
     this.addressEditLink.className = "edit-link";
     this.addressEditLink.href = "javascript:void(0)";
@@ -49,16 +51,18 @@ export default class BasicCardForm exten
     this.saveButton.addEventListener("click", this);
 
     this.footer.append(this.cancelButton, this.backButton, this.saveButton);
 
     // The markup is shared with form autofill preferences.
     let url = "formautofill/editCreditCard.xhtml";
     this.promiseReady = this._fetchMarkup(url).then(doc => {
       this.form = doc.getElementById("form");
+      this.form.addEventListener("input", this);
+      this.form.addEventListener("invalid", this);
       return this.form;
     });
   }
 
   _fetchMarkup(url) {
     return new Promise((resolve, reject) => {
       let xhr = new XMLHttpRequest();
       xhr.responseType = "document";
@@ -162,24 +166,34 @@ export default class BasicCardForm exten
       billingAddressSelect.value = basicCardPage.billingAddressGUID;
     } else if (!editing) {
       if (paymentRequest.getAddresses(state)[selectedShippingAddress]) {
         billingAddressSelect.value = selectedShippingAddress;
       } else {
         billingAddressSelect.value = Object.keys(addresses)[0];
       }
     }
+
+    this.updateSaveButtonState();
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "click": {
         this.onClick(event);
         break;
       }
+      case "input": {
+        this.onInput(event);
+        break;
+      }
+      case "invalid": {
+        this.onInvalid(event);
+        break;
+      }
     }
   }
 
   onClick(evt) {
     switch (evt.target) {
       case this.cancelButton: {
         paymentRequest.cancel();
         break;
@@ -246,25 +260,39 @@ export default class BasicCardForm exten
             "basic-card-page": basicCardPageState,
           });
         }
 
         this.requestStore.setState(nextState);
         break;
       }
       case this.saveButton: {
-        this.saveRecord();
+        if (this.form.checkValidity()) {
+          this.saveRecord();
+        }
         break;
       }
       default: {
         throw new Error("Unexpected click target");
       }
     }
   }
 
+  onInput(event) {
+    this.updateSaveButtonState();
+  }
+
+  onInvalid(event) {
+    this.saveButton.disabled = true;
+  }
+
+  updateSaveButtonState() {
+    this.saveButton.disabled = !this.form.checkValidity();
+  }
+
   saveRecord() {
     let record = this.formHandler.buildFormObject();
     let currentState = this.requestStore.getState();
     let {
       page,
       tempBasicCards,
       "basic-card-page": basicCardPage,
     } = currentState;
--- a/browser/components/payments/res/paymentRequest.css
+++ b/browser/components/payments/res/paymentRequest.css
@@ -73,16 +73,20 @@ payment-dialog > header {
   /* The area above the footer should scroll, if necessary. */
   overflow: auto;
 }
 
 .page > .page-body > h2:empty {
   display: none;
 }
 
+.page-error {
+  color: #D70022;
+}
+
 .page > footer {
   align-items: center;
   background-color: #eaeaee;
   display: flex;
   /* from visual spec: */
   padding-top: 20px;
   padding-bottom: 18px;
 }
--- a/browser/components/payments/res/unprivileged-fallbacks.js
+++ b/browser/components/payments/res/unprivileged-fallbacks.js
@@ -21,17 +21,17 @@ var log = {
   debug: console.debug.bind(console, "paymentRequest.xhtml:"),
 };
 
 var PaymentDialogUtils = {
   getAddressLabel(address) {
     return `${address.name} (${address.guid})`;
   },
   isCCNumber(str) {
-    return str.length > 0;
+    return !!str.replace(/[-\s]/g, "").match(/^\d{9,}$/);
   },
   DEFAULT_REGION: "US",
   supportedCountries: ["US", "CA"],
   getFormFormat(country) {
     return {
       "addressLevel1Label": country == "US" ? "state" : "province",
       "postalCodeLabel": country == "US" ? "zip" : "postalCode",
       "fieldsOrder": [
--- a/browser/components/payments/test/PaymentTestUtils.jsm
+++ b/browser/components/payments/test/PaymentTestUtils.jsm
@@ -167,16 +167,17 @@ var PaymentTestUtils = {
      * Don't await on this method from a ContentTask when expecting the dialog to close
      *
      * @returns {undefined}
      */
     clickPrimaryButton: () => {
       let {requestStore} = Cu.waiveXrays(content.document.querySelector("payment-dialog"));
       let {page} = requestStore.getState();
       let button = content.document.querySelector(`#${page.id} button.primary`);
+      ok(!button.disabled, "Primary button should not be disabled when clicking it");
       button.click();
     },
 
     /**
      * Click the cancel button
      *
      * Don't await on this task since the cancel can close the dialog before
      * ContentTask can resolve the promise.
--- a/browser/components/payments/test/browser/browser_card_edit.js
+++ b/browser/components/payments/test/browser/browser_card_edit.js
@@ -108,23 +108,23 @@ async function add_link(aOptions = {}) {
     }, aOptions);
 
     await navigateToAddAddressPage(frame, addressOptions);
 
     await fillInAddressForm(frame, PTU.Addresses.TimBL2, addressOptions);
 
     await verifyPersistCheckbox(frame, addressOptions);
 
+    await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+
     await spawnPaymentDialogTask(frame, async (testArgs = {}) => {
       let {
         PaymentTestUtils: PTU,
       } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
-      content.document.querySelector("address-form button:last-of-type").click();
-
       let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
         return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
       }, "Check address was added and we're back on basic-card page (add)");
 
       let addressCount = Object.keys(state.savedAddresses).length +
                          Object.keys(state.tempAddresses).length;
       is(addressCount, 2, "Check address was added");
 
@@ -147,28 +147,29 @@ async function add_link(aOptions = {}) {
       is(selectedAddressGuid, lastAddress.guid, "The select should have the new address selected");
     }, aOptions);
 
     await fillInCardForm(frame, PTU.BasicCards.JaneMasterCard, {
       isTemporary: aOptions.isPrivate,
       checkboxSelector: "basic-card-form .persist-checkbox",
     });
 
+    await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+
     await spawnPaymentDialogTask(frame, async (testArgs = {}) => {
       let {
         PaymentTestUtils: PTU,
       } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
-      content.document.querySelector("basic-card-form button:last-of-type").click();
-
       await PTU.DialogContentUtils.waitForState(content, (state) => {
         return state.page.id == "payment-summary";
-      }, "Check we are back on the sumamry page");
+      }, "Check we are back on the summary page");
     });
 
+
     await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.setSecurityCode, {
       securityCode: "123",
     });
 
     await spawnPaymentDialogTask(frame, async (testArgs = {}) => {
       let {
         PaymentTestUtils: PTU,
       } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
@@ -365,26 +366,26 @@ add_task(async function test_edit_link()
       let field = content.document.getElementById(key);
       if (!field) {
         ok(false, `${key} field not found`);
       }
       field.value = val.slice(0, -1) + "7";
       ok(!field.disabled, `Field #${key} shouldn't be disabled`);
     }
 
-    content.document.querySelector("address-form button:last-of-type").click();
+    content.document.querySelector("address-form button.save-button").click();
     state = await PTU.DialogContentUtils.waitForState(content, (state) => {
       return state.page.id == "basic-card-page" && state["basic-card-page"].guid &&
              Object.keys(state.savedAddresses).length == 1;
     }, "Check still only one address and we're back on basic-card page");
 
     is(Object.values(state.savedAddresses)[0].tel, PTU.Addresses.TimBL.tel.slice(0, -1) + "7",
        "Check that address was edited and saved");
 
-    content.document.querySelector("basic-card-form button:last-of-type").click();
+    content.document.querySelector("basic-card-form button.save-button").click();
 
     state = await PTU.DialogContentUtils.waitForState(content, (state) => {
       let cards = Object.entries(state.savedBasicCards);
       return cards.length == 1 &&
              cards[0][1]["cc-name"] == card["cc-name"];
     }, "Check card was edited");
 
     let cardGUIDs = Object.keys(state.savedBasicCards);
@@ -398,70 +399,78 @@ add_task(async function test_edit_link()
     state = await PTU.DialogContentUtils.waitForState(content, (state) => {
       return state.page.id == "payment-summary";
     }, "Switched back to payment-summary");
   }, args);
 });
 
 add_task(async function test_private_card_adding() {
   await setup([PTU.Addresses.TimBL], [PTU.BasicCards.JohnDoe]);
-  const args = {
-    methodData: [PTU.MethodData.basicCard],
-    details: PTU.Details.total60USD,
-  };
   let privateWin = await BrowserTestUtils.openNewBrowserWindow({private: true});
-  await spawnInDialogForMerchantTask(PTU.ContentTasks.createAndShowRequest, async function check() {
-    let {
-      PaymentTestUtils: PTU,
-    } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
+
+  await BrowserTestUtils.withNewTab({
+    gBrowser: privateWin.gBrowser,
+    url: BLANK_PAGE_URL,
+  }, async browser => {
+    let {win, frame} = await setupPaymentDialog(browser, {
+      methodData: [PTU.MethodData.basicCard],
+      details: PTU.Details.total60USD,
+      merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
+    });
 
-    let addLink = content.document.querySelector("payment-method-picker .add-link");
-    is(addLink.textContent, "Add", "Add link text");
+    await spawnPaymentDialogTask(frame, async function check() {
+      let {
+        PaymentTestUtils: PTU,
+      } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
-    addLink.click();
+      let addLink = content.document.querySelector("payment-method-picker .add-link");
+      is(addLink.textContent, "Add", "Add link text");
 
-    let state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-      return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
-    },
-                                                          "Check add page state");
+      addLink.click();
 
-    let savedCardCount = Object.keys(state.savedBasicCards).length;
-    let tempCardCount = Object.keys(state.tempBasicCards).length;
+      await PTU.DialogContentUtils.waitForState(content, (state) => {
+        return state.page.id == "basic-card-page" && !state["basic-card-page"].guid;
+      },
+                                                "Check card page state");
+    });
 
-    let card = Object.assign({}, PTU.BasicCards.JohnDoe);
+    await fillInCardForm(frame, PTU.BasicCards.JohnDoe);
 
-    info("filling fields");
-    for (let [key, val] of Object.entries(card)) {
-      let field = content.document.getElementById(key);
-      field.value = val;
-      ok(!field.disabled, `Field #${key} shouldn't be disabled`);
-    }
+    await spawnPaymentDialogTask(frame, async function() {
+      let {
+        PaymentTestUtils: PTU,
+      } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
-    content.document.querySelector("basic-card-form button:last-of-type").click();
+      let card = Object.assign({}, PTU.BasicCards.JohnDoe);
+      let state = await PTU.DialogContentUtils.getCurrentState(content);
+      let savedCardCount = Object.keys(state.savedBasicCards).length;
+      let tempCardCount = Object.keys(state.tempBasicCards).length;
+      content.document.querySelector("basic-card-form button.save-button").click();
 
-    state = await PTU.DialogContentUtils.waitForState(content, (state) => {
-      return Object.keys(state.tempBasicCards).length > tempCardCount;
-    },
-                                                      "Check card was added to temp collection");
+      state = await PTU.DialogContentUtils.waitForState(content, (state) => {
+        return Object.keys(state.tempBasicCards).length > tempCardCount;
+      },
+                                                        "Check card was added to temp collection");
 
-    is(savedCardCount, Object.keys(state.savedBasicCards).length, "No card was saved in state");
-    is(Object.keys(state.tempBasicCards).length, 1, "Card was added temporarily");
+      is(savedCardCount, Object.keys(state.savedBasicCards).length, "No card was saved in state");
+      is(Object.keys(state.tempBasicCards).length, 1, "Card was added temporarily");
 
-    let cardGUIDs = Object.keys(state.tempBasicCards);
-    is(cardGUIDs.length, 1, "Check there is one card");
+      let cardGUIDs = Object.keys(state.tempBasicCards);
+      is(cardGUIDs.length, 1, "Check there is one card");
 
-    let tempCard = state.tempBasicCards[cardGUIDs[0]];
-    // Card number should be masked, so skip cc-number in the compare loop below
-    delete card["cc-number"];
-    for (let [key, val] of Object.entries(card)) {
-      is(tempCard[key], val, "Check " + key + ` ${tempCard[key]} matches ${val}`);
-    }
-    // check computed fields
-    is(tempCard["cc-number"], "************1111", "cc-number is masked");
-    is(tempCard["cc-given-name"], "John", "cc-given-name was computed");
-    is(tempCard["cc-family-name"], "Doe", "cc-family-name was computed");
-    ok(tempCard["cc-exp"], "cc-exp was computed");
-    ok(tempCard["cc-number-encrypted"], "cc-number-encrypted was computed");
-  }, args, {
-    browser: privateWin.gBrowser,
+      let tempCard = state.tempBasicCards[cardGUIDs[0]];
+      // Card number should be masked, so skip cc-number in the compare loop below
+      delete card["cc-number"];
+      for (let [key, val] of Object.entries(card)) {
+        is(tempCard[key], val, "Check " + key + ` ${tempCard[key]} matches ${val}`);
+      }
+      // check computed fields
+      is(tempCard["cc-number"], "************1111", "cc-number is masked");
+      is(tempCard["cc-given-name"], "John", "cc-given-name was computed");
+      is(tempCard["cc-family-name"], "Doe", "cc-family-name was computed");
+      ok(tempCard["cc-exp"], "cc-exp was computed");
+      ok(tempCard["cc-number-encrypted"], "cc-number-encrypted was computed");
+    });
+    spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
+    await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
   });
   await BrowserTestUtils.closeWindow(privateWin);
 });
--- a/browser/components/payments/test/browser/browser_payments_onboarding_wizard.js
+++ b/browser/components/payments/test/browser/browser_payments_onboarding_wizard.js
@@ -81,31 +81,33 @@ add_task(async function test_onboarding_
       ok(content.isVisible(basicCardTitle), "Basic card page title is visible");
       is(basicCardTitle.textContent, "Add Credit Card", "Basic card page title is correctly shown");
 
       info("Check if the correct billing address is selected in the basic card page");
       PTU.DialogContentUtils.waitForState(content, (state) => {
         let billingAddressSelect = content.document.querySelector("#billingAddressGUID");
         return state.selectedShippingAddress == billingAddressSelect.value;
       }, "Shipping address is selected as the billing address");
+    });
 
-      for (let [key, val] of Object.entries(PTU.BasicCards.JohnDoe)) {
-        let field = content.document.getElementById(key);
-        field.value = val;
-        ok(!field.disabled, `Field #${key} shouldn't be disabled`);
-      }
-      content.document.querySelector("basic-card-form .save-button").click();
+    await fillInCardForm(frame, PTU.BasicCards.JohnDoe);
+
+    await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+
+    await spawnPaymentDialogTask(frame, async function() {
+      let {
+        PaymentTestUtils: PTU,
+      } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
       await PTU.DialogContentUtils.waitForState(content, (state) => {
         return state.page.id == "payment-summary";
       }, "Payment summary page is shown after the basic card page during on boarding");
 
       let cancelButton = content.document.querySelector("#cancel");
-      ok(content.isVisible(cancelButton),
-         "Payment summary page is rendered");
+      ok(content.isVisible(cancelButton), "Payment summary page is rendered");
     });
 
     info("Closing the payment dialog");
     spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
 
     await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
   });
 });
@@ -351,23 +353,26 @@ add_task(async function test_onboarding_
       let cardSaveButton = content.document.querySelector("basic-card-form .save-button");
       ok(content.isVisible(cardSaveButton), "Basic card page is rendered");
 
       info("Check if the correct billing address is selected in the basic card page");
       PTU.DialogContentUtils.waitForState(content, (state) => {
         let billingAddressSelect = content.document.querySelector("#billingAddressGUID");
         return state["basic-card-page"].billingAddressGUID == billingAddressSelect.value;
       }, "Billing Address is correctly shown");
+    });
 
-      for (let [key, val] of Object.entries(PTU.BasicCards.JohnDoe)) {
-        let field = content.document.getElementById(key);
-        field.value = val;
-        ok(!field.disabled, `Field #${key} shouldn't be disabled`);
-      }
-      content.document.querySelector("basic-card-form .save-button").click();
+    await fillInCardForm(frame, PTU.BasicCards.JohnDoe);
+
+    await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.clickPrimaryButton);
+
+    await spawnPaymentDialogTask(frame, async function() {
+      let {
+        PaymentTestUtils: PTU,
+      } = ChromeUtils.import("resource://testing-common/PaymentTestUtils.jsm", {});
 
       await PTU.DialogContentUtils.waitForState(content, (state) => {
         return state.page.id == "payment-summary";
       }, "payment-summary is shown after the basic card page during on boarding");
 
       let cancelButton = content.document.querySelector("#cancel");
       ok(content.isVisible(cancelButton), "Payment summary page is rendered");
     });
--- a/browser/components/payments/test/browser/head.js
+++ b/browser/components/payments/test/browser/head.js
@@ -321,16 +321,18 @@ add_task(async function setup_head() {
     if (msg.category == "CSP_CSPViolationWithURI" && msg.errorMessage.includes("at inline")) {
       // Ignore unknown CSP error.
       return;
     }
     if (msg.errorMessage.match(/docShell is null.*BrowserUtils.jsm/)) {
       // Bug 1478142 - Console spam from the Find Toolbar.
       return;
     }
+    info("message: " + msg.message);
+    info("errorMessage: " + msg.errorMessage);
     ok(false, msg.message || msg.errorMessage);
   });
   await setupFormAutofillStorage();
   registerCleanupFunction(function cleanup() {
     paymentSrv.cleanup();
     cleanupFormAutofillStorage();
     Services.prefs.clearUserPref(RESPONSE_TIMEOUT_PREF);
     SpecialPowers.postConsoleSentinel();
@@ -493,18 +495,27 @@ async function fillInCardForm(frame, aCa
 
     // fill the form
     info("fillInCardForm: fill the form with card: " + JSON.stringify(card));
     for (let [key, val] of Object.entries(card)) {
       let field = content.document.getElementById(key);
       if (!field) {
         ok(false, `${key} field not found`);
       }
-      field.value = val;
+      ok(!field.disabled, `Field #${key} shouldn't be disabled`);
+      field.value = "";
+      field.focus();
+      // cc-exp-* fields are numbers so convert to strings and pad left with 0
+      let fillValue = val.toString().padStart(2, "0");
+      EventUtils.synthesizeKey(fillValue, {}, content.window);
+      ok(field.value, fillValue, `${key} value is correct after synthesizeKey`);
     }
+
+    info([...content.document.getElementById("cc-exp-year").options].map(op => op.label).join(","));
+
     let persistCheckbox = content.document.querySelector(options.checkboxSelector);
     // only touch the checked state if explicitly told to in the options
     if (options.hasOwnProperty("isTemporary")) {
       Cu.waiveXrays(persistCheckbox).checked = !options.isTemporary;
     }
   }, {card: aCard, options: aOptions});
 }
 
--- a/browser/components/payments/test/mochitest/test_basic_card_form.html
+++ b/browser/components/payments/test/mochitest/test_basic_card_form.html
@@ -91,25 +91,31 @@ add_task(async function test_backButton(
 add_task(async function test_saveButton() {
   let form = new BasicCardForm();
   form.dataset.saveButtonLabel = "Save";
   form.dataset.errorGenericSave = "Generic error";
   await form.promiseReady;
   display.appendChild(form);
   await asyncElementRendered();
 
+  ok(form.saveButton.disabled, "Save button should initially be disabled");
   form.form.querySelector("#cc-number").focus();
-  sendString("4111111111111111");
+  sendString("4111 1111-1111 1111");
   form.form.querySelector("#cc-name").focus();
+  // Check .disabled after .focus() so that it's after both "input" and "change" events.
+  ok(form.saveButton.disabled, "Save button should still be disabled without a name");
   sendString("J. Smith");
   form.form.querySelector("#cc-exp-month").focus();
   sendString("11");
   form.form.querySelector("#cc-exp-year").focus();
   let year = (new Date()).getFullYear().toString();
   sendString(year);
+  form.saveButton.focus();
+  ok(!form.saveButton.disabled,
+     "Save button should be enabled since the required fields are filled");
 
   let messagePromise = promiseContentToChromeMessage("updateAutofillRecord");
   is(form.saveButton.textContent, "Save", "Check label");
   synthesizeMouseAtCenter(form.saveButton, {});
 
   let details = await messagePromise;
   is(details.collectionName, "creditCards", "Check collectionName");
   isDeeply(details, {
@@ -122,17 +128,17 @@ add_task(async function test_saveButton(
     },
     guid: undefined,
     messageType: "updateAutofillRecord",
     preserveOldProperties: true,
     record: {
       "cc-exp-month": "11",
       "cc-exp-year": year,
       "cc-name": "J. Smith",
-      "cc-number": "4111111111111111",
+      "cc-number": "4111 1111-1111 1111",
       "billingAddressGUID": "",
     },
     selectedStateKey: ["selectedPaymentCard"],
     successStateChange: {
       page: {
         id: "payment-summary",
       },
     },
--- a/browser/extensions/formautofill/FormAutofillStorage.jsm
+++ b/browser/extensions/formautofill/FormAutofillStorage.jsm
@@ -322,16 +322,20 @@ class AutofillRecords {
         if (existing.deleted) {
           this._data.splice(index, 1);
         } else {
           throw new Error(`Record ${recordToSave.guid} already exists`);
         }
       }
     } else if (!recordToSave.deleted) {
       this._normalizeRecord(recordToSave);
+      // _normalizeRecord shouldn't do any validation (throw) because in the
+      // `update` case it is called with partial records whereas
+      // `_validateFields` is called with a complete one.
+      this._validateFields(recordToSave);
 
       recordToSave.guid = this._generateGUID();
       recordToSave.version = this.version;
 
       // Metadata
       let now = Date.now();
       recordToSave.timeCreated = now;
       recordToSave.timeLastModified = now;
@@ -433,16 +437,23 @@ class AutofillRecords {
 
       this._maybeStoreLastSyncedField(recordFound, field, oldValue);
     }
 
     if (!hasValidField) {
       throw new Error("Record contains no valid field.");
     }
 
+    // _normalizeRecord above is called with the `record` argument provided to
+    // `update` which may not contain all resulting fields when
+    // `preserveOldProperties` is used. This means we need to validate for
+    // missing fields after we compose the record (`recordFound`) with the stored
+    // record like we do in the loop above.
+    this._validateFields(recordFound);
+
     recordFound.timeLastModified = Date.now();
     let syncMetadata = this._getSyncMetaData(recordFound);
     if (syncMetadata) {
       syncMetadata.changeCounter += 1;
     }
 
     this.computeFields(recordFound);
     this._data[recordFoundIndex] = recordFound;
@@ -1215,18 +1226,38 @@ class AutofillRecords {
   }
 
   // An interface to be inherited.
   _recordReadProcessor(record) {}
 
   // An interface to be inherited.
   computeFields(record) {}
 
-  // An interface to be inherited.
-  _normalizeFields(record) {}
+  /**
+  * An interface to be inherited to mutate the argument to normalize it.
+  *
+  * @param {object} partialRecord containing the record passed by the consumer of
+  *                               storage and in the case of `update` with
+  *                               `preserveOldProperties` will only include the
+  *                               properties that the user is changing so the
+  *                               lack of a field doesn't mean that the record
+  *                               won't have that field.
+  */
+  _normalizeFields(partialRecord) {}
+
+  /**
+   * An interface to be inherited to validate that the complete record is
+   * consistent and isn't missing required fields. Overrides should throw for
+   * invalid records.
+   *
+   * @param {object} record containing the complete record that would be stored
+   *                        if this doesn't throw due to an error.
+   * @throws
+   */
+  _validateFields(record) {}
 
   // An interface to be inherited.
   mergeIfPossible(guid, record, strict) {}
 }
 
 class Addresses extends AutofillRecords {
   constructor(store) {
     super(store, "addresses", VALID_ADDRESS_FIELDS, VALID_ADDRESS_COMPUTED_FIELDS, ADDRESS_SCHEMA_VERSION);
@@ -1574,17 +1605,17 @@ class CreditCards extends AutofillRecord
     delete creditCard["cc-additional-name"];
     delete creditCard["cc-family-name"];
   }
 
   _normalizeCCNumber(creditCard) {
     if (creditCard["cc-number"]) {
       let card = new CreditCard({number: creditCard["cc-number"]});
       creditCard["cc-number"] = card.number;
-      if (!creditCard["cc-number"]) {
+      if (!card.isValidNumber()) {
         delete creditCard["cc-number"];
       }
     }
   }
 
   _normalizeCCExpirationDate(creditCard) {
     let card = new CreditCard({
       expirationMonth: creditCard["cc-exp-month"],
@@ -1599,16 +1630,22 @@ class CreditCards extends AutofillRecord
     if (card.expirationYear) {
       creditCard["cc-exp-year"] = card.expirationYear;
     } else {
       delete creditCard["cc-exp-year"];
     }
     delete creditCard["cc-exp"];
   }
 
+  _validateFields(creditCard) {
+    if (!creditCard["cc-number"]) {
+      throw new Error("Missing/invalid cc-number");
+    }
+  }
+
   /**
    * Normalize the given record and return the first matched guid if storage has the same record.
    * @param {Object} targetCreditCard
    *        The credit card for duplication checking.
    * @returns {string|null}
    *          Return the first guid if storage has the same credit card and null otherwise.
    */
   getDuplicateGuid(targetCreditCard) {
--- a/browser/extensions/formautofill/FormAutofillUtils.jsm
+++ b/browser/extensions/formautofill/FormAutofillUtils.jsm
@@ -216,17 +216,17 @@ this.FormAutofillUtils = {
   },
 
   isCreditCardField(fieldName) {
     return this._fieldNameInfo[fieldName] == "creditCard";
   },
 
   isCCNumber(ccNumber) {
     let card = new CreditCard({number: ccNumber});
-    return !!card.number;
+    return card.isValidNumber();
   },
 
   getCategoryFromFieldName(fieldName) {
     return this._fieldNameInfo[fieldName];
   },
 
   getCategoriesFromFieldNames(fieldNames) {
     let categories = new Set();
--- a/browser/extensions/formautofill/content/autofillEditForms.js
+++ b/browser/extensions/formautofill/content/autofillEditForms.js
@@ -203,16 +203,17 @@ class EditCreditCard extends EditAutofil
    */
   constructor(elements, record, addresses, config) {
     super(elements);
 
     this._addresses = addresses;
     Object.assign(this, config);
     Object.assign(this._elements, {
       ccNumber: this._elements.form.querySelector("#cc-number"),
+      invalidCardNumberStringElement: this._elements.form.querySelector("#invalidCardNumberString"),
       year: this._elements.form.querySelector("#cc-exp-year"),
       billingAddress: this._elements.form.querySelector("#billingAddressGUID"),
       billingAddressRow: this._elements.form.querySelector(".billingAddressRow"),
     });
 
     this.loadRecord(record, addresses);
     this.attachEventListeners();
   }
@@ -232,16 +233,19 @@ class EditCreditCard extends EditAutofil
   generateYears() {
     const count = 11;
     const currentYear = new Date().getFullYear();
     const ccExpYear = this._record && this._record["cc-exp-year"];
 
     // Clear the list
     this._elements.year.textContent = "";
 
+    // Provide an empty year option
+    this._elements.year.appendChild(new Option());
+
     if (ccExpYear && ccExpYear < currentYear) {
       this._elements.year.appendChild(new Option(ccExpYear));
     }
 
     for (let i = 0; i < count; i++) {
       let year = currentYear + i;
       let option = new Option(year);
       this._elements.year.appendChild(option);
@@ -281,17 +285,18 @@ class EditCreditCard extends EditAutofil
     if (event.target != this._elements.ccNumber) {
       return;
     }
 
     let ccNumberField = this._elements.ccNumber;
 
     // Mark the cc-number field as invalid if the number is empty or invalid.
     if (!this.isCCNumber(ccNumberField.value)) {
-      ccNumberField.setCustomValidity(true);
+      let invalidCardNumberString = this._elements.invalidCardNumberStringElement.textContent;
+      ccNumberField.setCustomValidity(invalidCardNumberString || " ");
     }
   }
 
   handleInput(event) {
     // Clear the error message if cc-number is valid
     if (event.target == this._elements.ccNumber &&
         this.isCCNumber(this._elements.ccNumber.value)) {
       this._elements.ccNumber.setCustomValidity("");
--- a/browser/extensions/formautofill/content/editCreditCard.xhtml
+++ b/browser/extensions/formautofill/content/editCreditCard.xhtml
@@ -15,21 +15,22 @@
   <script src="chrome://formautofill/content/l10n.js"></script>
   <script src="chrome://formautofill/content/editDialog.js"></script>
   <script src="chrome://formautofill/content/autofillEditForms.js"></script>
 </head>
 <body dir="&locale.dir;">
   <form id="form" autocomplete="off">
     <label>
       <span data-localization="cardNumber"/>
-      <input id="cc-number" type="text"/>
+      <span id="invalidCardNumberString" hidden="hidden" data-localization="invalidCardNumber"></span>
+      <input id="cc-number" type="text" required="required" minlength="9" pattern="[- 0-9]+"/>
     </label>
     <label>
       <span data-localization="nameOnCard"/>
-      <input id="cc-name" type="text"/>
+      <input id="cc-name" type="text" required="required"/>
     </label>
     <div>
       <span data-localization="cardExpires"/>
       <select id="cc-exp-month">
         <option/>
         <option value="1">01</option>
         <option value="2">02</option>
         <option value="3">03</option>
--- a/browser/extensions/formautofill/content/editDialog.js
+++ b/browser/extensions/formautofill/content/editDialog.js
@@ -168,20 +168,24 @@ class EditCreditCardDialog extends Autof
   localizeDocument() {
     if (this._record) {
       this._elements.title.dataset.localization = "editCreditCardTitle";
     }
   }
 
   async handleSubmit() {
     let creditCard = this._elements.fieldContainer.buildFormObject();
-    if (!this._elements.fieldContainer._elements.form.checkValidity()) {
+    if (!this._elements.fieldContainer._elements.form.reportValidity()) {
       return;
     }
 
     // TODO: "MasterPassword.ensureLoggedIn" can be removed after the storage
     // APIs are refactored to be async functions (bug 1399367).
     if (await MasterPassword.ensureLoggedIn()) {
-      await this.saveRecord(creditCard, this._record ? this._record.guid : null);
+      try {
+        await this.saveRecord(creditCard, this._record ? this._record.guid : null);
+        window.close();
+      } catch (ex) {
+        Cu.reportError(ex);
+      }
     }
-    window.close();
   }
 }
--- a/browser/extensions/formautofill/locales/en-US/formautofill.properties
+++ b/browser/extensions/formautofill/locales/en-US/formautofill.properties
@@ -131,11 +131,12 @@ cancelBtnLabel = Cancel
 saveBtnLabel = Save
 countryWarningMessage2 = Form Autofill is currently available only for certain countries.
 
 # LOCALIZATION NOTE (addNewCreditCardTitle, editCreditCardTitle): The dialog title for creating or editing
 # credit cards in browser preferences.
 addNewCreditCardTitle = Add New Credit Card
 editCreditCardTitle = Edit Credit Card
 cardNumber = Card Number
+invalidCardNumber = Please enter a valid card number
 nameOnCard = Name on Card
 cardExpires = Expires
 billingAddress = Billing Address
--- a/browser/extensions/formautofill/test/browser/browser_editCreditCardDialog.js
+++ b/browser/extensions/formautofill/test/browser/browser_editCreditCardDialog.js
@@ -174,21 +174,26 @@ add_task(async function test_editCreditC
 
 add_task(async function test_addInvalidCreditCard() {
   await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, (win) => {
     const unloadHandler = () => ok(false, "Edit credit card dialog shouldn't be closed");
     win.addEventListener("unload", unloadHandler);
 
     EventUtils.synthesizeKey("VK_TAB", {}, win);
     EventUtils.synthesizeKey("test", {}, win);
+    EventUtils.synthesizeKey("VK_TAB", {}, win);
+    EventUtils.synthesizeKey("test name", {}, win);
+    EventUtils.synthesizeKey("VK_TAB", {}, win);
     EventUtils.synthesizeMouseAtCenter(win.document.querySelector("#save"), {}, win);
 
     is(win.document.querySelector("form").checkValidity(), false, "cc-number is invalid");
     SimpleTest.requestFlakyTimeout("Ensure the window remains open after save attempt");
     setTimeout(() => {
       win.removeEventListener("unload", unloadHandler);
+      info("closing");
       win.close();
     }, 500);
   });
+  info("closed");
   let creditCards = await getCreditCards();
 
   is(creditCards.length, 0, "Credit card storage is empty");
 });
--- a/browser/extensions/formautofill/test/browser/head.js
+++ b/browser/extensions/formautofill/test/browser/head.js
@@ -8,16 +8,17 @@
             sleep, expectPopupOpen, openPopupOn, expectPopupClose, closePopup, clickDoorhangerButton,
             getAddresses, saveAddress, removeAddresses, saveCreditCard,
             getDisplayedPopupItems, getDoorhangerCheckbox, waitForMasterPasswordDialog,
             getNotification, getDoorhangerButton, removeAllRecords, testDialog */
 
 "use strict";
 
 ChromeUtils.import("resource://testing-common/LoginTestUtils.jsm", this);
+ChromeUtils.import("resource://formautofill/MasterPassword.jsm", this);
 
 const MANAGE_ADDRESSES_DIALOG_URL = "chrome://formautofill/content/manageAddresses.xhtml";
 const MANAGE_CREDIT_CARDS_DIALOG_URL = "chrome://formautofill/content/manageCreditCards.xhtml";
 const EDIT_ADDRESS_DIALOG_URL = "chrome://formautofill/content/editAddress.xhtml";
 const EDIT_CREDIT_CARD_DIALOG_URL = "chrome://formautofill/content/editCreditCard.xhtml";
 const BASE_URL = "http://mochi.test:8888/browser/browser/extensions/formautofill/test/browser/";
 const FORM_URL = "http://mochi.test:8888/browser/browser/extensions/formautofill/test/browser/autocomplete_basic.html";
 const CREDITCARD_FORM_URL =
@@ -339,16 +340,21 @@ async function removeAllRecords() {
 async function waitForFocusAndFormReady(win) {
   return Promise.all([
     new Promise(resolve => waitForFocus(resolve, win)),
     BrowserTestUtils.waitForEvent(win, "FormReady"),
   ]);
 }
 
 async function testDialog(url, testFn, arg = undefined) {
+  if (url == EDIT_CREDIT_CARD_DIALOG_URL && arg) {
+    arg = Object.assign({}, arg, {
+      "cc-number": await MasterPassword.decrypt(arg["cc-number-encrypted"]),
+    });
+  }
   let win = window.openDialog(url, null, "width=600,height=600", arg);
   await waitForFocusAndFormReady(win);
   let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload");
   await testFn(win);
   return unloadPromise;
 }
 
 registerCleanupFunction(removeAllRecords);
--- a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
+++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
@@ -241,28 +241,28 @@ const TESTCASES = [
                <input id="cc-number" autocomplete="cc-number">
                <input id="cc-name" autocomplete="cc-name">
                <input id="cc-exp-month" autocomplete="cc-exp-month">
                <input id="cc-exp-year" autocomplete="cc-exp-year">
                </form>`,
     focusedInputId: "cc-number",
     profileData: {
       "guid": "123",
-      "cc-number": "1234000056780000",
+      "cc-number": "4111111111111111",
       "cc-name": "test name",
       "cc-exp-month": "06",
       "cc-exp-year": "25",
     },
     expectedResult: {
       "street-addr": "",
       "city": "",
       "country": "",
       "email": "",
       "tel": "",
-      "cc-number": "1234000056780000",
+      "cc-number": "4111111111111111",
       "cc-name": "test name",
       "cc-exp-month": "06",
       "cc-exp-year": "25",
     },
   },
 
 
 ];
--- a/browser/extensions/formautofill/test/unit/test_createRecords.js
+++ b/browser/extensions/formautofill/test/unit/test_createRecords.js
@@ -230,41 +230,41 @@ const TESTCASES = [
   {
     description: "A credit card form with the value of cc-number, cc-exp, and cc-name.",
     document: `<form>
                 <input id="cc-number" autocomplete="cc-number">
                 <input id="cc-name" autocomplete="cc-name">
                 <input id="cc-exp" autocomplete="cc-exp">
                </form>`,
     formValue: {
-      "cc-number": "4444000022220000",
+      "cc-number": "5105105105105100",
       "cc-name": "Foo Bar",
       "cc-exp": "2022-06",
     },
     expectedRecord: {
       address: [],
       creditCard: [{
-        "cc-number": "4444000022220000",
+        "cc-number": "5105105105105100",
         "cc-name": "Foo Bar",
         "cc-exp": "2022-06",
       }],
     },
   },
   {
     description: "A credit card form with cc-number value only.",
     document: `<form>
                 <input id="cc-number" autocomplete="cc-number">
                </form>`,
     formValue: {
-      "cc-number": "4444000022220000",
+      "cc-number": "4111111111111111",
     },
     expectedRecord: {
       address: [],
       creditCard: [{
-        "cc-number": "4444000022220000",
+        "cc-number": "4111111111111111",
       }],
     },
   },
   {
     description: "A credit card form must have cc-number value.",
     document: `<form>
                 <input id="cc-number" autocomplete="cc-number">
                 <input id="cc-name" autocomplete="cc-name">
@@ -327,20 +327,20 @@ const TESTCASES = [
       "family-name-shipping": "Doe",
       "organization-shipping": "Mozilla",
       "country-shipping": "US",
 
       "given-name-billing": "Foo",
       "organization-billing": "Bar",
       "country-billing": "US",
 
-      "cc-number-section-one": "4444000022220000",
+      "cc-number-section-one": "4111111111111111",
       "cc-name-section-one": "John",
 
-      "cc-number-section-two": "4444000022221111",
+      "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",
@@ -350,20 +350,20 @@ const TESTCASES = [
         "organization": "Mozilla",
         "country": "US",
       }, {
         "given-name": "Foo",
         "organization": "Bar",
         "country": "US",
       }],
       creditCard: [{
-        "cc-number": "4444000022220000",
+        "cc-number": "4111111111111111",
         "cc-name": "John",
       }, {
-        "cc-number": "4444000022221111",
+        "cc-number": "5105105105105100",
         "cc-name": "Foo Bar",
         "cc-exp": "2026-26",
       }],
     },
   },
 
 ];
 
--- a/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
+++ b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
@@ -77,45 +77,45 @@ const TESTCASES = [
         creditCard: [],
       },
     },
   },
   {
     description: "Trigger credit card saving",
     formValue: {
       "cc-name": "John Doe",
-      "cc-number": "1234567812345678",
+      "cc-number": "5105105105105100",
       "cc-exp-month": 12,
       "cc-exp-year": 2000,
     },
     expectedResult: {
       formSubmission: true,
       records: {
         address: [],
         creditCard: [{
           guid: null,
           record: {
             "cc-name": "John Doe",
-            "cc-number": "1234567812345678",
+            "cc-number": "5105105105105100",
             "cc-exp-month": 12,
             "cc-exp-year": 2000,
           },
           untouchedFields: [],
         }],
       },
     },
   },
   {
     description: "Trigger address and credit card saving",
     formValue: {
       "street-addr": "331 E. Evelyn Avenue",
       "country": "USA",
       "tel": "1-650-903-0800",
       "cc-name": "John Doe",
-      "cc-number": "1234567812345678",
+      "cc-number": "5105105105105100",
       "cc-exp-month": 12,
       "cc-exp-year": 2000,
     },
     expectedResult: {
       formSubmission: true,
       records: {
         address: [{
           guid: null,
@@ -128,17 +128,17 @@ const TESTCASES = [
             "tel": "1-650-903-0800",
           },
           untouchedFields: [],
         }],
         creditCard: [{
           guid: null,
           record: {
             "cc-name": "John Doe",
-            "cc-number": "1234567812345678",
+            "cc-number": "5105105105105100",
             "cc-exp-month": 12,
             "cc-exp-year": 2000,
           },
           untouchedFields: [],
         }],
       },
     },
   },
--- a/browser/extensions/formautofill/test/unit/test_transformFields.js
+++ b/browser/extensions/formautofill/test/unit/test_transformFields.js
@@ -521,19 +521,21 @@ const ADDRESS_NORMALIZE_TESTCASES = [
 ];
 
 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
   {
@@ -547,66 +549,76 @@ const CREDIT_CARD_COMPUTE_TESTCASES = [
   },
 
   // 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",
@@ -633,161 +645,191 @@ const CREDIT_CARD_NORMALIZE_TESTCASES = 
       "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",
     },
@@ -797,42 +839,48 @@ const CREDIT_CARD_NORMALIZE_TESTCASES = 
     },
   },
   {
     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]);
   }