mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
[PM-11588] Bugfix - parse user input value for combined expiry date when creating/adding a card cipher (#11103)
* simplify logic and fix some pattern-matching bugs * add first pass at parsing combined expiry year and month from user input * clean up code * fix broken three-digit parsing case * fix case where splitCombinedDateValues returns empty strings when the input is only a delimiter * fix incorrect expectation of falsy negative integers * clean up code * split out logic from parseYearMonthExpiry * move utils from vault to autofill
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { normalizeExpiryYearFormat } from "../../../autofill/utils";
|
||||
import { CardLinkedId as LinkedId } from "../../enums";
|
||||
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||
import { normalizeExpiryYearFormat } from "../../utils";
|
||||
|
||||
import { ItemView } from "./item.view";
|
||||
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { normalizeExpiryYearFormat, isCardExpired } from "@bitwarden/common/vault/utils";
|
||||
|
||||
function getExpiryYearValueFormats(currentCentury: string) {
|
||||
return [
|
||||
[-12, `${currentCentury}12`],
|
||||
[0, `${currentCentury}00`],
|
||||
[2043, "2043"], // valid year with a length of four should be taken directly
|
||||
[24, `${currentCentury}24`],
|
||||
[3054, "3054"], // valid year with a length of four should be taken directly
|
||||
[31423524543, `${currentCentury}43`],
|
||||
[4, `${currentCentury}04`],
|
||||
[null, null],
|
||||
[undefined, null],
|
||||
["-12", `${currentCentury}12`],
|
||||
["", null],
|
||||
["0", `${currentCentury}00`],
|
||||
["00", `${currentCentury}00`],
|
||||
["000", `${currentCentury}00`],
|
||||
["0000", `${currentCentury}00`],
|
||||
["00000", `${currentCentury}00`],
|
||||
["0234234", `${currentCentury}34`],
|
||||
["04", `${currentCentury}04`],
|
||||
["2043", "2043"], // valid year with a length of four should be taken directly
|
||||
["24", `${currentCentury}24`],
|
||||
["3054", "3054"], // valid year with a length of four should be taken directly
|
||||
["31423524543", `${currentCentury}43`],
|
||||
["4", `${currentCentury}04`],
|
||||
["aaaa", null],
|
||||
["adgshsfhjsdrtyhsrth", null],
|
||||
["agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", `${currentCentury}45`],
|
||||
];
|
||||
}
|
||||
|
||||
describe("normalizeExpiryYearFormat", () => {
|
||||
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
|
||||
|
||||
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
|
||||
|
||||
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
|
||||
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
|
||||
const formattedValue = normalizeExpiryYearFormat(inputValue);
|
||||
|
||||
expect(formattedValue).toEqual(expectedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the year 3107", () => {
|
||||
const theDistantFuture = new Date(Date.UTC(3107, 1, 1));
|
||||
jest.spyOn(Date, "now").mockReturnValue(theDistantFuture.valueOf());
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.setSystemTime(theDistantFuture);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const currentCentury = `${new Date(Date.now()).getFullYear()}`.slice(0, 2);
|
||||
expect(currentCentury).toBe("31");
|
||||
|
||||
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
|
||||
|
||||
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
|
||||
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
|
||||
const formattedValue = normalizeExpiryYearFormat(inputValue);
|
||||
|
||||
expect(formattedValue).toEqual(expectedValue);
|
||||
});
|
||||
});
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
});
|
||||
|
||||
function getCardExpiryDateValues() {
|
||||
const currentDate = new Date();
|
||||
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
// `Date` months are zero-indexed, our expiry date month inputs are one-indexed
|
||||
const currentMonth = currentDate.getMonth() + 1;
|
||||
|
||||
return [
|
||||
[null, null, false], // no month, no year
|
||||
[undefined, undefined, false], // no month, no year, invalid values
|
||||
["", "", false], // no month, no year, invalid values
|
||||
["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values
|
||||
["0", `${currentYear - 1}`, true], // invalid 0 month
|
||||
["00", `${currentYear + 1}`, false], // invalid 0 month
|
||||
[`${currentMonth}`, "0000", true], // current month, in the year 2000
|
||||
[null, `${currentYear}`.slice(-2), false], // no month, this year
|
||||
[null, `${currentYear - 1}`.slice(-2), true], // no month, last year
|
||||
["1", null, false], // no year, January
|
||||
["1", `${currentYear - 1}`, true], // January last year
|
||||
["13", `${currentYear}`, false], // 12 + 1 is Feb. in the next year (Date is zero-indexed)
|
||||
[`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired
|
||||
[`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over)
|
||||
[`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over)
|
||||
[`${currentMonth - 1}`, `${currentYear}`, true], // last month
|
||||
[`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now
|
||||
];
|
||||
}
|
||||
|
||||
describe("isCardExpired", () => {
|
||||
const expiryYearValueFormats = getCardExpiryDateValues();
|
||||
|
||||
expiryYearValueFormats.forEach(
|
||||
([inputMonth, inputYear, expectedValue]: [string | null, string | null, boolean]) => {
|
||||
it(`should return ${expectedValue} when the card expiry month is ${inputMonth} and the card expiry year is ${inputYear}`, () => {
|
||||
const testCardView = new CardView();
|
||||
testCardView.expMonth = inputMonth;
|
||||
testCardView.expYear = inputYear;
|
||||
|
||||
const cardIsExpired = isCardExpired(testCardView);
|
||||
|
||||
expect(cardIsExpired).toBe(expectedValue);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
|
||||
type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
|
||||
|
||||
/**
|
||||
* Takes a string or number value and returns a string value formatted as a valid 4-digit year
|
||||
*
|
||||
* @export
|
||||
* @param {(string | number)} yearInput
|
||||
* @return {*} {(Year | null)}
|
||||
*/
|
||||
export function normalizeExpiryYearFormat(yearInput: string | number): Year | null {
|
||||
// The input[type="number"] is returning a number, convert it to a string
|
||||
// An empty field returns null, avoid casting `"null"` to a string
|
||||
const yearInputIsEmpty = yearInput == null || yearInput === "";
|
||||
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
|
||||
|
||||
// Exit early if year is already formatted correctly or empty
|
||||
if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) {
|
||||
return expirationYear as Year;
|
||||
}
|
||||
|
||||
expirationYear = expirationYear
|
||||
// For safety, because even input[type="number"] will allow decimals
|
||||
.replace(/[^\d]/g, "")
|
||||
// remove any leading zero padding (leave the last leading zero if it ends the string)
|
||||
.replace(/^[0]+(?=.)/, "");
|
||||
|
||||
if (expirationYear === "") {
|
||||
expirationYear = null;
|
||||
}
|
||||
|
||||
// given the context of payment card expiry, a year character length of 3, or over 4
|
||||
// is more likely to be a mistake than an intentional value for the far past or far future.
|
||||
if (expirationYear && expirationYear.length !== 4) {
|
||||
const paddedYear = ("00" + expirationYear).slice(-2);
|
||||
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
|
||||
|
||||
expirationYear = currentCentury + paddedYear;
|
||||
}
|
||||
|
||||
return expirationYear as Year | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
|
||||
* the card is expired.
|
||||
*
|
||||
* @export
|
||||
* @param {CardView} cipherCard
|
||||
* @return {*} {boolean}
|
||||
*/
|
||||
export function isCardExpired(cipherCard: CardView): boolean {
|
||||
if (cipherCard) {
|
||||
const { expMonth = null, expYear = null } = cipherCard;
|
||||
|
||||
const now = new Date();
|
||||
const normalizedYear = normalizeExpiryYearFormat(expYear);
|
||||
|
||||
// If the card year is before the current year, don't bother checking the month
|
||||
if (normalizedYear && parseInt(normalizedYear) < now.getFullYear()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedYear && expMonth) {
|
||||
// `Date` months are zero-indexed
|
||||
const parsedMonth =
|
||||
parseInt(expMonth) - 1 ||
|
||||
// Add a month floor of 0 to protect against an invalid low month value of "0"
|
||||
0;
|
||||
|
||||
const parsedYear = parseInt(normalizedYear);
|
||||
|
||||
// First day of the next month minus one, to get last day of the card month
|
||||
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 0);
|
||||
|
||||
return cardExpiry < now;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user