1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-11 10:10:25 +01:00

[PM-10418] Bugfix - Expiration date on cards does not always autofill the correct format (#10705)

* add branching logic for alternative card expiration autofill strategy

* simplify logic and fix some pattern-matching bugs

* add EnableNewCardCombinedExpiryAutofill feature flag

* update default format for card expiry date and update tests

* review reccs
This commit is contained in:
Jonathan Prusik 2024-09-06 11:24:04 -04:00 committed by GitHub
parent 56ededa947
commit 9881c7842b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 544 additions and 184 deletions

View File

@ -90,6 +90,7 @@ export class CreditCardAutoFillConstants {
"data-stripe", "data-stripe",
"htmlName", "htmlName",
"htmlID", "htmlID",
"title",
"label-tag", "label-tag",
"placeholder", "placeholder",
"label-left", "label-left",
@ -299,6 +300,54 @@ 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
static readonly CardExpiryDateFormats: CardExpiryDateFormat[] = [
// English
{
Month: "mm",
MonthShort: "m",
Year: "yyyy",
YearShort: "yy",
},
// Danish
{
Month: "mm",
MonthShort: "m",
Year: "åååå",
YearShort: "åå",
},
// German/Dutch
{
Month: "mm",
MonthShort: "m",
Year: "jjjj",
YearShort: "jj",
},
// French/Spanish/Italian
{
Month: "mm",
MonthShort: "m",
Year: "aa",
YearShort: "aa",
},
// Russian
{
Month: "мм",
MonthShort: "м",
Year: "гггг",
YearShort: "гг",
},
// Portuguese
{
Month: "mm",
MonthShort: "m",
Year: "rrrr",
YearShort: "rr",
},
];
// Each index represents a language. These three arrays should all be the same length. // Each index represents a language. These three arrays should all be the same length.
// 0: English, 1: Danish, 2: German/Dutch, 3: French/Spanish/Italian, 4: Russian, 5: Portuguese // 0: English, 1: Danish, 2: German/Dutch, 3: French/Spanish/Italian, 4: Russian, 5: Portuguese
static readonly MonthAbbr = ["mm", "mm", "mm", "mm", "мм", "mm"]; static readonly MonthAbbr = ["mm", "mm", "mm", "mm", "мм", "mm"];
@ -306,6 +355,13 @@ export class CreditCardAutoFillConstants {
static readonly YearAbbrLong = ["yyyy", "åååå", "jjjj", "aa", "гггг", "rrrr"]; static readonly YearAbbrLong = ["yyyy", "åååå", "jjjj", "aa", "гггг", "rrrr"];
} }
export type CardExpiryDateFormat = {
Month: string;
MonthShort: string;
Year: string;
YearShort: string;
};
export class IdentityAutoFillConstants { export class IdentityAutoFillConstants {
static readonly IdentityAttributes: string[] = [ static readonly IdentityAttributes: string[] = [
"autoCompleteType", "autoCompleteType",

View File

@ -2475,10 +2475,10 @@ describe("AutofillService", () => {
options.cipher.card = mock<CardView>(); options.cipher.card = mock<CardView>();
}); });
it("returns null if the passed options contains a cipher with no card view", () => { it("returns null if the passed options contains a cipher with no card view", async () => {
options.cipher.card = undefined; options.cipher.card = undefined;
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2499,7 +2499,7 @@ describe("AutofillService", () => {
untrustedIframe: false, untrustedIframe: false,
}; };
it("returns an unmodified fill script when the field is a `span` field", () => { it("returns an unmodified fill script when the field is a `span` field", async () => {
const spanField = createAutofillFieldMock({ const spanField = createAutofillFieldMock({
opid: "span-field", opid: "span-field",
form: "validFormId", form: "validFormId",
@ -2510,7 +2510,7 @@ describe("AutofillService", () => {
pageDetails.fields = [spanField]; pageDetails.fields = [spanField];
jest.spyOn(AutofillService, "isExcludedFieldType"); jest.spyOn(AutofillService, "isExcludedFieldType");
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2522,7 +2522,7 @@ describe("AutofillService", () => {
}); });
AutoFillConstants.ExcludedAutofillTypes.forEach((excludedType) => { AutoFillConstants.ExcludedAutofillTypes.forEach((excludedType) => {
it(`returns an unmodified fill script when the field has a '${excludedType}' type`, () => { it(`returns an unmodified fill script when the field has a '${excludedType}' type`, async () => {
const invalidField = createAutofillFieldMock({ const invalidField = createAutofillFieldMock({
opid: `${excludedType}-field`, opid: `${excludedType}-field`,
form: "validFormId", form: "validFormId",
@ -2533,7 +2533,7 @@ describe("AutofillService", () => {
pageDetails.fields = [invalidField]; pageDetails.fields = [invalidField];
jest.spyOn(AutofillService, "isExcludedFieldType"); jest.spyOn(AutofillService, "isExcludedFieldType");
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2548,7 +2548,7 @@ describe("AutofillService", () => {
}); });
}); });
it("returns an unmodified fill script when the field is not viewable", () => { it("returns an unmodified fill script when the field is not viewable", async () => {
const notViewableField = createAutofillFieldMock({ const notViewableField = createAutofillFieldMock({
opid: "invalid-field", opid: "invalid-field",
form: "validFormId", form: "validFormId",
@ -2561,7 +2561,7 @@ describe("AutofillService", () => {
jest.spyOn(AutofillService, "forCustomFieldsOnly"); jest.spyOn(AutofillService, "forCustomFieldsOnly");
jest.spyOn(AutofillService, "isExcludedFieldType"); jest.spyOn(AutofillService, "isExcludedFieldType");
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2663,8 +2663,8 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "makeScriptActionWithValue"); jest.spyOn(autofillService as any, "makeScriptActionWithValue");
}); });
it("returns a fill script containing all of the passed card fields", () => { it("returns a fill script containing all of the passed card fields", async () => {
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2745,11 +2745,11 @@ describe("AutofillService", () => {
options.cipher.card.expMonth = "05"; options.cipher.card.expMonth = "05";
}); });
it("returns an expiration month parsed from found select options within the field", () => { it("returns an expiration month parsed from found select options within the field", async () => {
const testValue = "sometestvalue"; const testValue = "sometestvalue";
expMonthField.selectInfo.options[4] = ["May", testValue]; expMonthField.selectInfo.options[4] = ["May", testValue];
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2759,12 +2759,12 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]);
}); });
it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the end of the list of options", () => { it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the end of the list of options", async () => {
const testValue = "sometestvalue"; const testValue = "sometestvalue";
expMonthField.selectInfo.options[4] = ["May", testValue]; expMonthField.selectInfo.options[4] = ["May", testValue];
expMonthField.selectInfo.options.push(["", ""]); expMonthField.selectInfo.options.push(["", ""]);
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2774,12 +2774,12 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]);
}); });
it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the start of the list of options", () => { it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the start of the list of options", async () => {
const testValue = "sometestvalue"; const testValue = "sometestvalue";
expMonthField.selectInfo.options[4] = ["May", testValue]; expMonthField.selectInfo.options[4] = ["May", testValue];
expMonthField.selectInfo.options.unshift(["", ""]); expMonthField.selectInfo.options.unshift(["", ""]);
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2789,13 +2789,13 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]);
}); });
it("returns an expiration month with a zero attached if the field requires two characters, and the vault item has only one character", () => { it("returns an expiration month with a zero attached if the field requires two characters, and the vault item has only one character", async () => {
options.cipher.card.expMonth = "5"; options.cipher.card.expMonth = "5";
expMonthField.selectInfo = null; expMonthField.selectInfo = null;
expMonthField.placeholder = "mm"; expMonthField.placeholder = "mm";
expMonthField.maxLength = 2; expMonthField.maxLength = 2;
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2830,12 +2830,12 @@ describe("AutofillService", () => {
options.cipher.card.expYear = "2024"; options.cipher.card.expYear = "2024";
}); });
it("returns an expiration year parsed from the select options if an exact match is found for either the select option text or value", () => { it("returns an expiration year parsed from the select options if an exact match is found for either the select option text or value", async () => {
const someTestValue = "sometestvalue"; const someTestValue = "sometestvalue";
expYearField.selectInfo.options[1] = ["2024", someTestValue]; expYearField.selectInfo.options[1] = ["2024", someTestValue];
options.cipher.card.expYear = someTestValue; options.cipher.card.expYear = someTestValue;
let value = autofillService["generateCardFillScript"]( let value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2846,7 +2846,7 @@ describe("AutofillService", () => {
expYearField.selectInfo.options[1] = [someTestValue, "2024"]; expYearField.selectInfo.options[1] = [someTestValue, "2024"];
value = autofillService["generateCardFillScript"]( value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2856,12 +2856,12 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, someTestValue]); expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, someTestValue]);
}); });
it("returns an expiration year parsed from the select options if the value of an option contains only two characters and the vault item value contains four characters", () => { it("returns an expiration year parsed from the select options if the value of an option contains only two characters and the vault item value contains four characters", async () => {
const yearValue = "26"; const yearValue = "26";
expYearField.selectInfo.options.push(["The year 2026", yearValue]); expYearField.selectInfo.options.push(["The year 2026", yearValue]);
options.cipher.card.expYear = "2026"; options.cipher.card.expYear = "2026";
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2871,13 +2871,13 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, yearValue]); expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, yearValue]);
}); });
it("returns an expiration year parsed from the select options if the vault of an option is separated by a colon", () => { it("returns an expiration year parsed from the select options if the vault of an option is separated by a colon", async () => {
const yearValue = "26"; const yearValue = "26";
const colonSeparatedYearValue = `2:0${yearValue}`; const colonSeparatedYearValue = `2:0${yearValue}`;
expYearField.selectInfo.options.push(["The year 2026", colonSeparatedYearValue]); expYearField.selectInfo.options.push(["The year 2026", colonSeparatedYearValue]);
options.cipher.card.expYear = yearValue; options.cipher.card.expYear = yearValue;
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2891,14 +2891,14 @@ describe("AutofillService", () => {
]); ]);
}); });
it("returns an expiration year with `20` prepended to the vault item value if the field to be filled expects a `yyyy` format but the vault item only has two characters", () => { it("returns an expiration year with `20` prepended to the vault item value if the field to be filled expects a `yyyy` format but the vault item only has two characters", async () => {
const yearValue = "26"; const yearValue = "26";
expYearField.selectInfo = null; expYearField.selectInfo = null;
expYearField.placeholder = "yyyy"; expYearField.placeholder = "yyyy";
expYearField.maxLength = 4; expYearField.maxLength = 4;
options.cipher.card.expYear = yearValue; options.cipher.card.expYear = yearValue;
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2912,14 +2912,14 @@ describe("AutofillService", () => {
]); ]);
}); });
it("returns an expiration year with only the last two values if the field to be filled expects a `yy` format but the vault item contains four characters", () => { it("returns an expiration year with only the last two values if the field to be filled expects a `yy` format but the vault item contains four characters", async () => {
const yearValue = "26"; const yearValue = "26";
expYearField.selectInfo = null; expYearField.selectInfo = null;
expYearField.placeholder = "yy"; expYearField.placeholder = "yy";
expYearField.maxLength = 2; expYearField.maxLength = 2;
options.cipher.card.expYear = `20${yearValue}`; options.cipher.card.expYear = `20${yearValue}`;
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2930,25 +2930,6 @@ describe("AutofillService", () => {
}); });
}); });
describe("given a generic expiration date field", () => {
let expirationDateField: AutofillField;
let expirationDateFieldView: FieldView;
beforeEach(() => {
expirationDateField = createAutofillFieldMock({
opid: "expirationDate",
form: "validFormId",
elementNumber: 3,
htmlName: "expiration-date",
});
filledFields["exp-field"] = expirationDateField;
expirationDateFieldView = mock<FieldView>({ name: "exp" });
pageDetails.fields = [expirationDateField];
options.cipher.fields = [expirationDateFieldView];
options.cipher.card.expMonth = "05";
options.cipher.card.expYear = "2024";
});
const expectedDateFormats = [ const expectedDateFormats = [
["mm/yyyy", "05/2024"], ["mm/yyyy", "05/2024"],
["mm/yy", "05/24"], ["mm/yy", "05/24"],
@ -2963,9 +2944,31 @@ describe("AutofillService", () => {
["mmyyyy", "052024"], ["mmyyyy", "052024"],
["mmyy", "0524"], ["mmyy", "0524"],
]; ];
describe("given a generic expiration date field", () => {
let expirationDateField: AutofillField;
let expirationDateFieldView: FieldView;
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
expirationDateField = createAutofillFieldMock({
opid: "expirationDate",
form: "validFormId",
elementNumber: 3,
htmlName: "expiration-date",
});
filledFields["exp-field"] = expirationDateField;
expirationDateFieldView = mock<FieldView>({ name: "exp" });
pageDetails.fields = [expirationDateField];
options.cipher.fields = [expirationDateFieldView];
options.cipher.card.expMonth = "05";
options.cipher.card.expYear = "2024";
});
expectedDateFormats.forEach((dateFormat, index) => { expectedDateFormats.forEach((dateFormat, index) => {
it(`returns an expiration date format matching '${dateFormat[0]}'`, () => { it(`returns an expiration date format matching '${dateFormat[0]}'`, async () => {
expirationDateField.placeholder = dateFormat[0]; expirationDateField.placeholder = dateFormat[0];
// test alternate stored cipher value formats
if (index === 0) { if (index === 0) {
options.cipher.card.expYear = "24"; options.cipher.card.expYear = "24";
} }
@ -2973,7 +2976,13 @@ describe("AutofillService", () => {
options.cipher.card.expMonth = "5"; options.cipher.card.expMonth = "5";
} }
const value = autofillService["generateCardFillScript"]( const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(false);
const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
@ -2984,17 +2993,128 @@ describe("AutofillService", () => {
}); });
}); });
it("returns an expiration date format matching `yyyy-mm` if no valid format can be identified", () => { it("returns an expiration date format matching `yyyy-mm` if no valid format can be identified", async () => {
const value = autofillService["generateCardFillScript"]( const value = await autofillService["generateCardFillScript"](
fillScript, fillScript,
pageDetails, pageDetails,
filledFields, filledFields,
options, options,
); );
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(false);
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "2024-05"]); expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "2024-05"]);
}); });
}); });
const extraExpectedDateFormats = [
...expectedDateFormats,
["m yy", "5 24"],
["m yyyy", "5 2024"],
["m-yy", "5-24"],
["m-yyyy", "5-2024"],
["m.yy", "5.24"],
["m.yyyy", "5.2024"],
["m/yy", "5/24"],
["m/yyyy", "5/2024"],
["mm åååå", "05 2024"],
["mm yy", "05 24"],
["mm yyyy", "05 2024"],
["mm.yy", "05.24"],
["mm.yyyy", "05.2024"],
["myy", "524"],
["myyyy", "52024"],
["yy m", "24 5"],
["yy mm", "24 05"],
["yy mm", "24 05"],
["yy-m", "24-5"],
["yy.m", "24.5"],
["yy.mm", "24.05"],
["yy/m", "24/5"],
["yym", "245"],
["yyyy m", "2024 5"],
["yyyy mm", "2024 05"],
["yyyy-m", "2024-5"],
["yyyy.m", "2024.5"],
["yyyy.mm", "2024.05"],
["yyyy/m", "2024/5"],
["yyyym", "20245"],
["мм гг", "05 24"],
];
describe("given a generic expiration date field with the `enable-new-card-combined-expiry-autofill` feature-flag enabled", () => {
let expirationDateField: AutofillField;
let expirationDateFieldView: FieldView;
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
expirationDateField = createAutofillFieldMock({
opid: "expirationDate",
form: "validFormId",
elementNumber: 3,
htmlName: "expiration-date",
});
filledFields["exp-field"] = expirationDateField;
expirationDateFieldView = mock<FieldView>({ name: "exp" });
pageDetails.fields = [expirationDateField];
options.cipher.fields = [expirationDateFieldView];
options.cipher.card.expMonth = "05";
options.cipher.card.expYear = "2024";
});
afterEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
});
extraExpectedDateFormats.forEach((dateFormat, index) => {
it(`feature-flagged logic returns an expiration date format matching '${dateFormat[0]}'`, async () => {
expirationDateField.placeholder = dateFormat[0];
// test alternate stored cipher value formats
if (index === 0) {
options.cipher.card.expYear = "24";
}
if (index === 1) {
options.cipher.card.expMonth = "05";
}
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(true);
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", dateFormat[1]]);
});
});
it("feature-flagged logic returns an expiration date format matching `mm/yy` if no valid format can be identified", async () => {
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(true);
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "05/24"]);
});
});
}); });
describe("inUntrustedIframe", () => { describe("inUntrustedIframe", () => {

View File

@ -26,6 +26,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { FieldType, CipherType } from "@bitwarden/common/vault/enums"; import { FieldType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
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";
@ -50,6 +51,7 @@ import {
} from "./abstractions/autofill.service"; } from "./abstractions/autofill.service";
import { import {
AutoFillConstants, AutoFillConstants,
CardExpiryDateFormat,
CreditCardAutoFillConstants, CreditCardAutoFillConstants,
IdentityAutoFillConstants, IdentityAutoFillConstants,
} from "./autofill-constants"; } from "./autofill-constants";
@ -721,7 +723,12 @@ export default class AutofillService implements AutofillServiceInterface {
); );
break; break;
case CipherType.Card: case CipherType.Card:
fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options); fillScript = await this.generateCardFillScript(
fillScript,
pageDetails,
filledFields,
options,
);
break; break;
case CipherType.Identity: case CipherType.Identity:
fillScript = await this.generateIdentityFillScript( fillScript = await this.generateIdentityFillScript(
@ -937,12 +944,12 @@ export default class AutofillService implements AutofillServiceInterface {
* @returns {AutofillScript|null} * @returns {AutofillScript|null}
* @private * @private
*/ */
private generateCardFillScript( private async generateCardFillScript(
fillScript: AutofillScript, fillScript: AutofillScript,
pageDetails: AutofillPageDetails, pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField }, filledFields: { [id: string]: AutofillField },
options: GenerateFillScriptOptions, options: GenerateFillScriptOptions,
): AutofillScript | null { ): Promise<AutofillScript | null> {
if (!options.cipher.card) { if (!options.cipher.card) {
return null; return null;
} }
@ -1027,6 +1034,7 @@ export default class AutofillService implements AutofillServiceInterface {
this.makeScriptAction(fillScript, card, fillFields, filledFields, "code"); this.makeScriptAction(fillScript, card, fillFields, filledFields, "code");
this.makeScriptAction(fillScript, card, fillFields, filledFields, "brand"); this.makeScriptAction(fillScript, card, fillFields, filledFields, "brand");
// There is an expiration month field and the cipher has an expiration month value
if (fillFields.expMonth && AutofillService.hasValue(card.expMonth)) { if (fillFields.expMonth && AutofillService.hasValue(card.expMonth)) {
let expMonth: string = card.expMonth; let expMonth: string = card.expMonth;
@ -1065,6 +1073,7 @@ export default class AutofillService implements AutofillServiceInterface {
AutofillService.fillByOpid(fillScript, fillFields.expMonth, expMonth); AutofillService.fillByOpid(fillScript, fillFields.expMonth, expMonth);
} }
// There is an expiration year field and the cipher has an expiration year value
if (fillFields.expYear && AutofillService.hasValue(card.expYear)) { if (fillFields.expYear && AutofillService.hasValue(card.expYear)) {
let expYear: string = card.expYear; let expYear: string = card.expYear;
if (fillFields.expYear.selectInfo && fillFields.expYear.selectInfo.options) { if (fillFields.expYear.selectInfo && fillFields.expYear.selectInfo.options) {
@ -1111,11 +1120,21 @@ export default class AutofillService implements AutofillServiceInterface {
AutofillService.fillByOpid(fillScript, fillFields.expYear, expYear); AutofillService.fillByOpid(fillScript, fillFields.expYear, expYear);
} }
// There is a single expiry date field (combined values) and the cipher has both expiration month and year
if ( if (
fillFields.exp && fillFields.exp &&
AutofillService.hasValue(card.expMonth) && AutofillService.hasValue(card.expMonth) &&
AutofillService.hasValue(card.expYear) AutofillService.hasValue(card.expYear)
) { ) {
let combinedExpiryFillValue = null;
const enableNewCardCombinedExpiryAutofill = await this.configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
if (enableNewCardCombinedExpiryAutofill) {
combinedExpiryFillValue = this.generateCombinedExpiryValue(card, fillFields.exp);
} else {
const fullMonth = ("0" + card.expMonth).slice(-2); const fullMonth = ("0" + card.expMonth).slice(-2);
let fullYear: string = card.expYear; let fullYear: string = card.expYear;
@ -1127,9 +1146,9 @@ export default class AutofillService implements AutofillServiceInterface {
partYear = fullYear.substr(2, 2); partYear = fullYear.substr(2, 2);
} }
let exp: string = null;
for (let i = 0; i < CreditCardAutoFillConstants.MonthAbbr.length; i++) { for (let i = 0; i < CreditCardAutoFillConstants.MonthAbbr.length; i++) {
if ( if (
// mm/yyyy
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.MonthAbbr[i] +
@ -1137,8 +1156,9 @@ export default class AutofillService implements AutofillServiceInterface {
CreditCardAutoFillConstants.YearAbbrLong[i], CreditCardAutoFillConstants.YearAbbrLong[i],
) )
) { ) {
exp = fullMonth + "/" + fullYear; combinedExpiryFillValue = fullMonth + "/" + fullYear;
} else if ( } else if (
// mm/yy
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.MonthAbbr[i] +
@ -1147,8 +1167,9 @@ export default class AutofillService implements AutofillServiceInterface {
) && ) &&
partYear != null partYear != null
) { ) {
exp = fullMonth + "/" + partYear; combinedExpiryFillValue = fullMonth + "/" + partYear;
} else if ( } else if (
// yyyy/mm
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.YearAbbrLong[i] +
@ -1156,8 +1177,9 @@ export default class AutofillService implements AutofillServiceInterface {
CreditCardAutoFillConstants.MonthAbbr[i], CreditCardAutoFillConstants.MonthAbbr[i],
) )
) { ) {
exp = fullYear + "/" + fullMonth; combinedExpiryFillValue = fullYear + "/" + fullMonth;
} else if ( } else if (
// yy/mm
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] + CreditCardAutoFillConstants.YearAbbrShort[i] +
@ -1166,8 +1188,9 @@ export default class AutofillService implements AutofillServiceInterface {
) && ) &&
partYear != null partYear != null
) { ) {
exp = partYear + "/" + fullMonth; combinedExpiryFillValue = partYear + "/" + fullMonth;
} else if ( } else if (
// mm-yyyy
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.MonthAbbr[i] +
@ -1175,8 +1198,9 @@ export default class AutofillService implements AutofillServiceInterface {
CreditCardAutoFillConstants.YearAbbrLong[i], CreditCardAutoFillConstants.YearAbbrLong[i],
) )
) { ) {
exp = fullMonth + "-" + fullYear; combinedExpiryFillValue = fullMonth + "-" + fullYear;
} else if ( } else if (
// mm-yy
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.MonthAbbr[i] +
@ -1185,8 +1209,9 @@ export default class AutofillService implements AutofillServiceInterface {
) && ) &&
partYear != null partYear != null
) { ) {
exp = fullMonth + "-" + partYear; combinedExpiryFillValue = fullMonth + "-" + partYear;
} else if ( } else if (
// yyyy-mm
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.YearAbbrLong[i] +
@ -1194,8 +1219,9 @@ export default class AutofillService implements AutofillServiceInterface {
CreditCardAutoFillConstants.MonthAbbr[i], CreditCardAutoFillConstants.MonthAbbr[i],
) )
) { ) {
exp = fullYear + "-" + fullMonth; combinedExpiryFillValue = fullYear + "-" + fullMonth;
} else if ( } else if (
// yy-mm
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] + CreditCardAutoFillConstants.YearAbbrShort[i] +
@ -1204,49 +1230,64 @@ export default class AutofillService implements AutofillServiceInterface {
) && ) &&
partYear != null partYear != null
) { ) {
exp = partYear + "-" + fullMonth; combinedExpiryFillValue = partYear + "-" + fullMonth;
} else if ( } else if (
// yyyymm
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.MonthAbbr[i], CreditCardAutoFillConstants.YearAbbrLong[i] +
CreditCardAutoFillConstants.MonthAbbr[i],
) )
) { ) {
exp = fullYear + fullMonth; combinedExpiryFillValue = fullYear + fullMonth;
} else if ( } else if (
// yymm
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] + CreditCardAutoFillConstants.MonthAbbr[i], CreditCardAutoFillConstants.YearAbbrShort[i] +
CreditCardAutoFillConstants.MonthAbbr[i],
) && ) &&
partYear != null partYear != null
) { ) {
exp = partYear + fullMonth; combinedExpiryFillValue = partYear + fullMonth;
} else if ( } else if (
// mmyyyy
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrLong[i], CreditCardAutoFillConstants.MonthAbbr[i] +
CreditCardAutoFillConstants.YearAbbrLong[i],
) )
) { ) {
exp = fullMonth + fullYear; combinedExpiryFillValue = fullMonth + fullYear;
} else if ( } else if (
// mmyy
this.fieldAttrsContain( this.fieldAttrsContain(
fillFields.exp, fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrShort[i], CreditCardAutoFillConstants.MonthAbbr[i] +
CreditCardAutoFillConstants.YearAbbrShort[i],
) && ) &&
partYear != null partYear != null
) { ) {
exp = fullMonth + partYear; combinedExpiryFillValue = fullMonth + partYear;
} }
if (exp != null) { if (combinedExpiryFillValue != null) {
break; break;
} }
} }
if (exp == null) { // If none of the previous cases applied, set as default
exp = fullYear + "-" + fullMonth; if (combinedExpiryFillValue == null) {
combinedExpiryFillValue = fullYear + "-" + fullMonth;
}
} }
this.makeScriptActionWithValue(fillScript, exp, fillFields.exp, filledFields); this.makeScriptActionWithValue(
fillScript,
combinedExpiryFillValue,
fillFields.exp,
filledFields,
);
} }
return fillScript; return fillScript;
@ -1287,28 +1328,169 @@ export default class AutofillService implements AutofillServiceInterface {
* Used when handling autofill on credit card fields. Determines whether * Used when handling autofill on credit card fields. Determines whether
* the field has an attribute that matches the given value. * the field has an attribute that matches the given value.
* @param {AutofillField} field * @param {AutofillField} field
* @param {string} containsVal * @param {string} containsValue
* @returns {boolean} * @returns {boolean}
* @private * @private
*/ */
private fieldAttrsContain(field: AutofillField, containsVal: string): boolean { private fieldAttrsContain(field: AutofillField, containsValue: string): boolean {
if (!field) { if (!field) {
return false; return false;
} }
let doesContain = false; let doesContainValue = false;
CreditCardAutoFillConstants.CardAttributesExtended.forEach((attr) => { CreditCardAutoFillConstants.CardAttributesExtended.forEach((attributeName) => {
// eslint-disable-next-line // eslint-disable-next-line no-prototype-builtins
if (doesContain || !field.hasOwnProperty(attr) || !field[attr]) { if (doesContainValue || !field[attributeName]) {
return; return;
} }
let val = field[attr]; let fieldValue = field[attributeName];
val = val.replace(/ /g, "").toLowerCase(); fieldValue = fieldValue.replace(/ /g, "").toLowerCase();
doesContain = val.indexOf(containsVal) > -1; doesContainValue = fieldValue.indexOf(containsValue) > -1;
}); });
return doesContain; return doesContainValue;
}
/**
* Returns a string value representation of the combined card expiration month and year values
* in a format matching discovered guidance within the field attributes (typically provided for users).
*
* @param {CardView} cardCipher
* @param {AutofillField} field
*/
private generateCombinedExpiryValue(cardCipher: CardView, field: AutofillField): string {
/*
Some expectations of the passed stored card cipher view:
- At the time of writing, the stored card expiry year value (`expYear`)
can be any arbitrary string (no format validation). We may attempt some format
normalization here, but expect the user to have entered a string of integers
with a length of 2 or 4
- the `expiration` property cannot be used for autofill as it is an opinionated
format
- `expMonth` a stringified integer stored with no zero-padding and is not
zero-indexed (e.g. January is "1", not "01" or 0)
*/
// Expiry format options
let useMonthPadding = true;
let useYearFull = false;
let delimiter = "/";
let orderByYear = false;
// Because users are allowed to store truncated years, we need to make assumptions
// about the full year format when called for
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
// Note, we construct the output rather than doing string replacement against the
// format guidance pattern to avoid edge cases that would output invalid values
const [
// The guidance parsed from the field properties regarding expiry format
expectedExpiryDateFormat,
// The (localized) date pattern set that was used to parse the expiry format guidance
expiryDateFormatPatterns,
] = this.getExpectedExpiryDateFormat(field);
if (expectedExpiryDateFormat) {
const { Month, MonthShort, Year } = expiryDateFormatPatterns;
const expiryDateDelimitersPattern =
"\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\");
// assign the delimiter from the expected format string
delimiter =
expectedExpiryDateFormat.match(new RegExp(`[${expiryDateDelimitersPattern}]`, "g"))?.[0] ||
"";
// check if the expected format starts with a month form
// order matters here; check long form first, since short form will match against long
if (expectedExpiryDateFormat.indexOf(Month + delimiter) === 0) {
useMonthPadding = true;
orderByYear = false;
} else if (expectedExpiryDateFormat.indexOf(MonthShort + delimiter) === 0) {
useMonthPadding = false;
orderByYear = false;
} else {
orderByYear = true;
// short form can match against long form, but long won't match against short
const containsLongMonthPattern = new RegExp(`${Month}`, "i");
useMonthPadding = containsLongMonthPattern.test(expectedExpiryDateFormat);
}
const containsLongYearPattern = new RegExp(`${Year}`, "i");
useYearFull = containsLongYearPattern.test(expectedExpiryDateFormat);
}
const month = useMonthPadding
? // Ensure zero-padding
("0" + cardCipher.expMonth).slice(-2)
: // Handle zero-padded stored month values, even though they are not _expected_ to be as such
cardCipher.expMonth.replaceAll("0", "");
// Note: assumes the user entered an `expYear` value with a length of either 2 or 4
const year = (currentCentury + cardCipher.expYear).slice(useYearFull ? -4 : -2);
const combinedExpiryFillValue = (orderByYear ? [year, month] : [month, year]).join(delimiter);
return combinedExpiryFillValue;
}
/**
* Returns a string value representation of discovered guidance for a combined month and year expiration value from the field attributes
*
* @param {AutofillField} field
*/
private getExpectedExpiryDateFormat(
field: AutofillField,
): [string | null, CardExpiryDateFormat | null] {
let expectedDateFormat = null;
let dateFormatPatterns = null;
const expiryDateDelimitersPattern =
"\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\");
CreditCardAutoFillConstants.CardExpiryDateFormats.find((dateFormat) => {
dateFormatPatterns = dateFormat;
const { Month, MonthShort, YearShort, Year } = dateFormat;
// Non-exhaustive coverage of field guidances. Some uncovered edge cases: ". " delimiter, space-delimited delimiters ("mm / yyyy").
// We should consider if added whitespace is for improved readability of user-guidance or actually desired in the filled value.
// e.g. "/((mm|m)[\/\-\.\ ]{0,1}(yyyy|yy))|((yyyy|yy)[\/\-\.\ ]{0,1}(mm|m))/gi"
const dateFormatPattern = new RegExp(
`((${Month}|${MonthShort})[${expiryDateDelimitersPattern}]{0,1}(${Year}|${YearShort}))|((${Year}|${YearShort})[${expiryDateDelimitersPattern}]{0,1}(${Month}|${MonthShort}))`,
"gi",
);
return CreditCardAutoFillConstants.CardAttributesExtended.find((attributeName) => {
const fieldAttributeValue = field[attributeName];
const fieldAttributeMatch = fieldAttributeValue?.match(dateFormatPattern);
// break find as soon as a match is found
if (fieldAttributeMatch?.length) {
expectedDateFormat = fieldAttributeMatch[0];
// remove any irrelevant characters
const irrelevantExpiryCharactersPattern = new RegExp(
// "or digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w
`[^\\w${expiryDateDelimitersPattern}]|[\\d]`,
"gi",
);
expectedDateFormat.replaceAll(irrelevantExpiryCharactersPattern, "");
return true;
}
return false;
});
});
return [expectedDateFormat, dateFormatPatterns];
} }
/** /**

View File

@ -30,6 +30,7 @@ export enum FeatureFlag {
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub", EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor", GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2", DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
AccountDeprovisioning = "pm-10308-account-deprovisioning", AccountDeprovisioning = "pm-10308-account-deprovisioning",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements", NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
@ -76,6 +77,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE, [FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE, [FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE, [FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.StorageReseedRefactor]: FALSE, [FeatureFlag.StorageReseedRefactor]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE, [FeatureFlag.AccountDeprovisioning]: FALSE,