mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-25 12:15:18 +01: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:
parent
c8084cc4e3
commit
e88e231d48
@ -20,6 +20,7 @@ import {
|
|||||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||||
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
||||||
|
import { parseYearMonthExpiry } from "@bitwarden/common/autofill/utils";
|
||||||
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
|
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
|
||||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import {
|
import {
|
||||||
@ -1898,11 +1899,21 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
|||||||
const cardView = new CardView();
|
const cardView = new CardView();
|
||||||
cardView.cardholderName = card.cardholderName || "";
|
cardView.cardholderName = card.cardholderName || "";
|
||||||
cardView.number = card.number || "";
|
cardView.number = card.number || "";
|
||||||
cardView.expMonth = card.expirationMonth || "";
|
|
||||||
cardView.expYear = card.expirationYear || "";
|
|
||||||
cardView.code = card.cvv || "";
|
cardView.code = card.cvv || "";
|
||||||
cardView.brand = card.number ? CardView.getCardBrandByPatterns(card.number) : "";
|
cardView.brand = card.number ? CardView.getCardBrandByPatterns(card.number) : "";
|
||||||
|
|
||||||
|
// If there's a combined expiration date value and no individual month or year values,
|
||||||
|
// try to parse them from the combined value
|
||||||
|
if (card.expirationDate && !card.expirationMonth && !card.expirationYear) {
|
||||||
|
const [parsedYear, parsedMonth] = parseYearMonthExpiry(card.expirationDate);
|
||||||
|
|
||||||
|
cardView.expMonth = parsedMonth || "";
|
||||||
|
cardView.expYear = parsedYear || "";
|
||||||
|
} else {
|
||||||
|
cardView.expMonth = card.expirationMonth || "";
|
||||||
|
cardView.expYear = card.expirationYear || "";
|
||||||
|
}
|
||||||
|
|
||||||
const cipherView = new CipherView();
|
const cipherView = new CipherView();
|
||||||
cipherView.name = "";
|
cipherView.name = "";
|
||||||
cipherView.folderId = null;
|
cipherView.folderId = null;
|
||||||
|
@ -300,8 +300,6 @@ export class CreditCardAutoFillConstants {
|
|||||||
"cb-type",
|
"cb-type",
|
||||||
];
|
];
|
||||||
|
|
||||||
static readonly CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "];
|
|
||||||
|
|
||||||
// Note, these are expressions of user-guidance for the expected expiry date format to be used
|
// Note, these are expressions of user-guidance for the expected expiry date format to be used
|
||||||
static readonly CardExpiryDateFormats: CardExpiryDateFormat[] = [
|
static readonly CardExpiryDateFormats: CardExpiryDateFormat[] = [
|
||||||
// English
|
// English
|
||||||
|
@ -6,11 +6,15 @@ import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions
|
|||||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
import {
|
||||||
|
AutofillOverlayVisibility,
|
||||||
|
CardExpiryDateDelimiters,
|
||||||
|
} from "@bitwarden/common/autofill/constants";
|
||||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||||
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
|
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
|
||||||
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
||||||
|
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
import { EventType } from "@bitwarden/common/enums";
|
import { EventType } from "@bitwarden/common/enums";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
@ -30,7 +34,6 @@ import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
|||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||||
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.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 { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
|
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
|
||||||
@ -1397,8 +1400,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
if (expectedExpiryDateFormat) {
|
if (expectedExpiryDateFormat) {
|
||||||
const { Month, MonthShort, Year } = expiryDateFormatPatterns;
|
const { Month, MonthShort, Year } = expiryDateFormatPatterns;
|
||||||
|
|
||||||
const expiryDateDelimitersPattern =
|
const expiryDateDelimitersPattern = "\\" + CardExpiryDateDelimiters.join("\\");
|
||||||
"\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\");
|
|
||||||
|
|
||||||
// assign the delimiter from the expected format string
|
// assign the delimiter from the expected format string
|
||||||
delimiter =
|
delimiter =
|
||||||
@ -1450,8 +1452,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
let expectedDateFormat = null;
|
let expectedDateFormat = null;
|
||||||
let dateFormatPatterns = null;
|
let dateFormatPatterns = null;
|
||||||
|
|
||||||
const expiryDateDelimitersPattern =
|
const expiryDateDelimitersPattern = "\\" + CardExpiryDateDelimiters.join("\\");
|
||||||
"\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\");
|
|
||||||
|
|
||||||
CreditCardAutoFillConstants.CardExpiryDateFormats.find((dateFormat) => {
|
CreditCardAutoFillConstants.CardExpiryDateFormats.find((dateFormat) => {
|
||||||
dateFormatPatterns = dateFormat;
|
dateFormatPatterns = dateFormat;
|
||||||
@ -1489,6 +1490,8 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// @TODO if expectedDateFormat is still null, and there is a `pattern` attribute, cycle
|
||||||
|
// through generated formatted values, checking against the provided regex pattern
|
||||||
|
|
||||||
return [expectedDateFormat, dateFormatPatterns];
|
return [expectedDateFormat, dateFormatPatterns];
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
|
|||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
|
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
@ -23,7 +24,6 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti
|
|||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
|
|||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { isCardExpired } from "@bitwarden/common/autofill/utils";
|
||||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||||
import { EventType } from "@bitwarden/common/enums";
|
import { EventType } from "@bitwarden/common/enums";
|
||||||
@ -24,7 +25,6 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
|
|||||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
|
import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
|
||||||
import { isCardExpired } from "@bitwarden/common/vault/utils";
|
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||||
|
@ -12,6 +12,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
|||||||
import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||||
import { EventType } from "@bitwarden/common/enums";
|
import { EventType } from "@bitwarden/common/enums";
|
||||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
@ -36,7 +37,6 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
|
|||||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.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 { DialogService } from "@bitwarden/components";
|
||||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||||
|
|
||||||
|
@ -109,3 +109,5 @@ export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof Extens
|
|||||||
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute
|
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute
|
||||||
|
|
||||||
export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4;
|
export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4;
|
||||||
|
|
||||||
|
export * from "./match-patterns";
|
||||||
|
26
libs/common/src/autofill/constants/match-patterns.ts
Normal file
26
libs/common/src/autofill/constants/match-patterns.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export const CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "];
|
||||||
|
|
||||||
|
// `CardExpiryDateDelimiters` is not intended solely for regex consumption,
|
||||||
|
// so we need to format it here
|
||||||
|
export const ExpiryDateDelimitersPattern =
|
||||||
|
"\\" +
|
||||||
|
CardExpiryDateDelimiters.join("\\")
|
||||||
|
// replace space character with the regex whitespace character class
|
||||||
|
.replace(" ", "s");
|
||||||
|
|
||||||
|
export const MonthPattern = "(([1]{1}[0-2]{1})|(0?[1-9]{1}))";
|
||||||
|
|
||||||
|
// Because we're dealing with expiry dates, we assume the year will be in current or next century (as of 2024)
|
||||||
|
export const ExpiryFullYearPattern = "2[0-1]{1}\\d{2}";
|
||||||
|
|
||||||
|
export const DelimiterPatternExpression = new RegExp(`[${ExpiryDateDelimitersPattern}]`, "g");
|
||||||
|
|
||||||
|
export const IrrelevantExpiryCharactersPatternExpression = new RegExp(
|
||||||
|
// "nor digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w
|
||||||
|
`[^\\d${ExpiryDateDelimitersPattern}]`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MonthPatternExpression = new RegExp(`^${MonthPattern}$`);
|
||||||
|
|
||||||
|
export const ExpiryFullYearPatternExpression = new RegExp(`^${ExpiryFullYearPattern}$`);
|
284
libs/common/src/autofill/utils.spec.ts
Normal file
284
libs/common/src/autofill/utils.spec.ts
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import {
|
||||||
|
normalizeExpiryYearFormat,
|
||||||
|
isCardExpired,
|
||||||
|
parseYearMonthExpiry,
|
||||||
|
} from "@bitwarden/common/autofill/utils";
|
||||||
|
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||||
|
|
||||||
|
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}`, true], // invalid month
|
||||||
|
["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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const combinedDateTestValues = [
|
||||||
|
" 2024 / 05 ",
|
||||||
|
"05 2024",
|
||||||
|
"05 2024", // Tab whitespace character
|
||||||
|
"05 2024", // Em Quad
|
||||||
|
"05 2024", // Em Space
|
||||||
|
"05 2024", // En Quad
|
||||||
|
"05 2024", // En Space
|
||||||
|
"05 2024", // Figure Space
|
||||||
|
"05 2024", // Four-Per-Em Space
|
||||||
|
"05 2024", // Hair Space
|
||||||
|
"05 2024", // Ideographic Space
|
||||||
|
"05 2024", // Medium Mathematical Space
|
||||||
|
"05 2024", // No-Break Space
|
||||||
|
"05 2024", // ogham space mark
|
||||||
|
"05 2024", // Punctuation Space
|
||||||
|
"05 2024", // Six-Per-Em Space
|
||||||
|
"05 2024", // Thin Space
|
||||||
|
"05 2024", // Three-Per-Em Space
|
||||||
|
"05 24",
|
||||||
|
"05-2024",
|
||||||
|
"05-24",
|
||||||
|
"05.2024",
|
||||||
|
"05.24",
|
||||||
|
"05/2024",
|
||||||
|
"05/24",
|
||||||
|
"052024",
|
||||||
|
"0524",
|
||||||
|
"2024 05",
|
||||||
|
"2024 5",
|
||||||
|
"2024-05",
|
||||||
|
"2024-5",
|
||||||
|
"2024.05",
|
||||||
|
"2024.5",
|
||||||
|
"2024/05",
|
||||||
|
"2024/5",
|
||||||
|
"202405",
|
||||||
|
"20245",
|
||||||
|
"24 05",
|
||||||
|
"24 5",
|
||||||
|
"24-05",
|
||||||
|
"24-5",
|
||||||
|
"24.05",
|
||||||
|
"24.5",
|
||||||
|
"24/05",
|
||||||
|
"24/5",
|
||||||
|
"2405",
|
||||||
|
"5 2024",
|
||||||
|
"5 24",
|
||||||
|
"5-2024",
|
||||||
|
"5-24",
|
||||||
|
"5.2024",
|
||||||
|
"5.24",
|
||||||
|
"5/2024",
|
||||||
|
"5/24",
|
||||||
|
"52024",
|
||||||
|
];
|
||||||
|
const expectedParsedValue = ["2024", "5"];
|
||||||
|
describe("parseYearMonthExpiry", () => {
|
||||||
|
it('returns "null" expiration year and month values when a value of "" is passed', () => {
|
||||||
|
expect(parseYearMonthExpiry("")).toStrictEqual([null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "null" expiration year and month values when a value of "/" is passed', () => {
|
||||||
|
expect(parseYearMonthExpiry("/")).toStrictEqual([null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
combinedDateTestValues.forEach((combinedDate) => {
|
||||||
|
it(`returns an expiration year value of "${expectedParsedValue[0]}" and month value of "${expectedParsedValue[1]}" when a value of "${combinedDate}" is passed`, () => {
|
||||||
|
expect(parseYearMonthExpiry(combinedDate)).toStrictEqual(expectedParsedValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an expiration year value of "2002" and month value of "2" when a value of "022" is passed', () => {
|
||||||
|
expect(parseYearMonthExpiry("022")).toStrictEqual(["2002", "2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an expiration year value of "2002" and month value of "2" when a value of "202" is passed', () => {
|
||||||
|
expect(parseYearMonthExpiry("202")).toStrictEqual(["2002", "2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an expiration year value of "2002" and month value of "1" when a value of "1/2/3/4" is passed', () => {
|
||||||
|
expect(parseYearMonthExpiry("1/2/3/4")).toStrictEqual(["2002", "1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid expiration year and month values when a value of "198" is passed', () => {
|
||||||
|
// This static value will cause the test to fail in 2098
|
||||||
|
const testValue = "198";
|
||||||
|
const parsedValue = parseYearMonthExpiry(testValue);
|
||||||
|
|
||||||
|
expect(parsedValue[0]).toHaveLength(4);
|
||||||
|
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||||
|
|
||||||
|
expect(parsedValue).toStrictEqual(["2098", "1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ambiguous input cases: we use try/catch for these cases as a workaround to accept either
|
||||||
|
// outcome (both are valid interpretations) in the event of any future code changes.
|
||||||
|
describe("ambiguous input cases", () => {
|
||||||
|
it('returns valid expiration year and month values when a value of "111" is passed', () => {
|
||||||
|
const testValue = "111";
|
||||||
|
const parsedValue = parseYearMonthExpiry(testValue);
|
||||||
|
|
||||||
|
expect(parsedValue[0]).toHaveLength(4);
|
||||||
|
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(parsedValue).toStrictEqual(["2011", "1"]);
|
||||||
|
} catch {
|
||||||
|
expect(parsedValue).toStrictEqual(["2001", "11"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid expiration year and month values when a value of "212" is passed', () => {
|
||||||
|
const testValue = "212";
|
||||||
|
const parsedValue = parseYearMonthExpiry(testValue);
|
||||||
|
|
||||||
|
expect(parsedValue[0]).toHaveLength(4);
|
||||||
|
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(parsedValue).toStrictEqual(["2012", "2"]);
|
||||||
|
} catch {
|
||||||
|
expect(parsedValue).toStrictEqual(["2021", "2"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid expiration year and month values when a value of "245" is passed', () => {
|
||||||
|
const testValue = "245";
|
||||||
|
const parsedValue = parseYearMonthExpiry(testValue);
|
||||||
|
|
||||||
|
expect(parsedValue[0]).toHaveLength(4);
|
||||||
|
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(parsedValue).toStrictEqual(["2045", "2"]);
|
||||||
|
} catch {
|
||||||
|
expect(parsedValue).toStrictEqual(["2024", "5"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid expiration year and month values when a value of "524" is passed', () => {
|
||||||
|
const testValue = "524";
|
||||||
|
const parsedValue = parseYearMonthExpiry(testValue);
|
||||||
|
|
||||||
|
expect(parsedValue[0]).toHaveLength(4);
|
||||||
|
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(parsedValue).toStrictEqual(["2024", "5"]);
|
||||||
|
} catch {
|
||||||
|
expect(parsedValue).toStrictEqual(["2052", "4"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
307
libs/common/src/autofill/utils.ts
Normal file
307
libs/common/src/autofill/utils.ts
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import {
|
||||||
|
DelimiterPatternExpression,
|
||||||
|
ExpiryFullYearPattern,
|
||||||
|
ExpiryFullYearPatternExpression,
|
||||||
|
IrrelevantExpiryCharactersPatternExpression,
|
||||||
|
MonthPatternExpression,
|
||||||
|
} from "@bitwarden/common/autofill/constants";
|
||||||
|
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
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*
|
||||||
|
* @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, 10) < now.getFullYear()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedYear && expMonth) {
|
||||||
|
const parsedMonthInteger = parseInt(expMonth, 10);
|
||||||
|
|
||||||
|
const parsedMonth = isNaN(parsedMonthInteger)
|
||||||
|
? 0
|
||||||
|
: // Add a month floor of 0 to protect against an invalid low month value of "0" or negative integers
|
||||||
|
Math.max(
|
||||||
|
// `Date` months are zero-indexed
|
||||||
|
parsedMonthInteger - 1,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedYear = parseInt(normalizedYear, 10);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to split a string into date segments on the basis of expected formats and delimiter symbols.
|
||||||
|
*
|
||||||
|
* @param {string} combinedExpiryValue
|
||||||
|
* @return {*} {string[]}
|
||||||
|
*/
|
||||||
|
function splitCombinedDateValues(combinedExpiryValue: string): string[] {
|
||||||
|
let sanitizedValue = combinedExpiryValue
|
||||||
|
.replace(IrrelevantExpiryCharactersPatternExpression, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Do this after initial value replace to avoid identifying leading whitespace as delimiter
|
||||||
|
const parsedDelimiter = sanitizedValue.match(DelimiterPatternExpression)?.[0] || null;
|
||||||
|
|
||||||
|
let dateParts = [sanitizedValue];
|
||||||
|
|
||||||
|
if (parsedDelimiter?.length) {
|
||||||
|
// If the parsed delimiter is a whitespace character, assign 's' (character class) instead
|
||||||
|
const delimiterPattern = /\s/.test(parsedDelimiter) ? "\\s" : "\\" + parsedDelimiter;
|
||||||
|
|
||||||
|
sanitizedValue = sanitizedValue
|
||||||
|
// Remove all other delimiter characters not identified as the delimiter
|
||||||
|
.replace(new RegExp(`[^\\d${delimiterPattern}]`, "g"), "")
|
||||||
|
// Also de-dupe the delimiter character
|
||||||
|
.replace(new RegExp(`[${delimiterPattern}]{2,}`, "g"), parsedDelimiter);
|
||||||
|
|
||||||
|
dateParts = sanitizedValue.split(parsedDelimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
dateParts
|
||||||
|
// remove values that have no length
|
||||||
|
.filter((splitValue) => splitValue?.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an array of split card expiry date parts,
|
||||||
|
* returns an array of those values ordered by year then month
|
||||||
|
*
|
||||||
|
* @param {string[]} splitDateInput
|
||||||
|
* @return {*} {([string | null, string | null])}
|
||||||
|
*/
|
||||||
|
function parseDelimitedYearMonthExpiry([firstPart, secondPart]: string[]): [string, string] {
|
||||||
|
// Conditionals here are structured to avoid unnecessary evaluations and are ordered
|
||||||
|
// from more authoritative checks to checks yielding increasingly inferred conclusions
|
||||||
|
|
||||||
|
// If a 4-digit value is found (when there are multiple parts), it can't be month
|
||||||
|
if (ExpiryFullYearPatternExpression.test(firstPart)) {
|
||||||
|
return [firstPart, secondPart];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a 4-digit value is found (when there are multiple parts), it can't be month
|
||||||
|
if (ExpiryFullYearPatternExpression.test(secondPart)) {
|
||||||
|
return [secondPart, firstPart];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a two digit value that doesn't match against month pattern, assume it's a year
|
||||||
|
if (/\d{2}/.test(firstPart) && !MonthPatternExpression.test(firstPart)) {
|
||||||
|
return [firstPart, secondPart];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a two digit value that doesn't match against month pattern, assume it's a year
|
||||||
|
if (/\d{2}/.test(secondPart) && !MonthPatternExpression.test(secondPart)) {
|
||||||
|
return [secondPart, firstPart];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values are too ambiguous (e.g. "12/09"). For the most part,
|
||||||
|
// a month-looking value likely is, at the time of writing (year 2024).
|
||||||
|
let parsedYear = firstPart;
|
||||||
|
let parsedMonth = secondPart;
|
||||||
|
|
||||||
|
if (MonthPatternExpression.test(firstPart)) {
|
||||||
|
parsedYear = secondPart;
|
||||||
|
parsedMonth = firstPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [parsedYear, parsedMonth];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a single string of integers, attempts to identify card expiry date portions within
|
||||||
|
* and return values ordered by year then month
|
||||||
|
*
|
||||||
|
* @param {string} dateInput
|
||||||
|
* @return {*} {([string | null, string | null])}
|
||||||
|
*/
|
||||||
|
function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, string | null] {
|
||||||
|
if (dateInput.length > 4) {
|
||||||
|
// e.g.
|
||||||
|
// "052024"
|
||||||
|
// "202405"
|
||||||
|
// "20245"
|
||||||
|
// "52024"
|
||||||
|
|
||||||
|
// If the value is over 5-characters long, it likely has a full year format in it
|
||||||
|
const [parsedYear, parsedMonth] = dateInput
|
||||||
|
.split(new RegExp(`(?=${ExpiryFullYearPattern})|(?<=${ExpiryFullYearPattern})`, "g"))
|
||||||
|
.sort((current: string, next: string) => (current.length > next.length ? -1 : 1));
|
||||||
|
|
||||||
|
return [parsedYear, parsedMonth];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateInput.length === 4) {
|
||||||
|
// e.g.
|
||||||
|
// "0524"
|
||||||
|
// "2405"
|
||||||
|
|
||||||
|
// If the `sanitizedFirstPart` value is a length of 4, it must be split in half, since
|
||||||
|
// neither a year or month will be represented with three characters
|
||||||
|
const splitFirstPartFirstHalf = dateInput.slice(0, 2);
|
||||||
|
const splitFirstPartSecondHalf = dateInput.slice(-2);
|
||||||
|
|
||||||
|
let parsedYear = splitFirstPartSecondHalf;
|
||||||
|
let parsedMonth = splitFirstPartFirstHalf;
|
||||||
|
|
||||||
|
// If the first part doesn't match a month pattern, assume it's a year
|
||||||
|
if (!MonthPatternExpression.test(splitFirstPartFirstHalf)) {
|
||||||
|
parsedYear = splitFirstPartFirstHalf;
|
||||||
|
parsedMonth = splitFirstPartSecondHalf;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [parsedYear, parsedMonth];
|
||||||
|
}
|
||||||
|
|
||||||
|
// e.g.
|
||||||
|
// "245"
|
||||||
|
// "202"
|
||||||
|
// "212"
|
||||||
|
// "022"
|
||||||
|
// "111"
|
||||||
|
|
||||||
|
// A valid year representation here must be two characters so try to find it first.
|
||||||
|
|
||||||
|
let parsedYear = null;
|
||||||
|
let parsedMonth = null;
|
||||||
|
|
||||||
|
// Split if there is a digit with a leading zero
|
||||||
|
const splitFirstPartOnLeadingZero = dateInput.split(/(?<=0[1-9]{1})|(?=0[1-9]{1})/);
|
||||||
|
|
||||||
|
// Assume a leading zero indicates a month in ambiguous cases (e.g. "202"), since we're
|
||||||
|
// dealing with expiry dates and the next two-digit year with a leading zero will be 2100
|
||||||
|
if (splitFirstPartOnLeadingZero.length > 1) {
|
||||||
|
parsedYear = splitFirstPartOnLeadingZero[0];
|
||||||
|
parsedMonth = splitFirstPartOnLeadingZero[1];
|
||||||
|
|
||||||
|
if (splitFirstPartOnLeadingZero[0].startsWith("0")) {
|
||||||
|
parsedMonth = splitFirstPartOnLeadingZero[0];
|
||||||
|
parsedYear = splitFirstPartOnLeadingZero[1];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Here, a year has to be two-digits, and a month can't be more than one, so assume the first two digits that are greater than the current year is the year representation.
|
||||||
|
parsedYear = dateInput.slice(0, 2);
|
||||||
|
parsedMonth = dateInput.slice(-1);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10);
|
||||||
|
const normalizedParsedYearAlternative = parseInt(
|
||||||
|
normalizeExpiryYearFormat(dateInput.slice(-2)),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalizedParsedYear < currentYear && normalizedParsedYearAlternative >= currentYear) {
|
||||||
|
parsedYear = dateInput.slice(-2);
|
||||||
|
parsedMonth = dateInput.slice(0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [parsedYear, parsedMonth];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to parse year and month parts of a combined expiry date value.
|
||||||
|
*
|
||||||
|
* @param {string} combinedExpiryValue
|
||||||
|
* @return {*} {([string | null, string | null])}
|
||||||
|
*/
|
||||||
|
export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null, string | null] {
|
||||||
|
let parsedYear = null;
|
||||||
|
let parsedMonth = null;
|
||||||
|
|
||||||
|
const dateParts = splitCombinedDateValues(combinedExpiryValue);
|
||||||
|
|
||||||
|
if (dateParts.length < 1) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedFirstPart =
|
||||||
|
dateParts[0]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
|
||||||
|
const sanitizedSecondPart =
|
||||||
|
dateParts[1]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
|
||||||
|
|
||||||
|
// If there is only one date part, no delimiter was found in the passed value
|
||||||
|
if (dateParts.length === 1) {
|
||||||
|
[parsedYear, parsedMonth] = parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
|
||||||
|
}
|
||||||
|
// There are multiple date parts
|
||||||
|
else {
|
||||||
|
[parsedYear, parsedMonth] = parseDelimitedYearMonthExpiry([
|
||||||
|
sanitizedFirstPart,
|
||||||
|
sanitizedSecondPart,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedParsedYear = normalizeExpiryYearFormat(parsedYear);
|
||||||
|
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
|
||||||
|
|
||||||
|
// Set "empty" values to null
|
||||||
|
parsedYear = normalizedParsedYear?.length ? normalizedParsedYear : null;
|
||||||
|
parsedMonth = normalizedParsedMonth?.length ? normalizedParsedMonth : null;
|
||||||
|
|
||||||
|
return [parsedYear, parsedMonth];
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
import { Jsonify } from "type-fest";
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { normalizeExpiryYearFormat } from "../../../autofill/utils";
|
||||||
import { CardLinkedId as LinkedId } from "../../enums";
|
import { CardLinkedId as LinkedId } from "../../enums";
|
||||||
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||||
import { normalizeExpiryYearFormat } from "../../utils";
|
|
||||||
|
|
||||||
import { ItemView } from "./item.view";
|
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;
|
|
||||||
}
|
|
@ -1,5 +1,6 @@
|
|||||||
import * as papa from "papaparse";
|
import * as papa from "papaparse";
|
||||||
|
|
||||||
|
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||||
@ -11,7 +12,6 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
|||||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.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";
|
import { ImportResult } from "../models/import-result";
|
||||||
|
|
||||||
|
@ -5,11 +5,11 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
|||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||||
|
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||||
import { EventType } from "@bitwarden/common/enums";
|
import { EventType } from "@bitwarden/common/enums";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
|
||||||
import {
|
import {
|
||||||
CardComponent,
|
CardComponent,
|
||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
|
@ -5,13 +5,13 @@ import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
|
|||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
|
import { isCardExpired } from "@bitwarden/common/autofill/utils";
|
||||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
|
||||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||||
import { isCardExpired } from "@bitwarden/common/vault/utils";
|
|
||||||
import { CalloutModule, SearchModule } from "@bitwarden/components";
|
import { CalloutModule, SearchModule } from "@bitwarden/components";
|
||||||
|
|
||||||
import { AdditionalOptionsComponent } from "./additional-options/additional-options.component";
|
import { AdditionalOptionsComponent } from "./additional-options/additional-options.component";
|
||||||
|
Loading…
Reference in New Issue
Block a user