diff --git a/libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts b/libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts index c2004a5a681..ef5098bb91d 100644 --- a/libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts +++ b/libs/accounts/email-renderer/src/renderer/email-helpers.spec.ts @@ -136,5 +136,60 @@ describe('EmailHelpers', () => { expect(resultDefault).toEqual('01/15/2024'); expect(resultEst).toEqual('01/14/2024'); }); + + it('does not leak locale between concurrent calls', async () => { + const date = new Date('2025-03-13T12:00:00Z'); + + const [enResult, gbResult] = await Promise.all([ + Promise.resolve(constructLocalDateString(undefined, 'en', date)), + Promise.resolve(constructLocalDateString(undefined, 'en-GB', date)), + ]); + + expect(enResult).toEqual('03/13/2025'); + expect(gbResult).toEqual('13/03/2025'); + }); + + it('does not leak locale into subsequent calls', () => { + const date = new Date('2025-03-13T12:00:00Z'); + + constructLocalDateString(undefined, 'en-GB', date); + const enResult = constructLocalDateString(undefined, 'en', date); + + expect(enResult).toEqual('03/13/2025'); + }); + }); + + describe('constructLocalTimeAndDateStrings - locale isolation', () => { + it('does not leak locale between concurrent calls', async () => { + const [enResult, esResult] = await Promise.all([ + Promise.resolve( + constructLocalTimeAndDateStrings('America/Los_Angeles', 'en') + ), + Promise.resolve( + constructLocalTimeAndDateStrings('America/Los_Angeles', 'es') + ), + ]); + + const enDays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + const esDays = [ + 'lunes', + 'martes', + 'miércoles', + 'jueves', + 'viernes', + 'sábado', + 'domingo', + ]; + expect(enDays).toContain(enResult.date.split(',')[0]); + expect(esDays).toContain(esResult.date.split(',')[0]); + }); }); }); diff --git a/libs/accounts/email-renderer/src/renderer/email-helpers.ts b/libs/accounts/email-renderer/src/renderer/email-helpers.ts index ef9f9d917b8..f2152ffc4a3 100644 --- a/libs/accounts/email-renderer/src/renderer/email-helpers.ts +++ b/libs/accounts/email-renderer/src/renderer/email-helpers.ts @@ -53,15 +53,11 @@ export const constructLocalTimeAndDateStrings = ( time: string; timeZone: string; } => { - moment.tz.setDefault(DEFAULT_TIMEZONE); - const locale = determineLocale(acceptLanguage) || DEFAULT_LOCALE; - moment.locale(locale); - let timeMoment = moment(date ? date : undefined); - if (timeZone) { - timeMoment = timeMoment.tz(timeZone); - } + const timeMoment = moment(date ? date : undefined) + .locale(locale) + .tz(timeZone || DEFAULT_TIMEZONE); const formattedTime = timeMoment.format('LTS (z)'); const formattedDate = timeMoment.format('dddd, ll'); @@ -88,15 +84,11 @@ export const constructLocalDateString = ( date?: Date | number, formatString = 'L' ): string => { - moment.tz.setDefault(DEFAULT_TIMEZONE); - const locale = determineLocale(acceptLanguage) || DEFAULT_LOCALE; - moment.locale(locale); - let time = moment(date); - if (timeZone) { - time = time.tz(timeZone); - } + const time = moment(date) + .locale(locale) + .tz(timeZone || DEFAULT_TIMEZONE); return time.format(formatString); }; diff --git a/packages/fxa-auth-server/lib/senders/email.js b/packages/fxa-auth-server/lib/senders/email.js index f19baa408c4..8a586e0d1d8 100644 --- a/packages/fxa-auth-server/lib/senders/email.js +++ b/packages/fxa-auth-server/lib/senders/email.js @@ -23,7 +23,6 @@ const { ProductConfigurationManager } = require('@fxa/shared/cms'); const { Container } = require('typedi'); const DEFAULT_LOCALE = 'en'; -const DEFAULT_TIMEZONE = 'Etc/UTC'; const UTM_PREFIX = 'fx-'; const X_SES_CONFIGURATION_SET = 'X-SES-CONFIGURATION-SET'; @@ -184,15 +183,11 @@ module.exports = function (log, config, bounces, statsd) { } function constructLocalTimeString(timeZone, locale) { - // if no timeZone is passed, use DEFAULT_TIMEZONE - moment.tz.setDefault(DEFAULT_TIMEZONE); // if no locale is passed, use DEFAULT_LOCALE locale = locale || DEFAULT_LOCALE; - moment.locale(locale); - let timeMoment = moment(); - if (timeZone) { - timeMoment = timeMoment.tz(timeZone); - } + const timeMoment = moment() + .locale(locale) + .tz(timeZone || 'Etc/UTC'); // return a locale-specific time // if date or time is passed, return it as the current date or time const timeNow = timeMoment.format('LTS (z)'); @@ -206,15 +201,11 @@ module.exports = function (log, config, bounces, statsd) { date, formatString = 'L' ) { - // if no timeZone is passed, use DEFAULT_TIMEZONE - moment.tz.setDefault(DEFAULT_TIMEZONE); // if no locale is passed, use DEFAULT_LOCALE locale = locale || DEFAULT_LOCALE; - moment.locale(locale); - let time = moment(date); - if (timeZone) { - time = time.tz(timeZone); - } + const time = moment(date) + .locale(locale) + .tz(timeZone || 'Etc/UTC'); // return a locale-specific date return time.format(formatString); } diff --git a/packages/fxa-auth-server/test/local/senders/emails.ts b/packages/fxa-auth-server/test/local/senders/emails.ts index 94cf0463c3d..e698859d3be 100644 --- a/packages/fxa-auth-server/test/local/senders/emails.ts +++ b/packages/fxa-auth-server/test/local/senders/emails.ts @@ -6449,6 +6449,72 @@ describe('lib/senders/emails:', () => { }); }); + describe('constructLocalDateString - locale isolation', () => { + it('does not leak locale between concurrent calls', async () => { + const date = new Date('2025-03-13T12:00:00Z'); + + // Simulate concurrent calls with different locales + const [enResult, gbResult] = await Promise.all([ + Promise.resolve( + mailer._constructLocalDateString(undefined, 'en', date) + ), + Promise.resolve( + mailer._constructLocalDateString(undefined, 'en-GB', date) + ), + ]); + + // en formats as MM/DD/YYYY, en-GB formats as DD/MM/YYYY + assert.equal(enResult, '03/13/2025'); + assert.equal(gbResult, '13/03/2025'); + }); + + it('does not leak locale into subsequent calls', () => { + const date = new Date('2025-03-13T12:00:00Z'); + + // Call with en-GB first + mailer._constructLocalDateString(undefined, 'en-GB', date); + // Then call with en + const enResult = mailer._constructLocalDateString(undefined, 'en', date); + + assert.equal(enResult, '03/13/2025'); + }); + }); + + describe('constructLocalTimeString - locale isolation', () => { + it('does not leak locale between concurrent calls', async () => { + const [enResult, esResult] = await Promise.all([ + Promise.resolve( + mailer._constructLocalTimeString('America/Los_Angeles', 'en') + ), + Promise.resolve( + mailer._constructLocalTimeString('America/Los_Angeles', 'es') + ), + ]); + + // en day names vs es day names + const enDays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + const esDays = [ + 'lunes', + 'martes', + 'miércoles', + 'jueves', + 'viernes', + 'sábado', + 'domingo', + ]; + assert.include(enDays, enResult[1].split(',')[0]); + assert.include(esDays, esResult[1].split(',')[0]); + }); + }); + describe('constructLocalTimeString - returns date/time', () => { // Moment expects a single locale identifier. This tests to ensure // we account for this in _constructLocalTimeString