Bug 1476204 - Check Luhn algorithm in the basic-card-form and in storage and disable save button when invalid. r=jaws draft
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Thu, 26 Jul 2018 13:40:22 -0700
changeset 823224 4dcbee371cd0e30b5823b803c4f4734f897ec786
parent 823223 9980f4f0fc57cccdbee3b248a94666f3e288be63
child 823225 07cc1137c4f925a4f5554c2680c624d59da5fcd8
push id117615
push usermozilla@noorenberghe.ca
push dateThu, 26 Jul 2018 20:45:10 +0000
reviewersjaws
bugs1476204
milestone63.0a1
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]);
   }