[PM-11350] Use shared expiration year normalization util function (#10735)
* use shared expiration year normalization util function * use shared exp year normalization in web and desktop client * handle cases where input has leading zeroes * add utils tests * handle cases where input is all zeroes
This commit is contained in:
parent
b27dc44298
commit
5f2eecd7be
|
@ -29,6 +29,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp
|
|||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
|
||||
|
@ -1095,7 +1096,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||
fillFields.expYear.maxLength === 4
|
||||
) {
|
||||
if (expYear.length === 2) {
|
||||
expYear = "20" + expYear;
|
||||
expYear = normalizeExpiryYearFormat(expYear);
|
||||
}
|
||||
} else if (
|
||||
this.fieldAttrsContain(fillFields.expYear, "yy") ||
|
||||
|
@ -1121,7 +1122,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||
let partYear: string = null;
|
||||
if (fullYear.length === 2) {
|
||||
partYear = fullYear;
|
||||
fullYear = "20" + fullYear;
|
||||
fullYear = normalizeExpiryYearFormat(fullYear);
|
||||
} else if (fullYear.length === 4) {
|
||||
partYear = fullYear.substr(2, 2);
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti
|
|||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
|
@ -182,6 +183,11 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit {
|
|||
const { isFido2Session, sessionId, userVerification } = fido2SessionData;
|
||||
const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session;
|
||||
|
||||
// normalize card expiry year on save
|
||||
if (this.cipher.type === this.cipherType.Card) {
|
||||
this.cipher.card.expYear = normalizeExpiryYearFormat(this.cipher.card.expYear);
|
||||
}
|
||||
|
||||
// TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production.
|
||||
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
|
||||
if (
|
||||
|
|
|
@ -37,6 +37,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
|
|||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
|
@ -330,6 +331,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||
return this.restore();
|
||||
}
|
||||
|
||||
// normalize card expiry year on save
|
||||
if (this.cipher.type === this.cipherType.Card) {
|
||||
this.cipher.card.expYear = normalizeExpiryYearFormat(this.cipher.card.expYear);
|
||||
}
|
||||
|
||||
if (this.cipher.name == null || this.cipher.name === "") {
|
||||
this.platformUtilsService.showToast(
|
||||
"error",
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
|
|||
|
||||
import { CardLinkedId as LinkedId } from "../../enums";
|
||||
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||
import { normalizeExpiryYearFormat } from "../../utils";
|
||||
|
||||
import { ItemView } from "./item.view";
|
||||
|
||||
|
@ -65,17 +66,16 @@ export class CardView extends ItemView {
|
|||
}
|
||||
|
||||
get expiration(): string {
|
||||
if (!this.expMonth && !this.expYear) {
|
||||
const normalizedYear = normalizeExpiryYearFormat(this.expYear);
|
||||
|
||||
if (!this.expMonth && !normalizedYear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let exp = this.expMonth != null ? ("0" + this.expMonth).slice(-2) : "__";
|
||||
exp += " / " + (this.expYear != null ? this.formatYear(this.expYear) : "____");
|
||||
return exp;
|
||||
}
|
||||
exp += " / " + (normalizedYear || "____");
|
||||
|
||||
private formatYear(year: string): string {
|
||||
return year.length === 2 ? "20" + year : year;
|
||||
return exp;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Partial<Jsonify<CardView>>): CardView {
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
import { normalizeExpiryYearFormat } 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();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
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;
|
||||
}
|
|
@ -11,6 +11,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
|||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
||||
|
||||
import { ImportResult } from "../models/import-result";
|
||||
|
||||
|
@ -263,7 +264,8 @@ export abstract class BaseImporter {
|
|||
|
||||
cipher.card.expMonth = expiryMatch.groups.month;
|
||||
const year: string = expiryMatch.groups.year;
|
||||
cipher.card.expYear = year.length === 2 ? "20" + year : year;
|
||||
cipher.card.expYear = normalizeExpiryYearFormat(year);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
|||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
||||
import {
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
|
@ -101,9 +102,7 @@ export class CardDetailsSectionComponent implements OnInit {
|
|||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(({ cardholderName, number, brand, expMonth, expYear, code }) => {
|
||||
this.cipherFormContainer.patchCipher((cipher) => {
|
||||
// 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 expirationYear = expYear !== null ? `${expYear}` : null;
|
||||
const expirationYear = normalizeExpiryYearFormat(expYear);
|
||||
|
||||
Object.assign(cipher.card, {
|
||||
cardholderName,
|
||||
|
|
Loading…
Reference in New Issue