From 57252eaece8f0ac38174572cb03dbc407d8337a5 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 3 Feb 2026 20:30:44 -0500 Subject: [PATCH 1/6] chore(tests): Add Jest to auth-server, convert first set of tests --- packages/fxa-auth-server/.eslintrc.json | 11 + packages/fxa-auth-server/config/index.spec.ts | 48 ++ packages/fxa-auth-server/jest.config.js | 40 ++ .../fxa-auth-server/lib/authMethods.spec.ts | 133 +++++ .../fxa-auth-server/lib/crypto/butil.spec.ts | 35 ++ .../fxa-auth-server/lib/crypto/hkdf.spec.ts | 32 ++ .../lib/crypto/password.spec.ts | 52 ++ .../fxa-auth-server/lib/crypto/pbkdf2.spec.ts | 55 ++ .../fxa-auth-server/lib/crypto/random.spec.ts | 99 ++++ .../fxa-auth-server/lib/crypto/scrypt.spec.ts | 52 ++ packages/fxa-auth-server/lib/error.spec.ts | 275 ++++++++++ packages/fxa-auth-server/lib/features.spec.ts | 154 ++++++ packages/fxa-auth-server/lib/geodb.spec.ts | 73 +++ .../lib/getRemoteAddressChain.spec.ts | 75 +++ .../lib/routes/ip-profiling.spec.ts | 490 ++++++++++++++++++ packages/fxa-auth-server/lib/time.spec.ts | 40 ++ .../lib/verification-reminders.spec.ts | 465 +++++++++++++++++ packages/fxa-auth-server/package.json | 11 + packages/fxa-auth-server/scripts/test-ci.sh | 19 + .../fxa-auth-server/scripts/test-local.sh | 3 + packages/fxa-auth-server/tsconfig.json | 2 +- yarn.lock | 58 ++- 22 files changed, 2219 insertions(+), 3 deletions(-) create mode 100644 packages/fxa-auth-server/config/index.spec.ts create mode 100644 packages/fxa-auth-server/jest.config.js create mode 100644 packages/fxa-auth-server/lib/authMethods.spec.ts create mode 100644 packages/fxa-auth-server/lib/crypto/butil.spec.ts create mode 100644 packages/fxa-auth-server/lib/crypto/hkdf.spec.ts create mode 100644 packages/fxa-auth-server/lib/crypto/password.spec.ts create mode 100644 packages/fxa-auth-server/lib/crypto/pbkdf2.spec.ts create mode 100644 packages/fxa-auth-server/lib/crypto/random.spec.ts create mode 100644 packages/fxa-auth-server/lib/crypto/scrypt.spec.ts create mode 100644 packages/fxa-auth-server/lib/error.spec.ts create mode 100644 packages/fxa-auth-server/lib/features.spec.ts create mode 100644 packages/fxa-auth-server/lib/geodb.spec.ts create mode 100644 packages/fxa-auth-server/lib/getRemoteAddressChain.spec.ts create mode 100644 packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts create mode 100644 packages/fxa-auth-server/lib/time.spec.ts create mode 100644 packages/fxa-auth-server/lib/verification-reminders.spec.ts diff --git a/packages/fxa-auth-server/.eslintrc.json b/packages/fxa-auth-server/.eslintrc.json index 78bcd5b8447..2fda9afe84e 100644 --- a/packages/fxa-auth-server/.eslintrc.json +++ b/packages/fxa-auth-server/.eslintrc.json @@ -25,5 +25,16 @@ "dist", "fxa-*", "vendor" + ], + "overrides": [ + { + "files": ["**/*.spec.ts"], + "env": { + "jest": true + }, + "rules": { + "fxa/async-crypto-random": "off" + } + } ] } diff --git a/packages/fxa-auth-server/config/index.spec.ts b/packages/fxa-auth-server/config/index.spec.ts new file mode 100644 index 00000000000..2931fc3936d --- /dev/null +++ b/packages/fxa-auth-server/config/index.spec.ts @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +describe('Config', () => { + describe('NODE_ENV=prod', () => { + let originalEnv: Record; + + function mockEnv(key: string, value: string) { + originalEnv[key] = process.env[key]; + process.env[key] = value; + } + + beforeEach(() => { + originalEnv = {}; + jest.resetModules(); + mockEnv('NODE_ENV', 'prod'); + }); + + afterEach(() => { + for (const key in originalEnv) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + }); + + it('errors when secret settings have their default values', () => { + expect(() => { + require('./index'); + }).toThrow(/Config '[a-zA-Z._]+' must be set in production/); + }); + + it('succeeds when secret settings have all been configured', () => { + mockEnv('FLOW_ID_KEY', 'production secret here'); + mockEnv('OAUTH_SERVER_SECRET_KEY', 'production secret here'); + mockEnv( + 'PROFILE_SERVER_AUTH_SECRET_BEARER_TOKEN', + 'production secret here' + ); + expect(() => { + require('./index'); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/fxa-auth-server/jest.config.js b/packages/fxa-auth-server/jest.config.js new file mode 100644 index 00000000000..9385a0524cc --- /dev/null +++ b/packages/fxa-auth-server/jest.config.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: [ + '/lib/**/*.spec.ts', + '/config/**/*.spec.ts', + ], + moduleFileExtensions: ['ts', 'js', 'json'], + transform: { + '^.+\\.[tj]sx?$': ['ts-jest', { isolatedModules: true }], + }, + transformIgnorePatterns: [ + '/node_modules/(?!(@fxa|fxa-shared)/)', + ], + moduleNameMapper: { + '^@fxa/shared/(.*)$': '/../../libs/shared/$1/src', + '^@fxa/accounts/(.*)$': '/../../libs/accounts/$1/src', + '^@fxa/payments/(.*)$': '/../../libs/payments/$1/src', + '^@fxa/profile/(.*)$': '/../../libs/profile/$1/src', + '^fxa-shared/(.*)$': '/../fxa-shared/$1', + }, + testTimeout: 10000, + clearMocks: true, + testPathIgnorePatterns: ['/node_modules/'], + // Coverage configuration (enabled via --coverage flag) + collectCoverageFrom: [ + 'lib/**/*.{ts,js}', + 'config/**/*.{ts,js}', + '!lib/**/*.spec.{ts,js}', + '!config/**/*.spec.{ts,js}', + '!**/node_modules/**', + ], + coverageDirectory: '../../artifacts/coverage/fxa-auth-server-jest', + coverageReporters: ['text', 'lcov', 'html'], +}; diff --git a/packages/fxa-auth-server/lib/authMethods.spec.ts b/packages/fxa-auth-server/lib/authMethods.spec.ts new file mode 100644 index 00000000000..91cef0c5e66 --- /dev/null +++ b/packages/fxa-auth-server/lib/authMethods.spec.ts @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import sinon from 'sinon'; +import { AppError as error } from '@fxa/accounts/errors'; +import * as authMethods from './authMethods'; + +const MOCK_ACCOUNT = { + uid: 'abcdef123456', +}; + +function mockDB() { + return { + totpToken: sinon.stub(), + // Add other DB methods as needed + }; +} + +describe('availableAuthenticationMethods', () => { + let mockDbInstance: ReturnType; + + beforeEach(() => { + mockDbInstance = mockDB(); + }); + + it('returns [`pwd`,`email`] for non-TOTP-enabled accounts', async () => { + mockDbInstance.totpToken = sinon.stub().rejects(error.totpTokenNotFound()); + const amr = await authMethods.availableAuthenticationMethods( + mockDbInstance as any, + MOCK_ACCOUNT as any + ); + expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true); + expect(Array.from(amr).sort()).toEqual(['email', 'pwd']); + }); + + it('returns [`pwd`,`email`,`otp`] for TOTP-enabled accounts', async () => { + mockDbInstance.totpToken = sinon.stub().resolves({ + verified: true, + enabled: true, + sharedSecret: 'secret!', + }); + const amr = await authMethods.availableAuthenticationMethods( + mockDbInstance as any, + MOCK_ACCOUNT as any + ); + expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true); + expect(Array.from(amr).sort()).toEqual(['email', 'otp', 'pwd']); + }); + + it('returns [`pwd`,`email`] when TOTP token is not yet enabled', async () => { + mockDbInstance.totpToken = sinon.stub().resolves({ + verified: true, + enabled: false, + sharedSecret: 'secret!', + }); + const amr = await authMethods.availableAuthenticationMethods( + mockDbInstance as any, + MOCK_ACCOUNT as any + ); + expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true); + expect(Array.from(amr).sort()).toEqual(['email', 'pwd']); + }); + + it('rethrows unexpected DB errors', async () => { + mockDbInstance.totpToken = sinon.stub().rejects(error.serviceUnavailable()); + try { + await authMethods.availableAuthenticationMethods( + mockDbInstance as any, + MOCK_ACCOUNT as any + ); + throw new Error('error should have been re-thrown'); + } catch (err: any) { + expect(mockDbInstance.totpToken.calledWithExactly(MOCK_ACCOUNT.uid)).toBe(true); + expect(err.errno).toBe(error.ERRNO.SERVER_BUSY); + } + }); +}); + +describe('verificationMethodToAMR', () => { + it('maps `email` to `email`', () => { + expect(authMethods.verificationMethodToAMR('email')).toBe('email'); + }); + + it('maps `email-captcha` to `email`', () => { + expect(authMethods.verificationMethodToAMR('email-captcha')).toBe('email'); + }); + + it('maps `email-2fa` to `email`', () => { + expect(authMethods.verificationMethodToAMR('email-2fa')).toBe('email'); + }); + + it('maps `totp-2fa` to `otp`', () => { + expect(authMethods.verificationMethodToAMR('totp-2fa')).toBe('otp'); + }); + + it('maps `recovery-code` to `otp`', () => { + expect(authMethods.verificationMethodToAMR('recovery-code')).toBe('otp'); + }); + + it('throws when given an unknown verification method', () => { + expect(() => { + authMethods.verificationMethodToAMR('email-gotcha' as any); + }).toThrow(/unknown verificationMethod/); + }); +}); + +describe('maximumAssuranceLevel', () => { + it('returns 0 when no authentication methods are used', () => { + expect(authMethods.maximumAssuranceLevel([])).toBe(0); + expect(authMethods.maximumAssuranceLevel(new Set())).toBe(0); + }); + + it('returns 1 when only `pwd` auth is used', () => { + expect(authMethods.maximumAssuranceLevel(['pwd'])).toBe(1); + }); + + it('returns 1 when only `email` auth is used', () => { + expect(authMethods.maximumAssuranceLevel(['email'])).toBe(1); + }); + + it('returns 1 when only `otp` auth is used', () => { + expect(authMethods.maximumAssuranceLevel(['otp'])).toBe(1); + }); + + it('returns 1 when only things-you-know auth mechanisms are used', () => { + expect(authMethods.maximumAssuranceLevel(['email', 'pwd'])).toBe(1); + }); + + it('returns 2 when both `pwd` and `otp` methods are used', () => { + expect(authMethods.maximumAssuranceLevel(['pwd', 'otp'])).toBe(2); + }); +}); diff --git a/packages/fxa-auth-server/lib/crypto/butil.spec.ts b/packages/fxa-auth-server/lib/crypto/butil.spec.ts new file mode 100644 index 00000000000..36bd1aebd43 --- /dev/null +++ b/packages/fxa-auth-server/lib/crypto/butil.spec.ts @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as butil from './butil'; + +describe('butil', () => { + describe('.buffersAreEqual', () => { + it('returns false if lengths are different', () => { + expect(butil.buffersAreEqual(Buffer.alloc(2), Buffer.alloc(4))).toBe( + false + ); + }); + + it('returns true if buffers have same bytes', () => { + const b1 = Buffer.from('abcd', 'hex'); + const b2 = Buffer.from('abcd', 'hex'); + expect(butil.buffersAreEqual(b1, b2)).toBe(true); + }); + }); + + describe('.xorBuffers', () => { + it('throws an Error if lengths are different', () => { + expect(() => { + butil.xorBuffers(Buffer.alloc(2), Buffer.alloc(4)); + }).toThrow(); + }); + + it('should return a Buffer with bits ORed', () => { + const b1 = Buffer.from('e5', 'hex'); + const b2 = Buffer.from('5e', 'hex'); + expect(butil.xorBuffers(b1, b2)).toEqual(Buffer.from('bb', 'hex')); + }); + }); +}); diff --git a/packages/fxa-auth-server/lib/crypto/hkdf.spec.ts b/packages/fxa-auth-server/lib/crypto/hkdf.spec.ts new file mode 100644 index 00000000000..1117fbc0bf0 --- /dev/null +++ b/packages/fxa-auth-server/lib/crypto/hkdf.spec.ts @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import hkdf from './hkdf'; + +describe('hkdf', () => { + it('should extract', async () => { + const stretchedPw = + 'c16d46c31bee242cb31f916e9e38d60b76431d3f5304549cc75ae4bc20c7108c'; + const stretchedPwBuffer = Buffer.from(stretchedPw, 'hex'); + const info = 'mainKDF'; + const salt = Buffer.from( + '00f000000000000000000000000000000000000000000000000000000000034d', + 'hex' + ); + const lengthHkdf = 2 * 32; + + const hkdfResult = await hkdf(stretchedPwBuffer, info, salt, lengthHkdf); + const hkdfStr = hkdfResult.toString('hex'); + + expect(hkdfStr.substring(0, 64)).toBe( + '00f9b71800ab5337d51177d8fbc682a3653fa6dae5b87628eeec43a18af59a9d' + ); + expect(hkdfStr.substring(64, 128)).toBe( + '6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a' + ); + expect(salt.toString('hex')).toBe( + '00f000000000000000000000000000000000000000000000000000000000034d' + ); + }); +}); diff --git a/packages/fxa-auth-server/lib/crypto/password.spec.ts b/packages/fxa-auth-server/lib/crypto/password.spec.ts new file mode 100644 index 00000000000..d1c16075b6d --- /dev/null +++ b/packages/fxa-auth-server/lib/crypto/password.spec.ts @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export {}; + +const mockLog = {}; +const mockConfig = {}; +const Password = require('./password')(mockLog, mockConfig); + +describe('Password', () => { + it('password version zero', async () => { + const pwd = Buffer.from('aaaaaaaaaaaaaaaa'); + const salt = Buffer.from('bbbbbbbbbbbbbbbb'); + const p1 = new Password(pwd, salt, 0); + expect(p1.version).toBe(0); + const p2 = new Password(pwd, salt, 0); + expect(p2.version).toBe(0); + const hash = await p1.verifyHash(); + const matched = await p2.matches(hash); + expect(matched).toBe(true); + }); + + it('password version one', async () => { + const pwd = Buffer.from('aaaaaaaaaaaaaaaa'); + const salt = Buffer.from('bbbbbbbbbbbbbbbb'); + const p1 = new Password(pwd, salt, 1); + expect(p1.version).toBe(1); + const p2 = new Password(pwd, salt, 1); + expect(p2.version).toBe(1); + const hash = await p1.verifyHash(); + const matched = await p2.matches(hash); + expect(matched).toBe(true); + }); + + it('passwords of different versions should not match', async () => { + const pwd = Buffer.from('aaaaaaaaaaaaaaaa'); + const salt = Buffer.from('bbbbbbbbbbbbbbbb'); + const p1 = new Password(pwd, salt, 0); + const p2 = new Password(pwd, salt, 1); + const hash = await p1.verifyHash(); + const matched = await p2.matches(hash); + expect(matched).toBe(false); + }); + + it('scrypt queue stats can be reported', () => { + const stat = Password.stat(); + expect(stat.stat).toBe('scrypt'); + expect(stat).toHaveProperty('numPending'); + expect(stat).toHaveProperty('numPendingHWM'); + }); +}); diff --git a/packages/fxa-auth-server/lib/crypto/pbkdf2.spec.ts b/packages/fxa-auth-server/lib/crypto/pbkdf2.spec.ts new file mode 100644 index 00000000000..a641b199c05 --- /dev/null +++ b/packages/fxa-auth-server/lib/crypto/pbkdf2.spec.ts @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as pbkdf2 from './pbkdf2'; + +const ITERATIONS = 20000; +const LENGTH = 32; + +describe('pbkdf2', () => { + it('pbkdf2 derive', async () => { + const salt = Buffer.from( + 'identity.mozilla.com/picl/v1/first-PBKDF:andré@example.org' + ); + const password = Buffer.from('pässwörd'); + const K1 = await pbkdf2.derive(password, salt, ITERATIONS, LENGTH); + expect(K1.toString('hex')).toBe( + 'f84913e3d8e6d624689d0a3e9678ac8dcc79d2c2f3d9641488cd9d6ef6cd83dd' + ); + }); + + it('pbkdf2 derive long input', async () => { + const email = Buffer.from( + 'ijqmkkafer3xsj5rzoq+msnxsacvkmqxabtsvxvj@some-test-domain-with-a-long-name-example.org' + ); + const password = Buffer.from( + 'mSnxsacVkMQxAbtSVxVjCCoWArNUsFhiJqmkkafER3XSJ5rzoQ' + ); + const salt = Buffer.from( + `identity.mozilla.com/picl/v1/first-PBKDF:${email}` + ); + const K1 = await pbkdf2.derive(password, salt, ITERATIONS, LENGTH); + expect(K1.toString('hex')).toBe( + '5f99c22dfac713b6d73094604a05082e6d345f8a00d4947e57105733f51216eb' + ); + }); + + it('pbkdf2 derive bit array', async () => { + const salt = Buffer.from( + 'identity.mozilla.com/picl/v1/second-PBKDF:andré@example.org' + ); + const K2 = + '5b82f146a64126923e4167a0350bb181feba61f63cb1714012b19cb0be0119c5'; + const passwordString = 'pässwörd'; + const password = Buffer.concat([ + Buffer.from(K2, 'hex'), + Buffer.from(passwordString), + ]); + + const K1 = await pbkdf2.derive(password, salt, ITERATIONS, LENGTH); + expect(K1.toString('hex')).toBe( + 'c16d46c31bee242cb31f916e9e38d60b76431d3f5304549cc75ae4bc20c7108c' + ); + }); +}); diff --git a/packages/fxa-auth-server/lib/crypto/random.spec.ts b/packages/fxa-auth-server/lib/crypto/random.spec.ts new file mode 100644 index 00000000000..0f24e3b3fac --- /dev/null +++ b/packages/fxa-auth-server/lib/crypto/random.spec.ts @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import random from './random'; + +const base10 = random.base10; +const base32 = random.base32; + +describe('random', () => { + it('should generate random bytes', async () => { + let bytes = await random(16); + expect(Buffer.isBuffer(bytes)).toBe(true); + expect(bytes.length).toBe(16); + + bytes = await random(32); + expect(Buffer.isBuffer(bytes)).toBe(true); + expect(bytes.length).toBe(32); + }); + + it('should generate several random bytes buffers', async () => { + const bufs = await random(16, 8); + expect(bufs).toHaveLength(2); + + const a = bufs[0]; + const b = bufs[1]; + + expect(Buffer.isBuffer(a)).toBe(true); + expect(a.length).toBe(16); + + expect(Buffer.isBuffer(b)).toBe(true); + expect(b.length).toBe(8); + }); + + describe('hex', () => { + it('should generate a random hex string', async () => { + let str = await random.hex(16); + expect(typeof str).toBe('string'); + expect(/^[0-9a-f]+$/g.test(str)).toBe(true); + expect(str).toHaveLength(32); + + str = await random.hex(32); + expect(typeof str).toBe('string'); + expect(str).toHaveLength(64); + }); + + it('should generate several random hex strings', async () => { + const strs = await random.hex(16, 8); + expect(strs).toHaveLength(2); + + const a = strs[0]; + const b = strs[1]; + + expect(typeof a).toBe('string'); + expect(/^[0-9a-f]+$/g.test(a)).toBe(true); + expect(a).toHaveLength(32); + + expect(typeof b).toBe('string'); + expect(/^[0-9a-f]+$/g.test(b)).toBe(true); + expect(b).toHaveLength(16); + }); + }); + + describe('base32', () => { + it('takes 1 integer argument, returns a function', () => { + expect(typeof base32).toBe('function'); + expect(base32.length).toBe(1); + const gen = base32(10); + expect(typeof gen).toBe('function'); + expect(gen.length).toBe(0); + }); + + it('should have correct output', async () => { + const code = await base32(10)(); + expect(code).toHaveLength(10); + expect(/^[0-9A-Z]+$/.test(code)).toBe(true); + expect(code.indexOf('I')).toBe(-1); + expect(code.indexOf('L')).toBe(-1); + expect(code.indexOf('O')).toBe(-1); + expect(code.indexOf('U')).toBe(-1); + }); + }); + + describe('base10', () => { + it('takes 1 integer argument, returns a function', () => { + expect(typeof base10).toBe('function'); + expect(base10.length).toBe(1); + const gen = base10(10); + expect(typeof gen).toBe('function'); + expect(gen.length).toBe(0); + }); + + it('should have correct output', async () => { + const code = await base10(10)(); + expect(code).toHaveLength(10); + expect(/^[0-9]+$/.test(code)).toBe(true); + }); + }); +}); diff --git a/packages/fxa-auth-server/lib/crypto/scrypt.spec.ts b/packages/fxa-auth-server/lib/crypto/scrypt.spec.ts new file mode 100644 index 00000000000..f9047739699 --- /dev/null +++ b/packages/fxa-auth-server/lib/crypto/scrypt.spec.ts @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export {}; + +const mockConfig = { scrypt: { maxPending: 5 } }; +const mockLog: { buffer: string[]; warn: (obj: string) => void } = { + buffer: [], + warn: function (obj: string) { + mockLog.buffer.push(obj); + }, +}; + +const scrypt = require('./scrypt')(mockLog, mockConfig); + +describe('scrypt', () => { + it('scrypt basic', async () => { + const K1 = Buffer.from( + 'f84913e3d8e6d624689d0a3e9678ac8dcc79d2c2f3d9641488cd9d6ef6cd83dd', + 'hex' + ); + const salt = Buffer.from('identity.mozilla.com/picl/v1/scrypt'); + const K2 = await scrypt.hash(K1, salt, 65536, 8, 1, 32); + expect(K2).toBe( + '5b82f146a64126923e4167a0350bb181feba61f63cb1714012b19cb0be0119c5' + ); + }); + + it('scrypt enforces maximum number of pending requests', async () => { + const K1 = Buffer.from( + 'f84913e3d8e6d624689d0a3e9678ac8dcc79d2c2f3d9641488cd9d6ef6cd83dd', + 'hex' + ); + const salt = Buffer.from('identity.mozilla.com/picl/v1/scrypt'); + // Verify maxPending uses the config value + expect(scrypt.maxPending).toBe(5); + // Send many concurrent requests without yielding the event loop + const promises: Promise[] = []; + for (let i = 0; i < 10; i++) { + promises.push(scrypt.hash(K1, salt, 65536, 8, 1, 32)); + } + try { + await Promise.all(promises); + throw new Error('too many pending scrypt hashes were allowed'); + } catch (err: any) { + expect(err.message).toBe('too many pending scrypt hashes'); + expect(scrypt.numPendingHWM).toBe(6); + expect(mockLog.buffer[0]).toBe('scrypt.maxPendingExceeded'); + } + }); +}); diff --git a/packages/fxa-auth-server/lib/error.spec.ts b/packages/fxa-auth-server/lib/error.spec.ts new file mode 100644 index 00000000000..7d582de6d12 --- /dev/null +++ b/packages/fxa-auth-server/lib/error.spec.ts @@ -0,0 +1,275 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import verror from 'verror'; +import { AppError, OauthError } from '@fxa/accounts/errors'; + +const mockOauthRoutes = [ + { + path: '/token', + config: { cors: true }, + }, +]; + +describe('AppErrors', () => { + it('exported functions exist', () => { + expect(typeof AppError).toBe('function'); + expect(AppError.length).toBe(4); + expect(typeof AppError.translate).toBe('function'); + expect(AppError.translate.length).toBe(3); + expect(typeof AppError.invalidRequestParameter).toBe('function'); + expect(AppError.invalidRequestParameter.length).toBe(1); + expect(typeof AppError.missingRequestParameter).toBe('function'); + expect(AppError.missingRequestParameter.length).toBe(1); + }); + + it('converts an OauthError into AppError when not an oauth route', () => { + const oauthError = OauthError.invalidAssertion(); + expect(oauthError.errno).toBe(104); + const result = AppError.translate( + { route: { path: '/v1/oauth/token' } } as any, + oauthError, + mockOauthRoutes + ); + expect(result instanceof AppError).toBe(true); + expect(result.errno).toBe(110); + }); + + it('keeps an OauthError with an oauth route', () => { + const oauthError = OauthError.invalidAssertion(); + expect(oauthError.errno).toBe(104); + const result = AppError.translate( + { route: { path: '/v1/token' } } as any, + oauthError, + mockOauthRoutes + ); + expect(result instanceof OauthError).toBe(true); + expect(result.errno).toBe(104); + }); + + it('should translate with missing required parameters', () => { + const result = AppError.translate(null as any, { + output: { + payload: { + message: `foo${'is required'}`, + validation: { + keys: ['bar', 'baz'], + }, + }, + }, + } as any, []); + expect(result instanceof AppError).toBe(true); + expect(result.errno).toBe(108); + expect(result.message).toBe('Missing parameter in request body: bar'); + expect(result.output.statusCode).toBe(400); + expect(result.output.payload.error).toBe('Bad Request'); + expect(result.output.payload.errno).toBe(result.errno); + expect(result.output.payload.message).toBe(result.message); + expect(result.output.payload.param).toBe('bar'); + }); + + it('should translate with payload data', () => { + const data = require('../test/local/payments/fixtures/paypal/do_reference_transaction_failure.json'); + + const result = AppError.translate(null as any, { + output: { + statusCode: 500, + payload: { + error: 'Internal Server Error', + }, + }, + data: data, + } as any, []); + + expect(JSON.stringify(data)).toBe(result.output.payload.data); + }); + + it('should translate with invalid parameter', () => { + const result = AppError.translate(null as any, { + output: { + payload: { + validation: 'foo', + }, + }, + } as any, []); + expect(result instanceof AppError).toBe(true); + expect(result.errno).toBe(107); + expect(result.message).toBe('Invalid parameter in request body'); + expect(result.output.statusCode).toBe(400); + expect(result.output.payload.error).toBe('Bad Request'); + expect(result.output.payload.errno).toBe(result.errno); + expect(result.output.payload.message).toBe(result.message); + expect(result.output.payload.validation).toBe('foo'); + }); + + it('should translate with missing payload', () => { + const result = AppError.translate(null as any, { + output: {}, + } as any, []); + expect(result instanceof AppError).toBe(true); + expect(result.errno).toBe(999); + expect(result.message).toBe('Unspecified error'); + expect(result.output.statusCode).toBe(500); + expect(result.output.payload.error).toBe('Internal Server Error'); + expect(result.output.payload.errno).toBe(result.errno); + expect(result.output.payload.message).toBe(result.message); + }); + + it('maps an errno to its key', () => { + const error = AppError.cannotLoginNoPasswordSet(); + const actual = AppError.mapErrnoToKey(error); + expect(actual).toBe('UNABLE_TO_LOGIN_NO_PASSWORD_SET'); + }); + + it('backend error includes a cause error when supplied', () => { + const originalError = new Error('Service timed out.'); + const err = AppError.backendServiceFailure( + 'test', + 'checking', + {}, + originalError + ); + const fullError = verror.fullStack(err); + expect(fullError).toContain('caused by:'); + expect(fullError).toContain('Error: Service timed out.'); + }); + + it('tooManyRequests', () => { + let result = AppError.tooManyRequests(900, 'in 15 minutes'); + expect(result instanceof AppError).toBe(true); + expect(result.errno).toBe(114); + expect(result.message).toBe('Client has sent too many requests'); + expect(result.output.statusCode).toBe(429); + expect(result.output.payload.error).toBe('Too Many Requests'); + expect(result.output.payload.retryAfter).toBe(900); + expect(result.output.payload.retryAfterLocalized).toBe('in 15 minutes'); + + result = AppError.tooManyRequests(900); + expect(result.output.payload.retryAfter).toBe(900); + expect(result.output.payload.retryAfterLocalized).toBeUndefined(); + }); + + it('iapInvalidToken', () => { + const defaultErrorMessage = 'Invalid IAP token'; + let result = AppError.iapInvalidToken(); + expect(result instanceof AppError).toBe(true); + expect(result.errno).toBe(196); + expect(result.message).toBe(defaultErrorMessage); + expect(result.output.statusCode).toBe(400); + expect(result.output.payload.error).toBe('Bad Request'); + + let iapAPIError: any = { someProp: 123 }; + result = AppError.iapInvalidToken(iapAPIError); + expect(result.message).toBe(defaultErrorMessage); + + iapAPIError = { message: 'Wow helpful extra info' }; + result = AppError.iapInvalidToken(iapAPIError); + expect(result.message).toBe( + `${defaultErrorMessage}: ${iapAPIError.message}` + ); + }); + + it('unexpectedError without request data', () => { + const err = AppError.unexpectedError(); + expect(err instanceof AppError).toBe(true); + expect(err instanceof Error).toBe(true); + expect(err.errno).toBe(999); + expect(err.message).toBe('Unspecified error'); + expect(err.output.statusCode).toBe(500); + expect(err.output.payload.error).toBe('Internal Server Error'); + expect(err.output.payload.request).toBeUndefined(); + }); + + it('unexpectedError with request data', () => { + const err = AppError.unexpectedError({ + app: { + acceptLanguage: 'en, fr', + locale: 'en', + geo: { + city: 'Mountain View', + state: 'California', + }, + ua: { + os: 'Android', + osVersion: '9', + }, + devices: Promise.resolve([{ id: 1 }]), + metricsContext: Promise.resolve({ + service: 'sync', + }), + }, + method: 'GET', + path: '/v1/wibble', + query: { + foo: 'bar', + }, + payload: { + baz: 'qux', + email: 'foo@example.com', + displayName: 'Foo Bar', + metricsContext: { + utmSource: 'thingy', + }, + service: 'sync', + }, + headers: { + // x-forwarded-for is stripped out because it contains internal server IPs + // See https://github.com/mozilla/fxa-private/issues/66 + 'x-forwarded-for': '192.168.1.1 192.168.2.2', + wibble: 'blee', + }, + } as any); + expect(err.errno).toBe(999); + expect(err.message).toBe('Unspecified error'); + expect(err.output.statusCode).toBe(500); + expect(err.output.payload.error).toBe('Internal Server Error'); + expect(err.output.payload.request).toEqual({ + acceptLanguage: 'en, fr', + locale: 'en', + userAgent: { + os: 'Android', + osVersion: '9', + }, + method: 'GET', + path: '/v1/wibble', + query: { + foo: 'bar', + }, + payload: { + metricsContext: { + utmSource: 'thingy', + }, + service: 'sync', + }, + headers: { + wibble: 'blee', + }, + }); + }); + + const reasons = ['socket hang up', 'ECONNREFUSED']; + reasons.forEach((reason) => { + it(`converts ${reason} errors to backend service error`, () => { + const result = AppError.translate(null as any, { + output: { + payload: { + errno: 999, + statusCode: 500, + }, + }, + reason, + } as any, []); + + expect(result instanceof AppError).toBe(true); + expect(result.errno).toBe(203); + expect(result.message).toBe('System unavailable, try again soon'); + expect(result.output.statusCode).toBe(500); + expect(result.output.payload.error).toBe('Internal Server Error'); + expect(result.output.payload.errno).toBe(AppError.ERRNO.BACKEND_SERVICE_FAILURE); + expect(result.output.payload.message).toBe( + 'System unavailable, try again soon' + ); + }); + }); +}); diff --git a/packages/fxa-auth-server/lib/features.spec.ts b/packages/fxa-auth-server/lib/features.spec.ts new file mode 100644 index 00000000000..4d76e1ad6dd --- /dev/null +++ b/packages/fxa-auth-server/lib/features.spec.ts @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import sinon from 'sinon'; + +let hashResult = Array(40).fill('0').join(''); +const hash = { + update: sinon.spy(), + digest: sinon.spy(() => hashResult), +}; +const mockCrypto = { + createHash: sinon.spy(() => hash), +}; + +jest.mock('crypto', () => mockCrypto); + +const config = { + lastAccessTimeUpdates: {} as any, + signinConfirmation: {}, + signinUnblock: {}, + securityHistory: {}, +}; + +// Import after jest.mock +const featuresModule = require('./features'); +const features = featuresModule(config); + +describe('features', () => { + beforeEach(() => { + mockCrypto.createHash.resetHistory(); + hash.update.resetHistory(); + hash.digest.resetHistory(); + }); + + it('interface is correct', () => { + expect(typeof featuresModule.schema).toBe('object'); + expect(featuresModule.schema).not.toBeNull(); + + expect(typeof features).toBe('object'); + expect(Object.keys(features)).toHaveLength(2); + expect(typeof features.isSampledUser).toBe('function'); + expect(typeof features.isLastAccessTimeEnabledForUser).toBe('function'); + }); + + it('isSampledUser returns true when sample rate is 1', () => { + const uid = Array(64).fill('f').join(''); + const sampleRate = 1; + hashResult = Array(40).fill('f').join(''); + + expect(features.isSampledUser(sampleRate, uid, 'foo')).toBe(true); + + expect(mockCrypto.createHash.callCount).toBe(0); + expect(hash.update.callCount).toBe(0); + expect(hash.digest.callCount).toBe(0); + }); + + it('isSampledUser returns false when sample rate is 0', () => { + const uid = Array(64).fill('f').join(''); + const sampleRate = 0; + hashResult = Array(40).fill('0').join(''); + + expect(features.isSampledUser(sampleRate, uid, 'foo')).toBe(false); + + expect(mockCrypto.createHash.callCount).toBe(0); + expect(hash.update.callCount).toBe(0); + expect(hash.digest.callCount).toBe(0); + }); + + it('isSampledUser returns true when sample rate is greater than cohort value', () => { + const uid = Array(64).fill('f').join(''); + const sampleRate = 0.05; + // First 27 characters are ignored, last 13 are 0.04 * 0xfffffffffffff + hashResult = '0000000000000000000000000000a3d70a3d70a6'; + + expect(features.isSampledUser(sampleRate, uid, 'foo')).toBe(true); + + expect(mockCrypto.createHash.callCount).toBe(1); + let args: any = mockCrypto.createHash.args[0]; + expect(args).toHaveLength(1); + expect(args[0]).toBe('sha1'); + + expect(hash.update.callCount).toBe(2); + args = hash.update.args[0]; + expect(args).toHaveLength(1); + expect(args[0]).toBe(uid.toString()); + args = hash.update.args[1]; + expect(args).toHaveLength(1); + expect(args[0]).toBe('foo'); + + expect(hash.digest.callCount).toBe(1); + args = hash.digest.args[0]; + expect(args).toHaveLength(1); + expect(args[0]).toBe('hex'); + }); + + it('isSampledUser returns false when sample rate equals cohort value', () => { + const uid = Array(64).fill('f').join(''); + const sampleRate = 0.04; + hashResult = '0000000000000000000000000000a3d70a3d70a6'; + + expect(features.isSampledUser(sampleRate, uid, 'bar')).toBe(false); + + expect(mockCrypto.createHash.callCount).toBe(1); + expect(hash.update.callCount).toBe(2); + expect(hash.update.args[0][0]).toBe(uid.toString()); + expect(hash.update.args[1][0]).toBe('bar'); + expect(hash.digest.callCount).toBe(1); + }); + + it('isSampledUser returns false when sample rate is less than cohort value', () => { + const uid = Array(64).fill('f').join(''); + const sampleRate = 0.03; + + expect(features.isSampledUser(sampleRate, uid, 'foo')).toBe(false); + }); + + it('isSampledUser with different uid', () => { + const uid = Array(64).fill('7').join(''); + const sampleRate = 0.03; + // First 27 characters are ignored, last 13 are 0.02 * 0xfffffffffffff + hashResult = '000000000000000000000000000051eb851eb852'; + + mockCrypto.createHash.resetHistory(); + hash.update.resetHistory(); + hash.digest.resetHistory(); + + expect(features.isSampledUser(sampleRate, uid, 'wibble')).toBe(true); + + expect(hash.update.callCount).toBe(2); + expect(hash.update.args[0][0]).toBe(uid); + expect(hash.update.args[1][0]).toBe('wibble'); + }); + + it('isLastAccessTimeEnabledForUser', () => { + const uid = 'foo'; + const email = 'bar@mozilla.com'; + // First 27 characters are ignored, last 13 are 0.02 * 0xfffffffffffff + hashResult = '000000000000000000000000000051eb851eb852'; + + config.lastAccessTimeUpdates.enabled = true; + config.lastAccessTimeUpdates.sampleRate = 0; + + config.lastAccessTimeUpdates.sampleRate = 0.03; + expect(features.isLastAccessTimeEnabledForUser(uid, email)).toBe(true); + + config.lastAccessTimeUpdates.sampleRate = 0.02; + expect(features.isLastAccessTimeEnabledForUser(uid, email)).toBe(false); + + config.lastAccessTimeUpdates.enabled = false; + config.lastAccessTimeUpdates.sampleRate = 0.03; + expect(features.isLastAccessTimeEnabledForUser(uid, email)).toBe(false); + }); +}); diff --git a/packages/fxa-auth-server/lib/geodb.spec.ts b/packages/fxa-auth-server/lib/geodb.spec.ts new file mode 100644 index 00000000000..66ab9435674 --- /dev/null +++ b/packages/fxa-auth-server/lib/geodb.spec.ts @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import sinon from 'sinon'; + +const knownIpLocation = require('../test/known-ip-location'); + +function mockLog() { + return { + info: sinon.stub(), + trace: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + debug: sinon.stub(), + }; +} + +describe('geodb', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('returns location data when enabled', () => { + jest.doMock('../config', () => ({ + default: { + get: function (item: string) { + if (item === 'geodb') { + return { + enabled: true, + locationOverride: {}, + }; + } + return undefined; + }, + }, + })); + + const thisMockLog = mockLog(); + const getGeoData = require('./geodb')(thisMockLog); + const geoData = getGeoData(knownIpLocation.ip); + + expect(knownIpLocation.location.city.has(geoData.location.city)).toBe(true); + expect(geoData.location.country).toBe(knownIpLocation.location.country); + expect(geoData.location.countryCode).toBe( + knownIpLocation.location.countryCode + ); + expect(geoData.timeZone).toBe(knownIpLocation.location.tz); + expect(geoData.location.state).toBe(knownIpLocation.location.state); + expect(geoData.location.stateCode).toBe(knownIpLocation.location.stateCode); + }); + + it('returns empty object data when disabled', () => { + jest.doMock('../config', () => ({ + default: { + get: function (item: string) { + if (item === 'geodb') { + return { + enabled: false, + }; + } + return undefined; + }, + }, + })); + + const thisMockLog = mockLog(); + const getGeoData = require('./geodb')(thisMockLog); + const geoData = getGeoData('8.8.8.8'); + + expect(geoData).toEqual({}); + }); +}); diff --git a/packages/fxa-auth-server/lib/getRemoteAddressChain.spec.ts b/packages/fxa-auth-server/lib/getRemoteAddressChain.spec.ts new file mode 100644 index 00000000000..28d94389de9 --- /dev/null +++ b/packages/fxa-auth-server/lib/getRemoteAddressChain.spec.ts @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getRemoteAddressChain } from './getRemoteAddressChain'; + +describe('getRemoteAddressChain', () => { + describe('when remoteAddressChainOverride is disabled', () => { + const remoteAddressChainOverride = ''; + + it('builds remote address chain from x-forwarded-for', () => { + const xForwardedFor = '10.0.0.1,10.0.1.1'; + const remoteAddress = '10.5.0.1'; + const mockRequest = { + headers: { + 'x-forwarded-for': xForwardedFor, + }, + info: { + remoteAddress, + }, + } as any; + + const result = getRemoteAddressChain( + mockRequest, + remoteAddressChainOverride + ); + + expect(result.join(',')).toBe(`${xForwardedFor},${remoteAddress}`); + }); + + it('filters invalid IP addresses', () => { + const xForwardedFor = 'asdf,asdf'; + const remoteAddress = '10.5.0.1'; + const mockRequest = { + headers: { + 'x-forwarded-for': xForwardedFor, + }, + info: { + remoteAddress, + }, + } as any; + + const result = getRemoteAddressChain( + mockRequest, + remoteAddressChainOverride + ); + + expect(result.join(',')).toBe(remoteAddress); + }); + }); + + describe('when remoteAddressChainOverride is enabled', () => { + const remoteAddressChainOverride = '192.168.1.1,192.168.2.1'; + + it('returns remoteAddressChainOverride', () => { + const xForwardedFor = '10.0.0.1,10.0.1.1'; + const remoteAddress = '10.5.0.1'; + const mockRequest = { + headers: { + 'x-forwarded-for': xForwardedFor, + }, + info: { + remoteAddress, + }, + } as any; + + const result = getRemoteAddressChain( + mockRequest, + remoteAddressChainOverride + ); + + expect(result.join(',')).toBe(remoteAddressChainOverride); + }); + }); +}); diff --git a/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts b/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts new file mode 100644 index 00000000000..e66bcec4925 --- /dev/null +++ b/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts @@ -0,0 +1,490 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * NOTE: This test has been migrated from test/local/ip_profiling.js + * It tests the IP profiling behavior in the account login route. + * + * This test uses inline mocks to avoid dependency on the complex mocks.js + * which has proxyquire path resolution issues when called from lib/routes. + */ + +// IMPORTANT: Set environment variable BEFORE any imports that might load config +process.env.FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY = 'true'; + +import crypto from 'crypto'; +import sinon from 'sinon'; +import { Container } from 'typedi'; +import { v4 as uuid } from 'uuid'; +import { normalizeEmail } from 'fxa-shared/email/helpers'; + +const TEST_EMAIL = 'foo@gmail.com'; +const MS_ONE_DAY = 1000 * 60 * 60 * 24; +const UID = uuid({}, Buffer.alloc(16)).toString('hex'); + +const KNOWN_IP = '63.245.221.32'; // Mountain View, CA +const KNOWN_LOCATION = { + city: 'Mountain View', + country: 'United States', + countryCode: 'US', + state: 'California', + stateCode: 'CA', +}; + +const { ProfileClient } = require('@fxa/profile/client'); +const { AccountEventsManager } = require('../account-events'); +const { AccountDeleteManager } = require('../account-delete'); +const { AppConfig, AuthLogger } = require('../types'); +const { FxaMailer } = require('../senders/fxa-mailer'); +const { OAuthClientInfoServiceName } = require('../senders/oauth_client_info'); + +function mockGlean() { + const noopAsync = sinon.stub().resolves({}); + return { + login: { + success: noopAsync, + error: noopAsync, + totpSuccess: noopAsync, + totpFailure: noopAsync, + verifyCodeConfirmationEmailSent: noopAsync, + verifyCodeEmailSent: noopAsync, + complete: noopAsync, + }, + registration: { + accountCreated: noopAsync, + confirmationEmailSent: noopAsync, + complete: noopAsync, + error: noopAsync, + }, + resetPassword: { + emailSent: noopAsync, + createNewSuccess: noopAsync, + accountReset: noopAsync, + }, + account: { + passwordChanged: noopAsync, + passwordReset: noopAsync, + }, + loginConfirmSkipFor: { + knownIp: noopAsync, + }, + }; +} + +function mockLog() { + return { + activityEvent: sinon.stub().resolves(), + amplitudeEvent: sinon.stub().resolves(), + begin: sinon.stub(), + error: sinon.stub(), + flowEvent: sinon.stub().resolves(), + info: sinon.stub(), + notifyAttachedServices: sinon.stub().resolves(), + warn: sinon.stub(), + summary: sinon.stub(), + trace: sinon.stub(), + debug: sinon.stub(), + }; +} + +function mockDB(data: any) { + return { + account: sinon.stub().resolves({ + email: data.email, + emailVerified: data.emailVerified, + uid: data.uid, + primaryEmail: { + normalizedEmail: normalizeEmail(data.email), + email: data.email, + isVerified: data.emailVerified, + isPrimary: true, + }, + }), + accountRecord: sinon.stub().resolves({ + authSalt: crypto.randomBytes(32), + data: crypto.randomBytes(32), + email: data.email, + emailVerified: data.emailVerified, + primaryEmail: { + normalizedEmail: normalizeEmail(data.email), + email: data.email, + isVerified: data.emailVerified, + isPrimary: true, + }, + kA: crypto.randomBytes(32), + lastAuthAt: () => Date.now(), + uid: data.uid, + wrapWrapKb: crypto.randomBytes(32), + verifierSetAt: Date.now(), + }), + createSessionToken: sinon.stub().callsFake((opts: any) => { + return Promise.resolve({ + createdAt: opts.createdAt || Date.now(), + data: crypto.randomBytes(32).toString('hex'), + email: opts.email || data.email, + emailVerified: opts.emailVerified ?? data.emailVerified, + lastAuthAt: () => opts.createdAt || Date.now(), + id: crypto.randomBytes(32).toString('hex'), + tokenVerificationId: opts.tokenVerificationId, + tokenVerified: !opts.tokenVerificationId, + mustVerify: opts.mustVerify ?? false, + uid: opts.uid || data.uid, + }); + }), + createKeyFetchToken: sinon.stub().resolves({ + data: crypto.randomBytes(32).toString('hex'), + id: crypto.randomBytes(32).toString('hex'), + uid: data.uid, + }), + securityEvents: sinon.stub().resolves([]), + securityEvent: sinon.stub().resolves(), + verifiedLoginSecurityEvents: sinon.stub().resolves([]), + touchSessionToken: sinon.stub().resolves(), + totpToken: sinon.stub().resolves({ enabled: false }), + sessions: sinon.stub().resolves([]), + devices: sinon.stub().resolves([]), + }; +} + +function mockMailer() { + return { + sendVerifyLoginEmail: sinon.stub().resolves(), + sendNewDeviceLoginEmail: sinon.stub().resolves(), + sendVerifyEmail: sinon.stub().resolves(), + sendVerifyLoginCodeEmail: sinon.stub().resolves(), + }; +} + +function mockFxaMailer() { + const mock = { + canSend: sinon.stub().resolves(true), + sendNewDeviceLoginEmail: sinon.stub().resolves(), + sendVerifyLoginEmail: sinon.stub().resolves(), + }; + Container.set(FxaMailer, mock); + return mock; +} + +function mockOAuthClientInfo() { + const mock = { + fetch: sinon.stub().resolves('sync'), + }; + Container.set(OAuthClientInfoServiceName, mock); + return mock; +} + +function mockPush() { + return { + notifyDeviceConnected: sinon.stub().resolves(), + notifyDeviceDisconnected: sinon.stub().resolves(), + notifyPasswordChanged: sinon.stub().resolves(), + notifyPasswordReset: sinon.stub().resolves(), + notifyAccountUpdated: sinon.stub().resolves(), + notifyAccountDestroyed: sinon.stub().resolves(), + notifyCommandReceived: sinon.stub().resolves(), + notifyProfileUpdated: sinon.stub().resolves(), + notifyVerifyLoginRequest: sinon.stub().resolves(), + sendPush: sinon.stub().resolves(), + }; +} + +function mockVerificationReminders() { + return { + keys: ['first', 'second', 'third', 'final'], + create: sinon.stub().returns({ first: 1, second: 1, third: 1, final: 1 }), + delete: sinon.stub().returns({ first: 1, second: 1, third: 1, final: 1 }), + process: sinon.stub().returns({ first: [], second: [], third: [], final: [] }), + }; +} + +function mockCadReminders() { + return { + keys: ['first', 'second', 'third'], + create: sinon.stub().returns({ first: 1, second: 1, third: 1 }), + delete: sinon.stub().returns({ first: 1, second: 1, third: 1 }), + get: sinon.stub().returns({ first: null, second: null, third: null }), + process: sinon.stub().returns({ first: [], second: [], third: [] }), + }; +} + +function mockStatsd() { + return { + increment: sinon.stub(), + timing: sinon.stub(), + histogram: sinon.stub(), + }; +} + +function mockRequest(data: any) { + const metricsContext = data.payload?.metricsContext || {}; + + return { + app: { + acceptLanguage: 'en-US', + clientAddress: KNOWN_IP, + devices: Promise.resolve([]), + features: new Set(), + geo: { + timeZone: 'America/Los_Angeles', + location: KNOWN_LOCATION, + }, + locale: 'en-US', + metricsContext: Promise.resolve(metricsContext), + ua: { + browser: 'Firefox', + browserVersion: '57.0', + os: 'Mac OS X', + osVersion: '10.13', + deviceType: null, + formFactor: null, + }, + isMetricsEnabled: Promise.resolve(true), + }, + auth: { + credentials: data.credentials, + }, + clearMetricsContext: sinon.stub(), + emitMetricsEvent: sinon.stub().resolves(), + emitRouteFlowEvent: sinon.stub().resolves(), + gatherMetricsContext: sinon.stub().callsFake((d: any) => Promise.resolve(d)), + headers: { + 'user-agent': 'test user-agent', + }, + info: { + received: Date.now() - 1, + completed: 0, + }, + params: {}, + path: data.path, + payload: data.payload || {}, + propagateMetricsContext: sinon.stub().resolves(), + query: data.query || {}, + setMetricsFlowCompleteSignal: sinon.stub(), + stashMetricsContext: sinon.stub().resolves(), + validateMetricsContext: sinon.stub().returns(true), + }; +} + +function getRoute(routes: any[], path: string) { + return routes.find((r: any) => r.path === path); +} + +function makeRoutes(options: { db: any; mailer: any }) { + const { db, mailer } = options; + const config = { + oauth: {}, + securityHistory: { + ipProfiling: { + allowedRecency: MS_ONE_DAY, + }, + ipHmacKey: 'cool', + }, + signinConfirmation: {}, + smtp: {}, + }; + const log = mockLog(); + Container.set(AccountEventsManager, { + recordSecurityEvent: sinon.stub().resolves(), + }); + Container.set(AccountDeleteManager, { enqueue: sinon.stub() }); + Container.set(AppConfig, config); + Container.set(AuthLogger, log); + const cadReminders = mockCadReminders(); + const customs = { + check: sinon.stub().resolves(true), + flag: sinon.stub(), + }; + const signinUtils = require('./utils/signin')( + log, + config, + customs, + db, + mailer, + cadReminders + ); + signinUtils.checkPassword = () => Promise.resolve(true); + const glean = mockGlean(); + const { accountRoutes } = require('./account'); + + const authServerCacheRedis = { + get: sinon.stub().resolves(null), + del: sinon.stub().resolves(0), + }; + + return accountRoutes( + log, + db, + mailer, + require('../crypto/password')(log, config), + config, + customs, + signinUtils, + null, + mockPush(), + mockVerificationReminders(), + null, + null, + null, + null, + glean, + authServerCacheRedis, + mockStatsd() + ); +} + +async function runTest( + route: any, + request: any, + assertions?: (response: any) => void +): Promise { + const response = await route.handler(request); + if (assertions) { + assertions(response); + } + return response; +} + +describe('IP Profiling', () => { + let route: any; + let accountRoutes: any; + let mockDBInstance: any; + let mockMailerInstance: any; + let mockFxaMailerInstance: any; + let mockRequestInstance: any; + + jest.setTimeout(30000); + + beforeEach(() => { + jest.clearAllMocks(); + mockFxaMailerInstance = mockFxaMailer(); + mockOAuthClientInfo(); + mockDBInstance = mockDB({ + email: TEST_EMAIL, + emailVerified: true, + uid: UID, + }); + mockMailerInstance = mockMailer(); + mockRequestInstance = mockRequest({ + payload: { + authPW: crypto.randomBytes(32).toString('hex'), + email: TEST_EMAIL, + service: 'sync', + reason: 'signin', + metricsContext: { + flowBeginTime: Date.now(), + flowId: + 'F1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF1031DF103', + }, + }, + query: { + keys: 'true', + }, + }); + Container.set(ProfileClient, {}); + accountRoutes = makeRoutes({ + db: mockDBInstance, + mailer: mockMailerInstance, + }); + route = getRoute(accountRoutes, '/account/login'); + }); + + afterAll(() => { + Container.reset(); + }); + + it('no previously verified session', async () => { + mockDBInstance.verifiedLoginSecurityEvents = sinon.stub().resolves([ + { + name: 'account.login', + createdAt: Date.now(), + verified: false, + }, + ]); + + await runTest(route, mockRequestInstance, (response: any) => { + expect(mockMailerInstance.sendVerifyLoginEmail.callCount).toBe(1); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(response.sessionVerified).toBe(false); + }); + }); + + it('previously verified session', async () => { + mockDBInstance.verifiedLoginSecurityEvents = sinon.stub().resolves([ + { + name: 'account.login', + createdAt: Date.now(), + verified: true, + }, + ]); + + await runTest(route, mockRequestInstance, (response: any) => { + expect(mockMailerInstance.sendVerifyLoginEmail.callCount).toBe(0); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(1); + expect(response.sessionVerified).toBe(true); + }); + }); + + it('previously verified session more than a day', async () => { + mockDBInstance.securityEvents = sinon.stub().resolves([ + { + name: 'account.login', + createdAt: Date.now() - MS_ONE_DAY * 2, // Created two days ago + verified: true, + }, + ]); + + await runTest(route, mockRequestInstance, (response: any) => { + expect(mockMailerInstance.sendVerifyLoginEmail.callCount).toBe(1); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(response.sessionVerified).toBe(false); + }); + }); + + it('previously verified session with forced sign-in confirmation', async () => { + const forceSigninEmail = 'forcedemail@mozilla.com'; + mockRequestInstance.payload.email = forceSigninEmail; + + mockDBInstance.accountRecord = sinon.stub().resolves({ + authSalt: crypto.randomBytes(32), + data: crypto.randomBytes(32), + email: forceSigninEmail, + emailVerified: true, + primaryEmail: { + normalizedEmail: forceSigninEmail, + email: forceSigninEmail, + isVerified: true, + isPrimary: true, + }, + kA: crypto.randomBytes(32), + lastAuthAt: () => Date.now(), + uid: UID, + wrapWrapKb: crypto.randomBytes(32), + }); + + let response = await runTest(route, mockRequestInstance); + expect(mockMailerInstance.sendVerifyLoginEmail.callCount).toBe(1); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(response.sessionVerified).toBe(false); + + response = await runTest(route, mockRequestInstance); + expect(mockMailerInstance.sendVerifyLoginEmail.callCount).toBe(2); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(response.sessionVerified).toBe(false); + }); + + it('previously verified session with suspicious request', async () => { + mockRequestInstance.app.clientAddress = '63.245.221.32'; + mockRequestInstance.app.isSuspiciousRequest = true; + + let response = await runTest(route, mockRequestInstance); + expect(mockMailerInstance.sendVerifyLoginEmail.callCount).toBe(1); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(response.sessionVerified).toBe(false); + + response = await runTest(route, mockRequestInstance); + expect(mockMailerInstance.sendVerifyLoginEmail.callCount).toBe(2); + expect(mockFxaMailerInstance.sendNewDeviceLoginEmail.callCount).toBe(0); + expect(response.sessionVerified).toBe(false); + }); +}); diff --git a/packages/fxa-auth-server/lib/time.spec.ts b/packages/fxa-auth-server/lib/time.spec.ts new file mode 100644 index 00000000000..fcddb877075 --- /dev/null +++ b/packages/fxa-auth-server/lib/time.spec.ts @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { startOfMinute } from './time'; + +describe('time module', () => { + it('returned the expected interface', () => { + expect(typeof startOfMinute).toBe('function'); + expect(startOfMinute.length).toBe(1); + }); + + describe('start of minute:', () => { + it('returns the correct time', () => { + const date = new Date('2018-10-10T10:10Z'); + expect(startOfMinute(date)).toBe('2018-10-10T10:10:00Z'); + }); + }); + + describe('end of minute:', () => { + it('returns the correct time', () => { + const date = new Date('2018-10-10T10:10:59.999Z'); + expect(startOfMinute(date)).toBe('2018-10-10T10:10:00Z'); + }); + }); + + describe('with padding:', () => { + it('returns the correct time', () => { + const date = new Date('2018-09-09T09:09Z'); + expect(startOfMinute(date)).toBe('2018-09-09T09:09:00Z'); + }); + }); + + describe('non-UTC timezone:', () => { + it('returns the correct time', () => { + const date = new Date('2018-01-01T00:00+01:00'); + expect(startOfMinute(date)).toBe('2017-12-31T23:00:00Z'); + }); + }); +}); diff --git a/packages/fxa-auth-server/lib/verification-reminders.spec.ts b/packages/fxa-auth-server/lib/verification-reminders.spec.ts new file mode 100644 index 00000000000..503e1b5f5bb --- /dev/null +++ b/packages/fxa-auth-server/lib/verification-reminders.spec.ts @@ -0,0 +1,465 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +/** + * @jest-environment node + */ + +import sinon from 'sinon'; + +const REMINDERS = ['first', 'second', 'third']; +const EXPECTED_CREATE_DELETE_RESULT = REMINDERS.reduce( + (expected: Record, reminder) => { + expected[reminder] = 1; + return expected; + }, + {} +); + +function mockLog() { + return { + info: sinon.stub(), + trace: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + debug: sinon.stub(), + }; +} + +const config = require('../config').default.getProperties(); + +describe('#integration - lib/verification-reminders', () => { + let log: ReturnType; + let mockConfig: any; + let redis: any; + let verificationReminders: any; + + beforeEach(() => { + log = mockLog(); + mockConfig = { + redis: config.redis, + verificationReminders: { + rolloutRate: 1, + firstInterval: 1, + secondInterval: 2, + thirdInterval: 1000, + redis: { + maxConnections: 1, + minConnections: 1, + prefix: 'test-verification-reminders:', + }, + }, + }; + redis = require('./redis')( + { + ...config.redis, + ...mockConfig.verificationReminders.redis, + enabled: true, + }, + mockLog() + ); + verificationReminders = require('./verification-reminders')(log, mockConfig); + }); + + afterEach(async () => { + await redis.close(); + await verificationReminders.close(); + }); + + it('returned the expected interface', () => { + expect(typeof verificationReminders).toBe('object'); + expect(Object.keys(verificationReminders)).toHaveLength(6); + + expect(verificationReminders.keys).toEqual(['first', 'second', 'third']); + + expect(typeof verificationReminders.create).toBe('function'); + expect(verificationReminders.create.length).toBe(3); + + expect(typeof verificationReminders.delete).toBe('function'); + expect(verificationReminders.delete.length).toBe(1); + + expect(typeof verificationReminders.process).toBe('function'); + expect(verificationReminders.process.length).toBe(0); + + expect(typeof verificationReminders.reinstate).toBe('function'); + expect(verificationReminders.reinstate.length).toBe(2); + + expect(typeof verificationReminders.close).toBe('function'); + expect(verificationReminders.close.length).toBe(0); + }); + + describe('create without metadata:', () => { + let before: number; + let createResult: any; + + beforeEach(async () => { + before = Date.now(); + // Clobber keys to assert that misbehaving callers can't wreck the internal behaviour + verificationReminders.keys = []; + createResult = await verificationReminders.create( + 'wibble', + undefined, + undefined, + before - 1 + ); + }); + + afterEach(() => { + return verificationReminders.delete('wibble'); + }); + + it('returned the correct result', async () => { + expect(createResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); + }); + + REMINDERS.forEach((reminder) => { + it(`wrote ${reminder} reminder to redis`, async () => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toEqual(['wibble']); + }); + }); + + it('did not write metadata to redis', async () => { + const metadata = await redis.get('metadata:wibble'); + expect(metadata).toBeNull(); + }); + + describe('delete:', () => { + let deleteResult: any; + + beforeEach(async () => { + deleteResult = await verificationReminders.delete('wibble'); + }); + + it('returned the correct result', async () => { + expect(deleteResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); + }); + + REMINDERS.forEach((reminder) => { + it(`removed ${reminder} reminder from redis`, async () => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toHaveLength(0); + }); + }); + + it('did not call log.error', () => { + expect(log.error.callCount).toBe(0); + }); + }); + + describe('process:', () => { + let processResult: any; + + beforeEach(async () => { + await verificationReminders.create('blee', undefined, undefined, before); + processResult = await verificationReminders.process(before + 2); + }); + + afterEach(() => { + return verificationReminders.delete('blee'); + }); + + it('returned the correct result', async () => { + expect(typeof processResult).toBe('object'); + + expect(Array.isArray(processResult.first)).toBe(true); + expect(processResult.first).toHaveLength(2); + expect(typeof processResult.first[0]).toBe('object'); + expect(processResult.first[0].uid).toBe('wibble'); + expect(processResult.first[0].flowId).toBeUndefined(); + expect(processResult.first[0].flowBeginTime).toBeUndefined(); + expect(parseInt(processResult.first[0].timestamp)).toBeGreaterThan( + before - 1000 + ); + expect(parseInt(processResult.first[0].timestamp)).toBeLessThan(before); + expect(processResult.first[1].uid).toBe('blee'); + expect(parseInt(processResult.first[1].timestamp)).toBeGreaterThanOrEqual( + before + ); + expect(parseInt(processResult.first[1].timestamp)).toBeLessThan( + before + 1000 + ); + expect(processResult.first[1].flowId).toBeUndefined(); + expect(processResult.first[1].flowBeginTime).toBeUndefined(); + + expect(Array.isArray(processResult.second)).toBe(true); + expect(processResult.second).toHaveLength(2); + expect(processResult.second[0].uid).toBe('wibble'); + expect(processResult.second[0].timestamp).toBe( + processResult.first[0].timestamp + ); + expect(processResult.second[0].flowId).toBeUndefined(); + expect(processResult.second[0].flowBeginTime).toBeUndefined(); + expect(processResult.second[1].uid).toBe('blee'); + expect(processResult.second[1].timestamp).toBe( + processResult.first[1].timestamp + ); + expect(processResult.second[1].flowId).toBeUndefined(); + expect(processResult.second[1].flowBeginTime).toBeUndefined(); + + expect(processResult.third).toEqual([]); + }); + + REMINDERS.forEach((reminder) => { + if (reminder !== 'third') { + it(`removed ${reminder} reminder from redis correctly`, async () => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toHaveLength(0); + }); + } else { + it('left the third reminders in redis', async () => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(new Set(reminders)).toEqual(new Set(['wibble', 'blee'])); + }); + } + }); + + it('did not call log.error', () => { + expect(log.error.callCount).toBe(0); + }); + + describe('reinstate:', () => { + let reinstateResult: any; + + beforeEach(async () => { + reinstateResult = await verificationReminders.reinstate('second', [ + { timestamp: 2, uid: 'wibble' }, + { timestamp: 3, uid: 'blee' }, + ]); + }); + + afterEach(async () => { + return await redis.zrem('second', 'wibble', 'blee'); + }); + + it('returned the correct result', () => { + expect(reinstateResult).toBe(2); + }); + + it('left the first reminder empty', async () => { + const reminders = await redis.zrange('first', 0, -1); + expect(reminders).toHaveLength(0); + }); + + it('reinstated records to the second reminder', async () => { + const reminders = await redis.zrange('second', 0, -1, 'WITHSCORES'); + expect(reminders).toEqual(['wibble', '2', 'blee', '3']); + }); + + it('left the third reminders in redis', async () => { + const reminders = await redis.zrange('third', 0, -1); + expect(new Set(reminders)).toEqual(new Set(['wibble', 'blee'])); + }); + }); + }); + }); + + describe('create with metadata:', () => { + let before: number; + let createResult: any; + + beforeEach(async () => { + before = Date.now(); + createResult = await verificationReminders.create('wibble', 'blee', 42, before); + }); + + afterEach(() => { + return verificationReminders.delete('wibble'); + }); + + it('returned the correct result', async () => { + expect(createResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); + }); + + REMINDERS.forEach((reminder) => { + it(`wrote ${reminder} reminder to redis`, async () => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toEqual(['wibble']); + }); + }); + + it('wrote metadata to redis', async () => { + const metadata = await redis.get('metadata:wibble'); + expect(JSON.parse(metadata)).toEqual(['blee', 42]); + }); + + describe('delete:', () => { + let deleteResult: any; + + beforeEach(async () => { + deleteResult = await verificationReminders.delete('wibble'); + }); + + it('returned the correct result', async () => { + expect(deleteResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); + }); + + REMINDERS.forEach((reminder) => { + it(`removed ${reminder} reminder from redis`, async () => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toHaveLength(0); + }); + }); + + it('removed metadata from redis', async () => { + const metadata = await redis.get('metadata:wibble'); + expect(metadata).toBeNull(); + }); + + it('did not call log.error', () => { + expect(log.error.callCount).toBe(0); + }); + }); + + describe('process:', () => { + let processResult: any; + + beforeEach(async () => { + processResult = await verificationReminders.process(before + 2); + }); + + it('returned the correct result', async () => { + expect(typeof processResult).toBe('object'); + + expect(Array.isArray(processResult.first)).toBe(true); + expect(processResult.first).toHaveLength(1); + expect(processResult.first[0].flowId).toBe('blee'); + expect(processResult.first[0].flowBeginTime).toBe(42); + + expect(Array.isArray(processResult.second)).toBe(true); + expect(processResult.second).toHaveLength(1); + expect(processResult.second[0].flowId).toBe('blee'); + expect(processResult.second[0].flowBeginTime).toBe(42); + + expect(processResult.third).toEqual([]); + }); + + REMINDERS.forEach((reminder) => { + if (reminder !== 'third') { + it(`removed ${reminder} reminder from redis correctly`, async () => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toHaveLength(0); + }); + } else { + it('left the third reminder in redis', async () => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toEqual(['wibble']); + }); + + it('left the metadata in redis', async () => { + const metadata = await redis.get('metadata:wibble'); + expect(JSON.parse(metadata)).toEqual(['blee', 42]); + }); + } + }); + + it('did not call log.error', () => { + expect(log.error.callCount).toBe(0); + }); + + describe('reinstate:', () => { + let reinstateResult: any; + + beforeEach(async () => { + reinstateResult = await verificationReminders.reinstate('second', [ + { + timestamp: 2, + uid: 'wibble', + flowId: 'different!', + flowBeginTime: 56, + }, + ]); + }); + + afterEach(async () => { + await redis.zrem('second', 'wibble'); + await redis.del('metadata:wibble'); + }); + + it('returned the correct result', () => { + expect(reinstateResult).toBe(1); + }); + + it('left the first reminder empty', async () => { + const reminders = await redis.zrange('first', 0, -1); + expect(reminders).toHaveLength(0); + }); + + it('reinstated record to the second reminder', async () => { + const reminders = await redis.zrange('second', 0, -1, 'WITHSCORES'); + expect(reminders).toEqual(['wibble', '2']); + }); + + it('left the third reminder in redis', async () => { + const reminders = await redis.zrange('third', 0, -1); + expect(reminders).toEqual(['wibble']); + }); + + it('reinstated the metadata', async () => { + const metadata = await redis.get('metadata:wibble'); + expect(JSON.parse(metadata)).toEqual(['different!', 56]); + }); + }); + + describe('process:', () => { + let secondProcessResult: any; + + beforeEach(async () => { + secondProcessResult = await verificationReminders.process(before + 1000); + }); + + // NOTE: Because this suite has a slow setup, don't add any more test cases! + // Add further assertions to this test case instead. + it('returned the correct result and cleared everything from redis', async () => { + expect(typeof secondProcessResult).toBe('object'); + + expect(secondProcessResult.first).toEqual([]); + expect(secondProcessResult.second).toEqual([]); + + expect(Array.isArray(secondProcessResult.third)).toBe(true); + expect(secondProcessResult.third).toHaveLength(1); + expect(secondProcessResult.third[0].uid).toBe('wibble'); + expect(secondProcessResult.third[0].flowId).toBe('blee'); + expect(secondProcessResult.third[0].flowBeginTime).toBe(42); + + const reminders = await redis.zrange('third', 0, -1); + expect(reminders).toHaveLength(0); + + const metadata = await redis.get('metadata:wibble'); + expect(metadata).toBeNull(); + }); + }); + }); + }); +}); + +describe('lib/verification-reminders with invalid config:', () => { + it('throws if config contains clashing metadata key', () => { + expect(() => { + require('./verification-reminders')( + { + info: () => {}, + trace: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {}, + }, + { + redis: config.redis, + verificationReminders: { + rolloutRate: 1, + firstInterval: 1, + secondInterval: 2, + metadataInterval: 3, + redis: { + maxConnections: 1, + minConnections: 1, + prefix: 'test-verification-reminders:', + }, + }, + } + ); + }).toThrow(); + }); +}); diff --git a/packages/fxa-auth-server/package.json b/packages/fxa-auth-server/package.json index 12fa2a8dce6..f032ff75d7a 100644 --- a/packages/fxa-auth-server/package.json +++ b/packages/fxa-auth-server/package.json @@ -36,6 +36,12 @@ "start": "yarn check:mysql && pm2 start pm2.config.js && yarn check:url localhost:9000/__heartbeat__", "restart": "pm2 restart pm2.config.js", "test": "VERIFIER_VERSION=0 scripts/test-local.sh", + "test:jest": "jest --no-coverage --forceExit", + "test:jest:unit": "jest --no-coverage --forceExit --testPathIgnorePatterns='verification-reminders'", + "test:jest:integration": "jest --no-coverage --forceExit --testPathPattern='verification-reminders'", + "test:jest:watch": "jest --watch --no-coverage", + "test:jest:coverage": "jest --coverage --forceExit", + "test:jest:ci": "JEST_JUNIT_OUTPUT_DIR='../../artifacts/tests/fxa-auth-server' JEST_JUNIT_OUTPUT_NAME='jest-results.xml' jest --coverage --forceExit --ci --reporters=default --reporters=jest-junit", "test-unit": "VERIFIER_VERSION=0 TEST_TYPE=unit scripts/test-ci.sh", "test-integration": "VERIFIER_VERSION=0 TEST_TYPE=integration scripts/test-ci.sh", "test-integration-v2": "VERIFIER_VERSION=0 TEST_TYPE=integration-v2 scripts/test-ci.sh", @@ -131,6 +137,7 @@ "@types/dedent": "^0", "@types/hapi__hapi": "^20.0.10", "@types/ioredis": "^4.26.4", + "@types/jest": "^29.5.12", "@types/jsonwebtoken": "8.5.1", "@types/jws": "^3.2.3", "@types/lodash": "^4", @@ -142,6 +149,7 @@ "@types/nodemailer": "^7.0.4", "@types/request": "2.48.5", "@types/sass": "^1", + "@types/sinon": "^17.0.3", "@types/uuid": "^10.0.0", "@types/verror": "^1.10.4", "@types/webpack": "5.28.5", @@ -163,6 +171,8 @@ "grunt-copyright": "0.3.0", "grunt-eslint": "^25.0.0", "grunt-newer": "1.3.0", + "jest": "^29.7.0", + "jest-junit": "^16.0.0", "jsxgettext-recursive-next": "1.1.0", "jws": "4.0.1", "keypair": "1.0.4", @@ -185,6 +195,7 @@ "sinon": "^9.0.3", "storybook": "^7.6.21", "through": "2.3.8", + "ts-jest": "^29.1.2", "tsc-alias": "^1.8.8", "type-fest": "^4.38.0", "typescript": "5.5.3", diff --git a/packages/fxa-auth-server/scripts/test-ci.sh b/packages/fxa-auth-server/scripts/test-ci.sh index e0c08e74e73..c1bb9053a2f 100755 --- a/packages/fxa-auth-server/scripts/test-ci.sh +++ b/packages/fxa-auth-server/scripts/test-ci.sh @@ -29,3 +29,22 @@ done if [ "$TEST_TYPE" == 'integration' ]; then yarn run clean-up-old-ci-stripe-customers; fi; + +# Run Jest tests +# Integration tests are in files matching *.integration.spec.ts or verification-reminders.spec.ts +if [ "$TEST_TYPE" == 'unit' ]; then + echo -e "\n\nRunning Jest unit tests" + JEST_JUNIT_OUTPUT_DIR="../../artifacts/tests/fxa-auth-server" \ + JEST_JUNIT_OUTPUT_NAME="jest-unit-results.xml" \ + npx jest --coverage --forceExit --ci --reporters=default --reporters=jest-junit --testPathIgnorePatterns='verification-reminders' +elif [ "$TEST_TYPE" == 'integration' ]; then + echo -e "\n\nRunning Jest integration tests" + JEST_JUNIT_OUTPUT_DIR="../../artifacts/tests/fxa-auth-server" \ + JEST_JUNIT_OUTPUT_NAME="jest-integration-results.xml" \ + npx jest --coverage --forceExit --ci --reporters=default --reporters=jest-junit --testPathPattern='verification-reminders' +elif [ -z "$TEST_TYPE" ]; then + echo -e "\n\nRunning all Jest tests" + JEST_JUNIT_OUTPUT_DIR="../../artifacts/tests/fxa-auth-server" \ + JEST_JUNIT_OUTPUT_NAME="jest-results.xml" \ + npx jest --coverage --forceExit --ci --reporters=default --reporters=jest-junit +fi diff --git a/packages/fxa-auth-server/scripts/test-local.sh b/packages/fxa-auth-server/scripts/test-local.sh index d6e8a165aec..ade79d8e82f 100755 --- a/packages/fxa-auth-server/scripts/test-local.sh +++ b/packages/fxa-auth-server/scripts/test-local.sh @@ -29,6 +29,9 @@ fi GLOB=$* if [ -z "$GLOB" ]; then + echo "Jest tests" + npx jest --no-coverage --forceExit + echo "Local tests" mocha $DEFAULT_ARGS $GREP_TESTS test/local diff --git a/packages/fxa-auth-server/tsconfig.json b/packages/fxa-auth-server/tsconfig.json index 8353ae76038..f0cfabf2a03 100644 --- a/packages/fxa-auth-server/tsconfig.json +++ b/packages/fxa-auth-server/tsconfig.json @@ -7,7 +7,7 @@ // TODO: Remove after transition to TS is complete "checkJs": false, "outDir": "./dist", - "types": ["accept-language", "mocha", "mozlog", "node", "fxa-geodb"], + "types": ["accept-language", "mocha", "mozlog", "node", "fxa-geodb", "jest"], "lib": ["ESNext"], // We should remove this, but for whatever reason, esbuild was not complaining // about these explicit any diff --git a/yarn.lock b/yarn.lock index 031464652e6..c983ef8a2c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22574,7 +22574,7 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:29.5.14": +"@types/jest@npm:29.5.14, @types/jest@npm:^29.5.12": version: 29.5.14 resolution: "@types/jest@npm:29.5.14" dependencies: @@ -23570,7 +23570,7 @@ __metadata: languageName: node linkType: hard -"@types/sinon@npm:*": +"@types/sinon@npm:*, @types/sinon@npm:^17.0.3": version: 17.0.4 resolution: "@types/sinon@npm:17.0.4" dependencies: @@ -35475,6 +35475,7 @@ __metadata: "@types/ejs": "npm:^3.0.6" "@types/hapi__hapi": "npm:^20.0.10" "@types/ioredis": "npm:^4.26.4" + "@types/jest": "npm:^29.5.12" "@types/jsonwebtoken": "npm:8.5.1" "@types/jws": "npm:^3.2.3" "@types/lodash": "npm:^4" @@ -35487,6 +35488,7 @@ __metadata: "@types/nodemailer": "npm:^7.0.4" "@types/request": "npm:2.48.5" "@types/sass": "npm:^1" + "@types/sinon": "npm:^17.0.3" "@types/uuid": "npm:^10.0.0" "@types/verror": "npm:^1.10.4" "@types/webpack": "npm:5.28.5" @@ -35528,6 +35530,8 @@ __metadata: hot-shots: "npm:^10.2.1" i18n-iso-countries: "npm:^7.14.0" ioredis: "npm:^4.28.2" + jest: "npm:^29.7.0" + jest-junit: "npm:^16.0.0" joi: "npm:^17.13.3" jose: "npm:^5.9.6" jsonwebtoken: "npm:^9.0.2" @@ -35572,6 +35576,7 @@ __metadata: sinon: "npm:^9.0.3" storybook: "npm:^7.6.21" through: "npm:2.3.8" + ts-jest: "npm:^29.1.2" tsc-alias: "npm:^1.8.8" type-fest: "npm:^4.38.0" typescript: "npm:5.5.3" @@ -53808,6 +53813,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.7.3": + version: 7.7.3 + resolution: "semver@npm:7.7.3" + bin: + semver: bin/semver.js + checksum: 10c0/4afe5c986567db82f44c8c6faef8fe9df2a9b1d98098fc1721f57c696c4c21cebd572f297fc21002f81889492345b8470473bc6f4aff5fb032a6ea59ea2bc45e + languageName: node + linkType: hard + "send@npm:0.19.0": version: 0.19.0 resolution: "send@npm:0.19.0" @@ -57424,6 +57438,46 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:^29.1.2": + version: 29.4.6 + resolution: "ts-jest@npm:29.4.6" + dependencies: + bs-logger: "npm:^0.2.6" + fast-json-stable-stringify: "npm:^2.1.0" + handlebars: "npm:^4.7.8" + json5: "npm:^2.2.3" + lodash.memoize: "npm:^4.1.2" + make-error: "npm:^1.3.6" + semver: "npm:^7.7.3" + type-fest: "npm:^4.41.0" + yargs-parser: "npm:^21.1.1" + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 || ^30.0.0 + "@jest/types": ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: ">=4.3 <6" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + bin: + ts-jest: cli.js + checksum: 10c0/013dda99ac938cd4b94bae9323ed1b633cd295976c256d596d01776866188078fe7b82b8b3ebd05deb401b27b5618d9d76208eded2568661240ecf9694a5c933 + languageName: node + linkType: hard + "ts-jest@npm:^29.2.3, ts-jest@npm:^29.2.5": version: 29.4.0 resolution: "ts-jest@npm:29.4.0" From b41601a9f3c2e67940d09159fae211a2e8b45ead Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 3 Feb 2026 21:42:29 -0500 Subject: [PATCH 2/6] chore(auth-server): Move OAuth key bypass to Jest setup file - Add jest.setup.js to set FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY - Remove inline env var setting from ip-profiling.spec.ts - Configure jest.config.js to use setupFiles This centralizes the test environment configuration and matches the approach used in the auth-local-test-migration branch. --- packages/fxa-auth-server/jest.config.js | 1 + packages/fxa-auth-server/jest.setup.js | 14 ++++++++++++++ .../lib/routes/ip-profiling.spec.ts | 3 --- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 packages/fxa-auth-server/jest.setup.js diff --git a/packages/fxa-auth-server/jest.config.js b/packages/fxa-auth-server/jest.config.js index 9385a0524cc..a69ca724fb7 100644 --- a/packages/fxa-auth-server/jest.config.js +++ b/packages/fxa-auth-server/jest.config.js @@ -26,6 +26,7 @@ module.exports = { }, testTimeout: 10000, clearMocks: true, + setupFiles: ['/jest.setup.js'], testPathIgnorePatterns: ['/node_modules/'], // Coverage configuration (enabled via --coverage flag) collectCoverageFrom: [ diff --git a/packages/fxa-auth-server/jest.setup.js b/packages/fxa-auth-server/jest.setup.js new file mode 100644 index 00000000000..838e84a6da1 --- /dev/null +++ b/packages/fxa-auth-server/jest.setup.js @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Jest global setup - runs before each test file. + * + * The OAuth keys exist in config/key.json but the config module's path + * resolution can behave differently under Jest's module transformation. + * This sets the env var to allow tests to run without requiring the + * OAuth key validation (which is a runtime concern, not a unit test concern). + */ + +process.env.FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY = 'true'; diff --git a/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts b/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts index e66bcec4925..bb29c874522 100644 --- a/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts +++ b/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts @@ -10,9 +10,6 @@ * which has proxyquire path resolution issues when called from lib/routes. */ -// IMPORTANT: Set environment variable BEFORE any imports that might load config -process.env.FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY = 'true'; - import crypto from 'crypto'; import sinon from 'sinon'; import { Container } from 'typedi'; From 755a4b688d362dc74e473ad9dd1ba934453b27f5 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 3 Feb 2026 21:50:14 -0500 Subject: [PATCH 3/6] refactor(auth-server): Use shared mocks in ip-profiling test Refactor ip-profiling.spec.ts to use shared mocks from test/mocks.js where possible, reducing code duplication and improving maintainability. - Replace inline mockDB, mockMailer, mockPush, mockGlean, etc. with shared versions from test/mocks.js - Use getRoute helper from test/routes_helpers.js - Keep mockRequest inline due to proxyquire path resolution issues when running tests from lib/routes/ - Reduce file from ~487 lines to ~305 lines (~37% reduction) --- .../lib/routes/ip-profiling.spec.ts | 234 ++---------------- 1 file changed, 26 insertions(+), 208 deletions(-) diff --git a/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts b/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts index bb29c874522..c3109b0b9f6 100644 --- a/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts +++ b/packages/fxa-auth-server/lib/routes/ip-profiling.spec.ts @@ -6,15 +6,21 @@ * NOTE: This test has been migrated from test/local/ip_profiling.js * It tests the IP profiling behavior in the account login route. * - * This test uses inline mocks to avoid dependency on the complex mocks.js - * which has proxyquire path resolution issues when called from lib/routes. + * This test uses shared mocks from test/mocks.js where possible, but keeps + * mockRequest inline because the shared version uses proxyquire with relative + * paths that don't work from lib/routes/. */ import crypto from 'crypto'; import sinon from 'sinon'; import { Container } from 'typedi'; import { v4 as uuid } from 'uuid'; -import { normalizeEmail } from 'fxa-shared/email/helpers'; + +const mocks = require('../../test/mocks'); +const { getRoute } = require('../../test/routes_helpers'); +const { ProfileClient } = require('@fxa/profile/client'); +const { AccountDeleteManager } = require('../account-delete'); +const { AppConfig, AuthLogger } = require('../types'); const TEST_EMAIL = 'foo@gmail.com'; const MS_ONE_DAY = 1000 * 60 * 60 * 24; @@ -29,190 +35,11 @@ const KNOWN_LOCATION = { stateCode: 'CA', }; -const { ProfileClient } = require('@fxa/profile/client'); -const { AccountEventsManager } = require('../account-events'); -const { AccountDeleteManager } = require('../account-delete'); -const { AppConfig, AuthLogger } = require('../types'); -const { FxaMailer } = require('../senders/fxa-mailer'); -const { OAuthClientInfoServiceName } = require('../senders/oauth_client_info'); - -function mockGlean() { - const noopAsync = sinon.stub().resolves({}); - return { - login: { - success: noopAsync, - error: noopAsync, - totpSuccess: noopAsync, - totpFailure: noopAsync, - verifyCodeConfirmationEmailSent: noopAsync, - verifyCodeEmailSent: noopAsync, - complete: noopAsync, - }, - registration: { - accountCreated: noopAsync, - confirmationEmailSent: noopAsync, - complete: noopAsync, - error: noopAsync, - }, - resetPassword: { - emailSent: noopAsync, - createNewSuccess: noopAsync, - accountReset: noopAsync, - }, - account: { - passwordChanged: noopAsync, - passwordReset: noopAsync, - }, - loginConfirmSkipFor: { - knownIp: noopAsync, - }, - }; -} - -function mockLog() { - return { - activityEvent: sinon.stub().resolves(), - amplitudeEvent: sinon.stub().resolves(), - begin: sinon.stub(), - error: sinon.stub(), - flowEvent: sinon.stub().resolves(), - info: sinon.stub(), - notifyAttachedServices: sinon.stub().resolves(), - warn: sinon.stub(), - summary: sinon.stub(), - trace: sinon.stub(), - debug: sinon.stub(), - }; -} - -function mockDB(data: any) { - return { - account: sinon.stub().resolves({ - email: data.email, - emailVerified: data.emailVerified, - uid: data.uid, - primaryEmail: { - normalizedEmail: normalizeEmail(data.email), - email: data.email, - isVerified: data.emailVerified, - isPrimary: true, - }, - }), - accountRecord: sinon.stub().resolves({ - authSalt: crypto.randomBytes(32), - data: crypto.randomBytes(32), - email: data.email, - emailVerified: data.emailVerified, - primaryEmail: { - normalizedEmail: normalizeEmail(data.email), - email: data.email, - isVerified: data.emailVerified, - isPrimary: true, - }, - kA: crypto.randomBytes(32), - lastAuthAt: () => Date.now(), - uid: data.uid, - wrapWrapKb: crypto.randomBytes(32), - verifierSetAt: Date.now(), - }), - createSessionToken: sinon.stub().callsFake((opts: any) => { - return Promise.resolve({ - createdAt: opts.createdAt || Date.now(), - data: crypto.randomBytes(32).toString('hex'), - email: opts.email || data.email, - emailVerified: opts.emailVerified ?? data.emailVerified, - lastAuthAt: () => opts.createdAt || Date.now(), - id: crypto.randomBytes(32).toString('hex'), - tokenVerificationId: opts.tokenVerificationId, - tokenVerified: !opts.tokenVerificationId, - mustVerify: opts.mustVerify ?? false, - uid: opts.uid || data.uid, - }); - }), - createKeyFetchToken: sinon.stub().resolves({ - data: crypto.randomBytes(32).toString('hex'), - id: crypto.randomBytes(32).toString('hex'), - uid: data.uid, - }), - securityEvents: sinon.stub().resolves([]), - securityEvent: sinon.stub().resolves(), - verifiedLoginSecurityEvents: sinon.stub().resolves([]), - touchSessionToken: sinon.stub().resolves(), - totpToken: sinon.stub().resolves({ enabled: false }), - sessions: sinon.stub().resolves([]), - devices: sinon.stub().resolves([]), - }; -} - -function mockMailer() { - return { - sendVerifyLoginEmail: sinon.stub().resolves(), - sendNewDeviceLoginEmail: sinon.stub().resolves(), - sendVerifyEmail: sinon.stub().resolves(), - sendVerifyLoginCodeEmail: sinon.stub().resolves(), - }; -} - -function mockFxaMailer() { - const mock = { - canSend: sinon.stub().resolves(true), - sendNewDeviceLoginEmail: sinon.stub().resolves(), - sendVerifyLoginEmail: sinon.stub().resolves(), - }; - Container.set(FxaMailer, mock); - return mock; -} - -function mockOAuthClientInfo() { - const mock = { - fetch: sinon.stub().resolves('sync'), - }; - Container.set(OAuthClientInfoServiceName, mock); - return mock; -} - -function mockPush() { - return { - notifyDeviceConnected: sinon.stub().resolves(), - notifyDeviceDisconnected: sinon.stub().resolves(), - notifyPasswordChanged: sinon.stub().resolves(), - notifyPasswordReset: sinon.stub().resolves(), - notifyAccountUpdated: sinon.stub().resolves(), - notifyAccountDestroyed: sinon.stub().resolves(), - notifyCommandReceived: sinon.stub().resolves(), - notifyProfileUpdated: sinon.stub().resolves(), - notifyVerifyLoginRequest: sinon.stub().resolves(), - sendPush: sinon.stub().resolves(), - }; -} - -function mockVerificationReminders() { - return { - keys: ['first', 'second', 'third', 'final'], - create: sinon.stub().returns({ first: 1, second: 1, third: 1, final: 1 }), - delete: sinon.stub().returns({ first: 1, second: 1, third: 1, final: 1 }), - process: sinon.stub().returns({ first: [], second: [], third: [], final: [] }), - }; -} - -function mockCadReminders() { - return { - keys: ['first', 'second', 'third'], - create: sinon.stub().returns({ first: 1, second: 1, third: 1 }), - delete: sinon.stub().returns({ first: 1, second: 1, third: 1 }), - get: sinon.stub().returns({ first: null, second: null, third: null }), - process: sinon.stub().returns({ first: [], second: [], third: [] }), - }; -} - -function mockStatsd() { - return { - increment: sinon.stub(), - timing: sinon.stub(), - histogram: sinon.stub(), - }; -} - +/** + * Simplified mockRequest for this test file. + * The shared mocks.mockRequest() uses proxyquire with relative paths + * that don't resolve correctly when running from lib/routes/. + */ function mockRequest(data: any) { const metricsContext = data.payload?.metricsContext || {}; @@ -263,10 +90,6 @@ function mockRequest(data: any) { }; } -function getRoute(routes: any[], path: string) { - return routes.find((r: any) => r.path === path); -} - function makeRoutes(options: { db: any; mailer: any }) { const { db, mailer } = options; const config = { @@ -280,18 +103,13 @@ function makeRoutes(options: { db: any; mailer: any }) { signinConfirmation: {}, smtp: {}, }; - const log = mockLog(); - Container.set(AccountEventsManager, { - recordSecurityEvent: sinon.stub().resolves(), - }); + const log = mocks.mockLog(); + mocks.mockAccountEventsManager(); Container.set(AccountDeleteManager, { enqueue: sinon.stub() }); Container.set(AppConfig, config); Container.set(AuthLogger, log); - const cadReminders = mockCadReminders(); - const customs = { - check: sinon.stub().resolves(true), - flag: sinon.stub(), - }; + const cadReminders = mocks.mockCadReminders(); + const customs = mocks.mockCustoms(); const signinUtils = require('./utils/signin')( log, config, @@ -301,7 +119,7 @@ function makeRoutes(options: { db: any; mailer: any }) { cadReminders ); signinUtils.checkPassword = () => Promise.resolve(true); - const glean = mockGlean(); + const glean = mocks.mockGlean(); const { accountRoutes } = require('./account'); const authServerCacheRedis = { @@ -318,15 +136,15 @@ function makeRoutes(options: { db: any; mailer: any }) { customs, signinUtils, null, - mockPush(), - mockVerificationReminders(), + mocks.mockPush(), + mocks.mockVerificationReminders(), null, null, null, null, glean, authServerCacheRedis, - mockStatsd() + mocks.mockStatsd() ); } @@ -354,14 +172,14 @@ describe('IP Profiling', () => { beforeEach(() => { jest.clearAllMocks(); - mockFxaMailerInstance = mockFxaMailer(); - mockOAuthClientInfo(); - mockDBInstance = mockDB({ + mockFxaMailerInstance = mocks.mockFxaMailer({ canSend: sinon.stub().resolves(true) }); + mocks.mockOAuthClientInfo(); + mockDBInstance = mocks.mockDB({ email: TEST_EMAIL, emailVerified: true, uid: UID, }); - mockMailerInstance = mockMailer(); + mockMailerInstance = mocks.mockMailer(); mockRequestInstance = mockRequest({ payload: { authPW: crypto.randomBytes(32).toString('hex'), From 39610746d87648b24589732124353af5e17ec618 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 3 Feb 2026 21:58:16 -0500 Subject: [PATCH 4/6] chore(auth-server): Rename Jest CI artifacts to match Mocha pattern Update Jest test result filenames to follow the same naming convention as Mocha test results for consistency in CI artifacts. - jest-unit-results.xml -> fxa-auth-server-jest-unit-results.xml - jest-integration-results.xml -> fxa-auth-server-jest-integration-results.xml - jest-results.xml -> fxa-auth-server-jest-results.xml --- packages/fxa-auth-server/scripts/test-ci.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/fxa-auth-server/scripts/test-ci.sh b/packages/fxa-auth-server/scripts/test-ci.sh index c1bb9053a2f..50992b89664 100755 --- a/packages/fxa-auth-server/scripts/test-ci.sh +++ b/packages/fxa-auth-server/scripts/test-ci.sh @@ -35,16 +35,16 @@ fi; if [ "$TEST_TYPE" == 'unit' ]; then echo -e "\n\nRunning Jest unit tests" JEST_JUNIT_OUTPUT_DIR="../../artifacts/tests/fxa-auth-server" \ - JEST_JUNIT_OUTPUT_NAME="jest-unit-results.xml" \ + JEST_JUNIT_OUTPUT_NAME="fxa-auth-server-jest-unit-results.xml" \ npx jest --coverage --forceExit --ci --reporters=default --reporters=jest-junit --testPathIgnorePatterns='verification-reminders' elif [ "$TEST_TYPE" == 'integration' ]; then echo -e "\n\nRunning Jest integration tests" JEST_JUNIT_OUTPUT_DIR="../../artifacts/tests/fxa-auth-server" \ - JEST_JUNIT_OUTPUT_NAME="jest-integration-results.xml" \ + JEST_JUNIT_OUTPUT_NAME="fxa-auth-server-jest-integration-results.xml" \ npx jest --coverage --forceExit --ci --reporters=default --reporters=jest-junit --testPathPattern='verification-reminders' elif [ -z "$TEST_TYPE" ]; then echo -e "\n\nRunning all Jest tests" JEST_JUNIT_OUTPUT_DIR="../../artifacts/tests/fxa-auth-server" \ - JEST_JUNIT_OUTPUT_NAME="jest-results.xml" \ + JEST_JUNIT_OUTPUT_NAME="fxa-auth-server-jest-results.xml" \ npx jest --coverage --forceExit --ci --reporters=default --reporters=jest-junit fi From db59a1417b405b0998d95bb3d323ea3bab2d4b6c Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Wed, 4 Feb 2026 10:38:49 -0500 Subject: [PATCH 5/6] feat(auth-server): Add Jest integration test infrastructure with CircleCI job - Add test-server.ts helper that spawns auth server as child process (avoids Jest ESM module compatibility issues) - Add jest.integration.config.js for remote/integration tests - Add smoke.spec.ts as initial integration test - Add test-integration-jest Nx target and npm script - Add "Integration Test Jest - Servers - Auth" CircleCI job to: - test_pull_request workflow - test_and_deploy_tag workflow - nightly workflow - Add supporting helpers: mailbox.ts, profile-helper.ts - Add documentation in jest-integration-test-learnings.md --- .circleci/config.yml | 37 ++ nx.json | 11 + .../docs/jest-integration-test-learnings.md | 427 +++++++++++++++++ .../jest.integration.config.js | 40 ++ packages/fxa-auth-server/package.json | 3 + .../fxa-auth-server/test/remote/smoke.spec.ts | 60 +++ .../test/support/helpers/mailbox.ts | 133 ++++++ .../test/support/helpers/profile-helper.ts | 49 ++ .../test/support/helpers/test-server.ts | 218 +++++++++ .../test/support/jest-setup-integration.ts | 19 + .../test/support/types/portfinder.d.ts | 17 + yarn.lock | 444 +++++++++++++++++- 12 files changed, 1451 insertions(+), 7 deletions(-) create mode 100644 packages/fxa-auth-server/docs/jest-integration-test-learnings.md create mode 100644 packages/fxa-auth-server/jest.integration.config.js create mode 100644 packages/fxa-auth-server/test/remote/smoke.spec.ts create mode 100644 packages/fxa-auth-server/test/support/helpers/mailbox.ts create mode 100644 packages/fxa-auth-server/test/support/helpers/profile-helper.ts create mode 100644 packages/fxa-auth-server/test/support/helpers/test-server.ts create mode 100644 packages/fxa-auth-server/test/support/jest-setup-integration.ts create mode 100644 packages/fxa-auth-server/test/support/types/portfinder.d.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index abda473594c..a7a37cbc9f8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -963,6 +963,16 @@ workflows: workflow: test_pull_request requires: - Build (PR) + - integration-test: + name: Integration Test Jest - Servers - Auth (PR) + nx_run: affected --base=main --head=$CIRCLE_SHA1 + projects: --exclude '*,!tag:scope:server:auth' + start_customs: true + target: -t test-integration-jest + test_suite: servers-auth-jest-integration + workflow: test_pull_request + requires: + - Build (PR) - integration-test: name: Integration Test - Libraries (PR) nx_run: affected --base=main --head=$CIRCLE_SHA1 @@ -988,6 +998,7 @@ workflows: - Integration Test - Servers (PR) - Integration Test - Servers - Auth (PR) - Integration Test - Servers - Auth V2 (PR) + - Integration Test Jest - Servers - Auth (PR) - Integration Test - Libraries (PR) - Firefox Functional Tests - Playwright (PR) @@ -1174,6 +1185,21 @@ workflows: nx_run: run-many --no-cloud requires: - Build + - integration-test: + name: Integration Test Jest - Servers - Auth + projects: --exclude '*,!tag:scope:server:auth' + start_customs: true + target: -t test-integration-jest + test_suite: servers-auth-jest-integration + workflow: test_and_deploy_tag + filters: + branches: + ignore: /.*/ + tags: + only: /.*/ + nx_run: run-many --no-cloud + requires: + - Build - integration-test: name: Integration Test - Libraries projects: --exclude '*,!tag:scope:shared:*' @@ -1278,6 +1304,16 @@ workflows: nx_run: run-many --skipRemoteCache requires: - Build (nightly) + - integration-test: + name: Integration Test Jest - Servers - Auth (nightly) + projects: --exclude '*,!tag:scope:server:auth' + start_customs: true + target: -t test-integration-jest + test_suite: servers-auth-jest-integration + workflow: nightly + nx_run: run-many --skipRemoteCache + requires: + - Build (nightly) - integration-test: name: Integration Test - Libraries (nightly) projects: --exclude '*,!tag:scope:shared:*' @@ -1304,6 +1340,7 @@ workflows: - Integration Test - Servers (nightly) - Integration Test - Servers - Auth (nightly) - Integration Test - Servers - Auth V2 (nightly) + - Integration Test Jest - Servers - Auth (nightly) - Integration Test - Libraries (nightly) - Firefox Functional Tests - Playwright (nightly) - create-fxa-image: diff --git a/nx.json b/nx.json index f8bf627eecd..aef6b79cfd5 100644 --- a/nx.json +++ b/nx.json @@ -128,6 +128,17 @@ ], "cache": true }, + "test-integration-jest": { + "dependsOn": ["build", "gen-keys"], + "inputs": ["test", "^test"], + "outputs": [ + "{workspaceRoot}/artifacts/tests", + "{projectRoot}/coverage", + "{projectRoot}/.nyc_output", + "{projectRoot}/test-results.xml" + ], + "cache": true + }, "test-unit": { "dependsOn": ["build", "gen-keys"], "inputs": ["test", "^test"], diff --git a/packages/fxa-auth-server/docs/jest-integration-test-learnings.md b/packages/fxa-auth-server/docs/jest-integration-test-learnings.md new file mode 100644 index 00000000000..ceb6dd9594d --- /dev/null +++ b/packages/fxa-auth-server/docs/jest-integration-test-learnings.md @@ -0,0 +1,427 @@ +# Jest Integration Test Infrastructure - Learnings + +## Overview + +This document captures learnings from setting up Jest integration tests for `fxa-auth-server` that spawn the auth server as a child process. + +**Key Insight:** The auth server cannot be `require()`d directly in Jest due to ESM module compatibility issues. The solution is to spawn it as a child process. + +--- + +## The Problem: ESM Module Incompatibility + +### What We Tried (Option B - Failed) + +We attempted to `require('../bin/key_server')` directly in Jest tests, which failed due to: + +1. **ESM Modules in node_modules** + - `@octokit/rest` uses ES module syntax (`import/export`) + - `universal-user-agent` uses ES module syntax + - Many transitive dependencies use ESM + +2. **BigInt Literals** + - Some packages use `8n` syntax (BigInt) + - esbuild's default target (`es2018`) doesn't support BigInt + +3. **Legacy Non-Strict Code** + - `yamlparser` defines `function eval()` which is illegal in strict mode + - Cannot be transformed by any modern bundler + +### Transformers We Tried + +| Transformer | Result | +|-------------|--------| +| `ts-jest` | Failed on ESM imports | +| `esbuild-jest` | Failed on BigInt, then on `yamlparser` | +| `transformIgnorePatterns: []` | Whack-a-mole with incompatible packages | + +### Why Mocha Tests Work + +The existing Mocha tests use `esbuild-register`: +```bash +mocha --require esbuild-register ... +``` + +This works because `esbuild-register` hooks into Node's module loader at runtime, transparently handling ESM. Jest's transform pipeline is different and can't achieve the same result. + +--- + +## The Solution: Option A - Child Process + +Spawn the auth server as a separate Node.js process, avoiding Jest's module system entirely. + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Jest Test Process │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ test/remote/smoke.spec.ts │ │ +│ │ │ │ +│ │ beforeAll: createTestServer() │ │ +│ │ └─> spawns child process │ │ +│ │ └─> waits for heartbeat │ │ +│ │ │ │ +│ │ tests: fetch(server.publicUrl + '/v1/...') │ │ +│ │ │ │ +│ │ afterAll: server.stop() │ │ +│ │ └─> kills child process │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ HTTP requests + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Auth Server (Child Process) │ +│ │ +│ node -r esbuild-register bin/key_server.js │ +│ │ +│ - Runs on dynamically allocated port │ +│ - Uses esbuild-register for ESM support │ +│ - Isolated from Jest's module system │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Implementation Details + +#### 1. Dynamic Port Allocation + +```typescript +import portfinder from 'portfinder'; + +const authServerPort = await portfinder.getPortPromise({ port: 9000 }); +``` + +This enables parallel test execution - each test suite gets its own port. + +#### 2. Spawning with esbuild-register + +```typescript +const serverProcess = spawn( + 'node', + ['-r', 'esbuild-register', 'bin/key_server.js'], + { + cwd: AUTH_SERVER_ROOT, + env: { + ...process.env, + NODE_ENV: 'dev', + CONFIG_FILES: configPath, + PORT: String(port), + }, + stdio: printLogs ? 'inherit' : 'pipe', + } +); +``` + +#### 3. Waiting for Server Ready + +```typescript +async function waitForServer(url: string, maxAttempts = 30): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch(`${url}/__heartbeat__`); + if (response.ok) return; + } catch (e) { + // Server not ready yet + } + await new Promise(resolve => setTimeout(resolve, 1000)); + } + throw new Error(`Server did not become ready`); +} +``` + +#### 4. Clean Shutdown + +```typescript +stop: async () => { + if (serverProcess && !serverProcess.killed) { + serverProcess.kill('SIGTERM'); + await new Promise(resolve => setTimeout(resolve, 500)); + if (!serverProcess.killed) { + serverProcess.kill('SIGKILL'); + } + } +} +``` + +--- + +## File Structure + +``` +test/ +├── support/ +│ ├── helpers/ +│ │ ├── test-server.ts # Spawns auth server as child process +│ │ ├── mailbox.ts # Email fetching from mail_helper +│ │ └── profile-helper.ts # Mock profile server +│ ├── jest-setup-integration.ts # Jest setup (env vars, timeout) +│ └── .tmp/ # Temp config files (gitignored) +├── remote/ +│ └── smoke.spec.ts # Example integration test +└── ... +``` + +--- + +## Jest Configuration + +### jest.integration.config.js + +```javascript +const baseConfig = require('./jest.config'); + +module.exports = { + ...baseConfig, + + // Module mappings + moduleNameMapper: { + ...baseConfig.moduleNameMapper, + '^@fxa/vendored/(.*)$': '/../../libs/vendored/$1/src', + }, + + // Test patterns + testMatch: [ + '/test/remote/**/*.spec.ts', + '/test/integration/**/*.spec.ts', + ], + + // Longer timeout for server startup + testTimeout: 120000, + + // Setup file + setupFilesAfterEnv: [ + '/test/support/jest-setup-integration.ts', + ], +}; +``` + +### Key Settings + +| Setting | Value | Reason | +|---------|-------|--------| +| `testTimeout` | 120000 | Server startup takes 5-10 seconds | +| `forceExit` | CLI flag | Kill hanging connections | +| `moduleNameMapper` | vendored paths | Resolve @fxa/vendored/* | + +--- + +## Writing Integration Tests + +### Test Naming Convention + +**Important:** Integration tests MUST include `#integration` in the describe block name. This is used by CI to filter tests. + +```typescript +// CORRECT - includes #integration tag +describe('#integration - my feature', () => { + +// WRONG - missing tag, won't be recognized as integration test +describe('my feature', () => { +``` + +For V2 protocol tests, use `#integrationV2`: +```typescript +describe('#integrationV2 - my feature with v2 protocol', () => { +``` + +### Basic Pattern + +```typescript +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +describe('#integration - my feature', () => { + let server: TestServerInstance; + + beforeAll(async () => { + server = await createTestServer(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it('should do something', async () => { + const response = await fetch(`${server.publicUrl}/v1/some/endpoint`); + expect(response.ok).toBe(true); + }); +}); +``` + +### With Config Overrides + +```typescript +beforeAll(async () => { + server = await createTestServer({ + configOverrides: { + signinConfirmation: { skipForNewAccounts: false }, + }, + }); +}); +``` + +### With Logging Enabled + +```typescript +beforeAll(async () => { + server = await createTestServer({ + printLogs: true, // or set REMOTE_TEST_LOGS=true + }); +}); +``` + +### Using Unique Emails + +```typescript +it('creates an account', async () => { + const email = server.uniqueEmail(); // e.g., "a1b2c3d4e5@restmail.net" + // Use email in test... +}); +``` + +--- + +## Running Tests + +```bash +# Run all integration tests +npx jest --config jest.integration.config.js --forceExit + +# Run specific test file +npx jest --config jest.integration.config.js test/remote/smoke.spec.ts --forceExit + +# Run with logs +REMOTE_TEST_LOGS=true npx jest --config jest.integration.config.js --forceExit + +# Run without coverage (faster) +npx jest --config jest.integration.config.js --no-coverage --forceExit +``` + +### CI Integration + +The CI script (`scripts/test-ci.sh`) uses `TEST_TYPE` environment variable to filter tests: + +```bash +# Unit tests only (excludes #integration) +TEST_TYPE=unit yarn test + +# Integration tests only (includes #integration) +TEST_TYPE=integration yarn test + +# V2 integration tests only (includes #integrationV2) +TEST_TYPE=integration-v2 yarn test +``` + +For Mocha, this uses `--grep` patterns. For Jest, we may need to use `--testNamePattern`: + +```bash +# Jest equivalent for integration tests +npx jest --config jest.integration.config.js --testNamePattern="#integration" --forceExit +``` + +--- + +## Common Issues and Solutions + +### 1. "Server did not become ready" + +**Cause:** Server failed to start or is taking too long. + +**Solutions:** +- Run with `REMOTE_TEST_LOGS=true` to see server output +- Check if required services are running (MySQL, Redis) +- Check for port conflicts + +### 2. Jest doesn't exit + +**Cause:** Open handles (connections, timers). + +**Solution:** Always use `--forceExit` flag for integration tests. + +### 3. Port conflicts + +**Cause:** Previous test didn't clean up, or running multiple test suites. + +**Solution:** `portfinder` handles this automatically by finding available ports. + +### 4. "Cannot find module" errors + +**Cause:** Missing module mappings in Jest config. + +**Solution:** Add to `moduleNameMapper` in jest.integration.config.js. + +--- + +## Dependencies Added + +```bash +yarn add -D portfinder +``` + +Note: `@types/portfinder` doesn't exist, so we created a local type declaration at `test/support/types/portfinder.d.ts`. + +--- + +## What NOT to Do + +### Don't require the server directly + +```typescript +// BAD - will fail with ESM errors +const server = require('../bin/key_server'); +``` + +### Don't use esbuild-jest for integration tests + +```typescript +// BAD - leads to whack-a-mole with incompatible packages +transform: { + '^.+\\.[tj]sx?$': 'esbuild-jest', +}, +transformIgnorePatterns: [], +``` + +### Don't hardcode ports + +```typescript +// BAD - will conflict with parallel tests +const url = 'http://localhost:9000'; + +// GOOD - use dynamic port +const url = server.publicUrl; +``` + +--- + +## Comparison: Original Mocha vs New Jest + +| Aspect | Mocha (Original) | Jest (New) | +|--------|------------------|------------| +| Server loading | `proxyquire` + `esbuild-register` | Child process spawn | +| Port allocation | Fixed (9000) | Dynamic (portfinder) | +| Parallelization | Not supported | Supported | +| Config overrides | Via `proxyquire` | Via temp config file | +| Module mocking | `proxyquire` | Not supported (use DI) | + +--- + +## Future Improvements + +1. **Parallel execution** - Already supported via dynamic ports, but may need `maxWorkers` tuning for CI + +2. **Retry mechanism** - Add `--testRetries=2` for flaky tests + +3. **Server pooling** - For faster tests, could maintain a pool of pre-started servers + +4. **Better cleanup** - Track all spawned processes and ensure cleanup on test failure + +--- + +## Summary + +The key learning is that **Jest's module transformation cannot handle the auth-server's dependency tree**. The solution is to spawn the server as a child process using `node -r esbuild-register`, which: + +1. Avoids Jest's module system entirely +2. Uses the same ESM handling as Mocha tests +3. Enables parallel test execution via dynamic ports +4. Provides clean isolation between test suites diff --git a/packages/fxa-auth-server/jest.integration.config.js b/packages/fxa-auth-server/jest.integration.config.js new file mode 100644 index 00000000000..5988a5416d6 --- /dev/null +++ b/packages/fxa-auth-server/jest.integration.config.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const baseConfig = require('./jest.config'); + +module.exports = { + ...baseConfig, + + // Add vendored module mapping (not in base config) + moduleNameMapper: { + ...baseConfig.moduleNameMapper, + '^@fxa/vendored/(.*)$': '/../../libs/vendored/$1/src', + }, + + // Integration test specific settings + testMatch: [ + '/test/remote/**/*.spec.ts', + '/test/integration/**/*.spec.ts', + ], + + // Longer timeout for integration tests (includes server startup) + testTimeout: 120000, + + // Setup file for each test (env vars, custom matchers) + setupFilesAfterEnv: [ + '/test/support/jest-setup-integration.ts', + ], + + // Parallel execution is enabled - each suite gets its own ports + // Adjust based on CI resources if needed + // maxWorkers: '50%', + + // Coverage for integration tests + collectCoverageFrom: [ + 'lib/**/*.{ts,js}', + '!lib/**/*.spec.{ts,js}', + ], + coverageDirectory: '../../artifacts/coverage/fxa-auth-server-jest-integration', +}; diff --git a/packages/fxa-auth-server/package.json b/packages/fxa-auth-server/package.json index f032ff75d7a..adbc32b85ff 100644 --- a/packages/fxa-auth-server/package.json +++ b/packages/fxa-auth-server/package.json @@ -45,6 +45,7 @@ "test-unit": "VERIFIER_VERSION=0 TEST_TYPE=unit scripts/test-ci.sh", "test-integration": "VERIFIER_VERSION=0 TEST_TYPE=integration scripts/test-ci.sh", "test-integration-v2": "VERIFIER_VERSION=0 TEST_TYPE=integration-v2 scripts/test-ci.sh", + "test-integration-jest": "JEST_JUNIT_OUTPUT_DIR='../../artifacts/tests/fxa-auth-server' JEST_JUNIT_OUTPUT_NAME='jest-integration-results.xml' npx jest --config jest.integration.config.js --forceExit --ci --reporters=default --reporters=jest-junit", "populate-firestore-customers": "CONFIG_FILES='config/secrets.json' node -r esbuild-register ./scripts/populate-firestore-customers.ts", "populate-vat-taxes": "CONFIG_FILES='config/secrets.json' node -r esbuild-register ./scripts/populate-vat-taxes.ts", "paypal-processor": "CONFIG_FILES='config/secrets.json' node -r esbuild-register ./scripts/paypal-processor.ts", @@ -160,6 +161,7 @@ "chai": "^4.5.0", "chai-as-promised": "^7.1.1", "esbuild": "^0.17.15", + "esbuild-jest": "^0.5.0", "esbuild-register": "^3.5.0", "eslint": "^8.57.1", "fxa-shared": "workspace:*", @@ -186,6 +188,7 @@ "nx": "21.2.4", "nyc": "^17.1.0", "pm2": "^6.0.14", + "portfinder": "^1.0.38", "prettier": "^3.5.3", "proxyquire": "^2.1.3", "read": "3.0.1", diff --git a/packages/fxa-auth-server/test/remote/smoke.spec.ts b/packages/fxa-auth-server/test/remote/smoke.spec.ts new file mode 100644 index 00000000000..7da72fa3cad --- /dev/null +++ b/packages/fxa-auth-server/test/remote/smoke.spec.ts @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Minimal smoke test to validate the Jest integration test infrastructure. + * This test verifies: + * 1. Test server starts on a dynamic port + * 2. HTTP requests work against the server + * 3. Server shuts down cleanly + */ + +import { createTestServer, TestServerInstance } from '../support/helpers/test-server'; + +describe('#integration - smoke test', () => { + let server: TestServerInstance; + + beforeAll(async () => { + server = await createTestServer(); + console.log(`Test server started on ${server.publicUrl}`); + }); + + afterAll(async () => { + await server.stop(); + console.log('Test server stopped'); + }); + + it('server responds to heartbeat', async () => { + const response = await fetch(`${server.publicUrl}/__heartbeat__`); + expect(response.ok).toBe(true); + }); + + it('server responds to API endpoint', async () => { + // Check that the API is accessible + const response = await fetch(`${server.publicUrl}/v1/account/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'test@example.com' }), + }); + const body = await response.json(); + console.log('account/status response:', response.status, body); + // Should get 200 with exists: false for non-existent account + expect(response.status).toBe(200); + expect(body.exists).toBe(false); + }); + + it('generates unique emails', () => { + const email1 = server.uniqueEmail(); + const email2 = server.uniqueEmail(); + + expect(email1).toMatch(/@restmail\.net$/); + expect(email2).toMatch(/@restmail\.net$/); + expect(email1).not.toBe(email2); + }); + + it('config has correct publicUrl', () => { + expect(server.config.publicUrl).toBe(server.publicUrl); + expect(server.publicUrl).toMatch(/^http:\/\/localhost:\d+$/); + }); +}); diff --git a/packages/fxa-auth-server/test/support/helpers/mailbox.ts b/packages/fxa-auth-server/test/support/helpers/mailbox.ts new file mode 100644 index 00000000000..969a1fff099 --- /dev/null +++ b/packages/fxa-auth-server/test/support/helpers/mailbox.ts @@ -0,0 +1,133 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EventEmitter } from 'events'; + +export interface EmailData { + headers: { + 'x-verify-code'?: string; + 'x-recovery-code'?: string; + 'x-verify-short-code'?: string; + 'x-signin-verify-code'?: string; + 'x-password-forgot-otp'?: string; + 'x-account-change-verify-code'?: string; + 'x-unblock-code'?: string; + 'x-template-name'?: string; + 'x-link'?: string; + 'x-service-id'?: string; + to?: string; + cc?: string; + [key: string]: string | undefined; + }; + text?: string; + html?: string; + subject?: string; +} + +export interface Mailbox { + waitForEmail: (email: string) => Promise; + waitForCode: (email: string) => Promise; + waitForMfaCode: (email: string) => Promise; + eventEmitter: EventEmitter; +} + +const MAX_RETRIES = 20; +const RETRY_DELAY_MS = 1000; + +export function createMailbox( + host = 'localhost', + port = 9001, + printLogs = false +): Mailbox { + const eventEmitter = new EventEmitter(); + + function log(...args: any[]): void { + if (printLogs) { + console.log(...args); + } + } + + async function fetchMail(email: string): Promise { + const url = `http://${host}:${port}/mail/${encodeURIComponent(email)}`; + log('checking mail', url); + + const response = await fetch(url, { method: 'GET' }); + + if (!response.ok) { + throw new Error(`Mail fetch failed: ${response.status}`); + } + + const body = await response.text(); + log('mail body', body); + + try { + const json = JSON.parse(body); + return json && json.length > 0 ? json : null; + } catch { + return null; + } + } + + async function deleteMail(email: string): Promise { + const url = `http://${host}:${port}/mail/${encodeURIComponent(email)}`; + log('deleting mail', url); + await fetch(url, { method: 'DELETE' }); + } + + async function waitForEmail(email: string): Promise { + const username = email.split('@')[0]; + + for (let tries = MAX_RETRIES; tries > 0; tries--) { + log('mail status tries', tries); + + const mail = await fetchMail(username); + + if (mail && mail.length > 0) { + await deleteMail(username); + const emailData = mail[0]; + eventEmitter.emit('email:message', email, emailData); + return emailData; + } + + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } + + const error = new Error(`Timeout waiting for email: ${email}`); + eventEmitter.emit('email:error', email, error); + throw error; + } + + async function waitForCode(email: string): Promise { + const emailData = await waitForEmail(email); + const code = + emailData.headers['x-verify-code'] || + emailData.headers['x-recovery-code'] || + emailData.headers['x-verify-short-code'] || + emailData.headers['x-password-forgot-otp']; + + if (!code) { + throw new Error('Email did not contain a verification code'); + } + + return code; + } + + async function waitForMfaCode(email: string): Promise { + const emailData = await waitForEmail(email); + const code = emailData.headers['x-account-change-verify-code']; + + if (!code) { + throw new Error('Email did not contain an MFA verification code'); + } + + return code; + } + + return { + waitForEmail, + waitForCode, + waitForMfaCode, + eventEmitter, + }; +} diff --git a/packages/fxa-auth-server/test/support/helpers/profile-helper.ts b/packages/fxa-auth-server/test/support/helpers/profile-helper.ts new file mode 100644 index 00000000000..c5c18ea8044 --- /dev/null +++ b/packages/fxa-auth-server/test/support/helpers/profile-helper.ts @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import Hapi from '@hapi/hapi'; + +export interface ProfileHelper { + port: number; + url: string; + close: () => Promise; +} + +/** + * Creates a mock profile server that handles cache invalidation requests. + * This prevents tests from requiring a real profile server. + */ +export async function createProfileHelper(port: number): Promise { + const server = new Hapi.Server({ + host: 'localhost', + port, + }); + + server.route([ + { + method: 'DELETE', + path: '/v1/cache/{uid}', + handler: async (request, h) => { + return h.response({}).code(200); + }, + }, + { + method: 'GET', + path: '/__heartbeat__', + handler: async (request, h) => { + return h.response({ status: 'ok' }).code(200); + }, + }, + ]); + + await server.start(); + + return { + port, + url: `http://localhost:${port}`, + close: async () => { + await server.stop(); + }, + }; +} diff --git a/packages/fxa-auth-server/test/support/helpers/test-server.ts b/packages/fxa-auth-server/test/support/helpers/test-server.ts new file mode 100644 index 00000000000..93131ae962f --- /dev/null +++ b/packages/fxa-auth-server/test/support/helpers/test-server.ts @@ -0,0 +1,218 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Test server helper that spawns the auth server as a child process. + * This avoids Jest's module system issues with ESM dependencies. + */ + +import { spawn, ChildProcess } from 'child_process'; +import crypto from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import portfinder from 'portfinder'; +import { createMailbox, Mailbox } from './mailbox'; +import { createProfileHelper, ProfileHelper } from './profile-helper'; + +export interface TestServerConfig { + printLogs?: boolean; + configOverrides?: Record; +} + +export interface TestServerInstance { + config: any; + mailbox: Mailbox; + profileServer: ProfileHelper | null; + publicUrl: string; + uniqueEmail: (domain?: string) => string; + uniqueUnicodeEmail: () => string; + stop: () => Promise; +} + +interface AllocatedPorts { + authServerPort: number; + profileServerPort: number; +} + +const AUTH_SERVER_ROOT = path.resolve(__dirname, '../../..'); + +/** + * Find available ports for this test suite. + */ +async function allocatePorts(): Promise { + const authServerPort = await portfinder.getPortPromise({ port: 9000 }); + const profileServerPort = await portfinder.getPortPromise({ port: authServerPort + 1 }); + return { authServerPort, profileServerPort }; +} + +/** + * Wait for server to be ready by polling the heartbeat endpoint. + */ +async function waitForServer(url: string, maxAttempts = 30, delayMs = 1000): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch(`${url}/__heartbeat__`); + if (response.ok) { + return; + } + } catch (e) { + // Server not ready yet + } + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + throw new Error(`Server at ${url} did not become ready after ${maxAttempts} attempts`); +} + +/** + * Create a temporary config file with overrides. + */ +function createTempConfig(baseConfig: any, overrides: Record, port: number): string { + const config = { + ...overrides, + listen: { host: '127.0.0.1', port }, + publicUrl: `http://localhost:${port}`, + }; + + const tempDir = path.join(AUTH_SERVER_ROOT, 'test', 'support', '.tmp'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + const configPath = path.join(tempDir, `config-${port}.json`); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + return configPath; +} + +/** + * Spawn the auth server as a child process. + */ +function spawnAuthServer( + port: number, + configPath: string, + printLogs: boolean +): ChildProcess { + const env = { + ...process.env, + NODE_ENV: 'dev', + CONFIG_FILES: configPath, + PORT: String(port), + IP_ADDRESS: '127.0.0.1', + PUBLIC_URL: `http://localhost:${port}`, + }; + + const serverProcess = spawn( + 'node', + ['-r', 'esbuild-register', path.join(AUTH_SERVER_ROOT, 'bin', 'key_server.js')], + { + cwd: AUTH_SERVER_ROOT, + env, + stdio: printLogs ? 'inherit' : 'pipe', + } + ); + + if (!printLogs && serverProcess.stderr) { + serverProcess.stderr.on('data', (data) => { + // Silently ignore stderr unless it's a critical error + const msg = data.toString(); + if (msg.includes('EADDRINUSE') || msg.includes('fatal')) { + console.error('Server error:', msg); + } + }); + } + + return serverProcess; +} + +/** + * Create a test server instance for a test suite. + * Each suite gets its own server on dynamically allocated ports. + */ +export async function createTestServer( + options: TestServerConfig = {} +): Promise { + const { + printLogs = process.env.REMOTE_TEST_LOGS === 'true', + configOverrides = {}, + } = options; + + // Allocate unique ports for this suite + const ports = await allocatePorts(); + const publicUrl = `http://localhost:${ports.authServerPort}`; + + // Load base config to get smtp settings + const baseConfigPath = require.resolve('../../../config'); + delete require.cache[baseConfigPath]; + const baseConfig = require('../../../config').default.getProperties(); + + // Create temp config file with overrides + const configPath = createTempConfig(baseConfig, configOverrides, ports.authServerPort); + + // Create mailbox helper (connects to shared mail_helper HTTP API) + const mailbox = createMailbox( + baseConfig.smtp?.api?.host || 'localhost', + baseConfig.smtp?.api?.port || 9001, + printLogs + ); + + // Spawn the auth server + const serverProcess = spawnAuthServer(ports.authServerPort, configPath, printLogs); + + // Wait for server to be ready + try { + await waitForServer(publicUrl); + } catch (e) { + serverProcess.kill(); + throw e; + } + + // Start profile helper on its allocated port + let profileServer: ProfileHelper | null = null; + if (baseConfig.profileServer?.url) { + profileServer = await createProfileHelper(ports.profileServerPort); + } + + const instance: TestServerInstance = { + config: { + ...baseConfig, + ...configOverrides, + publicUrl, + listen: { host: '127.0.0.1', port: ports.authServerPort }, + }, + mailbox, + profileServer, + publicUrl, + uniqueEmail: (domain = '@restmail.net') => { + const base = crypto.randomBytes(10).toString('hex'); + return `${base}${domain}`; + }, + uniqueUnicodeEmail: () => { + return `${crypto.randomBytes(10).toString('hex')}${String.fromCharCode(1234)}@${String.fromCharCode(5678)}restmail.net`; + }, + stop: async () => { + // Kill server process + if (serverProcess && !serverProcess.killed) { + serverProcess.kill('SIGTERM'); + // Wait a bit for graceful shutdown + await new Promise(resolve => setTimeout(resolve, 500)); + if (!serverProcess.killed) { + serverProcess.kill('SIGKILL'); + } + } + + // Stop profile server + if (profileServer) { + await profileServer.close(); + } + + // Clean up temp config + try { + fs.unlinkSync(configPath); + } catch (e) { + // Ignore cleanup errors + } + }, + }; + + return instance; +} diff --git a/packages/fxa-auth-server/test/support/jest-setup-integration.ts b/packages/fxa-auth-server/test/support/jest-setup-integration.ts new file mode 100644 index 00000000000..e47f396562e --- /dev/null +++ b/packages/fxa-auth-server/test/support/jest-setup-integration.ts @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Jest setup file for integration tests. + * This runs before each test file. + */ + +// Bypass OAuth key validation +process.env.FXA_OPENID_UNSAFELY_ALLOW_MISSING_ACTIVE_KEY = 'true'; + +// Increase timeout for integration tests (server startup can take time) +jest.setTimeout(60000); + +// Global error handler for unhandled rejections +process.on('unhandledRejection', (reason) => { + console.error('Unhandled Rejection:', reason); +}); diff --git a/packages/fxa-auth-server/test/support/types/portfinder.d.ts b/packages/fxa-auth-server/test/support/types/portfinder.d.ts new file mode 100644 index 00000000000..c492530b9cb --- /dev/null +++ b/packages/fxa-auth-server/test/support/types/portfinder.d.ts @@ -0,0 +1,17 @@ +declare module 'portfinder' { + interface PortFinderOptions { + port?: number; + stopPort?: number; + host?: string; + } + + export function getPortPromise(options?: PortFinderOptions): Promise; + export function getPort( + options: PortFinderOptions, + callback: (err: Error | null, port: number) => void + ): void; + export function getPort(callback: (err: Error | null, port: number) => void): void; + + export let basePort: number; + export let highestPort: number; +} diff --git a/yarn.lock b/yarn.lock index c983ef8a2c4..2128c2ce124 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3302,6 +3302,17 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/code-frame@npm:7.29.0" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.28.5" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/d34cc504e7765dfb576a663d97067afb614525806b5cad1a5cc1a7183b916fec8ff57fa233585e3926fd5a9e6b31aae6df91aa81ae9775fb7a28f658d3346f0d + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.20.5, @babel/compat-data@npm:^7.27.2, @babel/compat-data@npm:^7.27.7": version: 7.27.7 resolution: "@babel/compat-data@npm:7.27.7" @@ -3309,6 +3320,13 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.28.6": + version: 7.29.0 + resolution: "@babel/compat-data@npm:7.29.0" + checksum: 10c0/08f348554989d23aa801bf1405aa34b15e841c0d52d79da7e524285c77a5f9d298e70e11d91cc578d8e2c9542efc586d50c5f5cf8e1915b254a9dcf786913a94 + languageName: node + linkType: hard + "@babel/core@npm:^7.0.0, @babel/core@npm:^7.1.0, @babel/core@npm:^7.11.1, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.10, @babel/core@npm:^7.12.3, @babel/core@npm:^7.16.0, @babel/core@npm:^7.18.5, @babel/core@npm:^7.18.9, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.0, @babel/core@npm:^7.23.0, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.24.4, @babel/core@npm:^7.26.10, @babel/core@npm:^7.27.1, @babel/core@npm:^7.5.5, @babel/core@npm:^7.7.2, @babel/core@npm:^7.7.5, @babel/core@npm:^7.8.0": version: 7.27.7 resolution: "@babel/core@npm:7.27.7" @@ -3332,6 +3350,29 @@ __metadata: languageName: node linkType: hard +"@babel/core@npm:^7.12.17": + version: 7.29.0 + resolution: "@babel/core@npm:7.29.0" + dependencies: + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helpers": "npm:^7.28.6" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/traverse": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + "@jridgewell/remapping": "npm:^2.3.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/5127d2e8e842ae409e11bcbb5c2dff9874abf5415e8026925af7308e903f4f43397341467a130490d1a39884f461bc2b67f3063bce0be44340db89687fd852aa + languageName: node + linkType: hard + "@babel/eslint-parser@npm:^7.16.3": version: 7.27.5 resolution: "@babel/eslint-parser@npm:7.27.5" @@ -3372,6 +3413,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/generator@npm:7.29.0" + dependencies: + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/5c3df8f2475bfd5f97ad0211c52171aff630088b148e7b89d056b39d69855179bc9f2d1ee200263c76c2398a49e4fdbb38b9709ebc4f043cc04d9ee09a66668a + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.18.6, @babel/helper-annotate-as-pure@npm:^7.22.5, @babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": version: 7.27.3 resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" @@ -3394,6 +3448,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-compilation-targets@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-compilation-targets@npm:7.28.6" + dependencies: + "@babel/compat-data": "npm:^7.28.6" + "@babel/helper-validator-option": "npm:^7.27.1" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/3fcdf3b1b857a1578e99d20508859dbd3f22f3c87b8a0f3dc540627b4be539bae7f6e61e49d931542fe5b557545347272bbdacd7f58a5c77025a18b745593a50 + languageName: node + linkType: hard + "@babel/helper-create-class-features-plugin@npm:^7.18.6, @babel/helper-create-class-features-plugin@npm:^7.21.0, @babel/helper-create-class-features-plugin@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-create-class-features-plugin@npm:7.27.1" @@ -3493,6 +3560,16 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-imports@npm:7.28.6" + dependencies: + "@babel/traverse": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/b49d8d8f204d9dbfd5ac70c54e533e5269afb3cea966a9d976722b13e9922cc773a653405f53c89acb247d5aebdae4681d631a3ae3df77ec046b58da76eda2ac + languageName: node + linkType: hard + "@babel/helper-module-transforms@npm:^7.27.1, @babel/helper-module-transforms@npm:^7.27.3": version: 7.27.3 resolution: "@babel/helper-module-transforms@npm:7.27.3" @@ -3506,6 +3583,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-transforms@npm:7.28.6" + dependencies: + "@babel/helper-module-imports": "npm:^7.28.6" + "@babel/helper-validator-identifier": "npm:^7.28.5" + "@babel/traverse": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/6f03e14fc30b287ce0b839474b5f271e72837d0cafe6b172d759184d998fbee3903a035e81e07c2c596449e504f453463d58baa65b6f40a37ded5bec74620b2b + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-optimise-call-expression@npm:7.27.1" @@ -3522,6 +3612,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-plugin-utils@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-plugin-utils@npm:7.28.6" + checksum: 10c0/3f5f8acc152fdbb69a84b8624145ff4f9b9f6e776cb989f9f968f8606eb7185c5c3cfcf3ba08534e37e1e0e1c118ac67080610333f56baa4f7376c99b5f1143d + languageName: node + linkType: hard + "@babel/helper-remap-async-to-generator@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-remap-async-to-generator@npm:7.27.1" @@ -3607,6 +3704,16 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helpers@npm:7.28.6" + dependencies: + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/c4a779c66396bb0cf619402d92f1610601ff3832db2d3b86b9c9dd10983bf79502270e97ac6d5280cea1b1a37de2f06ecbac561bd2271545270407fbe64027cb + languageName: node + linkType: hard + "@babel/highlight@npm:^7.0.0, @babel/highlight@npm:^7.10.4": version: 7.25.9 resolution: "@babel/highlight@npm:7.25.9" @@ -3641,6 +3748,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/parser@npm:7.29.0" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/333b2aa761264b91577a74bee86141ef733f9f9f6d4fc52548e4847dc35dfbf821f58c46832c637bfa761a6d9909d6a68f7d1ed59e17e4ffbb958dc510c17b62 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.27.1" @@ -4351,6 +4469,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-commonjs@npm:^7.12.13": + version: 7.28.6 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.28.6" + dependencies: + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/7c45992797c6150644c8552feff4a016ba7bd6d59ff2b039ed969a9c5b20a6804cd9d21db5045fc8cca8ca7f08262497e354e93f8f2be6a1cdf3fbfa8c31a9b6 + languageName: node + linkType: hard + "@babel/plugin-transform-modules-commonjs@npm:^7.23.0, @babel/plugin-transform-modules-commonjs@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-modules-commonjs@npm:7.27.1" @@ -5007,6 +5137,17 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/template@npm:7.28.6" + dependencies: + "@babel/code-frame": "npm:^7.28.6" + "@babel/parser": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10c0/66d87225ed0bc77f888181ae2d97845021838c619944877f7c4398c6748bcf611f216dfd6be74d39016af502bca876e6ce6873db3c49e4ac354c56d34d57e9f5 + languageName: node + linkType: hard + "@babel/traverse@npm:^7.1.6, @babel/traverse@npm:^7.16.0, @babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.23.2, @babel/traverse@npm:^7.26.10, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.27.3, @babel/traverse@npm:^7.27.7, @babel/traverse@npm:^7.4.5, @babel/traverse@npm:^7.7.0, @babel/traverse@npm:^7.7.2": version: 7.27.7 resolution: "@babel/traverse@npm:7.27.7" @@ -5037,6 +5178,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/traverse@npm:7.29.0" + dependencies: + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.29.0" + debug: "npm:^4.3.1" + checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb + languageName: node + linkType: hard + "@babel/types@npm:7.25.8": version: 7.25.8 resolution: "@babel/types@npm:7.25.8" @@ -5068,6 +5224,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/23cc3466e83bcbfab8b9bd0edaafdb5d4efdb88b82b3be6728bbade5ba2f0996f84f63b1c5f7a8c0d67efded28300898a5f930b171bb40b311bca2029c4e9b4f + languageName: node + linkType: hard + "@base2/pretty-print-object@npm:1.0.1": version: 1.0.1 resolution: "@base2/pretty-print-object@npm:1.0.1" @@ -5089,6 +5255,18 @@ __metadata: languageName: node linkType: hard +"@cnakazawa/watch@npm:^1.0.3": + version: 1.0.4 + resolution: "@cnakazawa/watch@npm:1.0.4" + dependencies: + exec-sh: "npm:^0.3.2" + minimist: "npm:^1.2.0" + bin: + watch: cli.js + checksum: 10c0/8678b6f582bdc5ffe59c0d45c2ad21f4ea1d162ec7ddb32e85078fca481c26958f27bcdef6007b8e9a066da090ccf9d31e1753f8de1e5f32466a04227d70dc31 + languageName: node + linkType: hard + "@colors/colors@npm:1.5.0": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" @@ -9024,6 +9202,29 @@ __metadata: languageName: node linkType: hard +"@jest/transform@npm:^26.6.2": + version: 26.6.2 + resolution: "@jest/transform@npm:26.6.2" + dependencies: + "@babel/core": "npm:^7.1.0" + "@jest/types": "npm:^26.6.2" + babel-plugin-istanbul: "npm:^6.0.0" + chalk: "npm:^4.0.0" + convert-source-map: "npm:^1.4.0" + fast-json-stable-stringify: "npm:^2.0.0" + graceful-fs: "npm:^4.2.4" + jest-haste-map: "npm:^26.6.2" + jest-regex-util: "npm:^26.0.0" + jest-util: "npm:^26.6.2" + micromatch: "npm:^4.0.2" + pirates: "npm:^4.0.1" + slash: "npm:^3.0.0" + source-map: "npm:^0.6.1" + write-file-atomic: "npm:^3.0.0" + checksum: 10c0/1a1d636528d9b122b87b870633763c67f131533fce61e5db536dfbbea0bbfe8fe130daededb686ccc230389473a2b8ece5d0e1eaf380066d8902bde48579de31 + languageName: node + linkType: hard + "@jest/transform@npm:^27.5.1": version: 27.5.1 resolution: "@jest/transform@npm:27.5.1" @@ -9160,6 +9361,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + languageName: node + linkType: hard + "@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" @@ -26553,6 +26764,24 @@ __metadata: languageName: node linkType: hard +"babel-jest@npm:^26.6.3": + version: 26.6.3 + resolution: "babel-jest@npm:26.6.3" + dependencies: + "@jest/transform": "npm:^26.6.2" + "@jest/types": "npm:^26.6.2" + "@types/babel__core": "npm:^7.1.7" + babel-plugin-istanbul: "npm:^6.0.0" + babel-preset-jest: "npm:^26.6.2" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.4" + slash: "npm:^3.0.0" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/355e431fbd663fd43dcf68c93edcf66e31c3295c35754739edb3ce39435fdc407de75540b310b370e6eb924af528839b6effb8de21870ad12423aac31e258221 + languageName: node + linkType: hard + "babel-jest@npm:^27.4.2, babel-jest@npm:^27.5.1": version: 27.5.1 resolution: "babel-jest@npm:27.5.1" @@ -26626,7 +26855,7 @@ __metadata: languageName: node linkType: hard -"babel-plugin-istanbul@npm:^6.1.1": +"babel-plugin-istanbul@npm:^6.0.0, babel-plugin-istanbul@npm:^6.1.1": version: 6.1.1 resolution: "babel-plugin-istanbul@npm:6.1.1" dependencies: @@ -26639,6 +26868,18 @@ __metadata: languageName: node linkType: hard +"babel-plugin-jest-hoist@npm:^26.6.2": + version: 26.6.2 + resolution: "babel-plugin-jest-hoist@npm:26.6.2" + dependencies: + "@babel/template": "npm:^7.3.3" + "@babel/types": "npm:^7.3.3" + "@types/babel__core": "npm:^7.0.0" + "@types/babel__traverse": "npm:^7.0.6" + checksum: 10c0/2fcddf7b338e38453d6a42c23db5b790e4188fcbffeba8ff74a62b7d64fe5a642b009a7bd780e47840c382600628de2a6486a92bb151648c64028a6c628e9bfd + languageName: node + linkType: hard + "babel-plugin-jest-hoist@npm:^27.5.1": version: 27.5.1 resolution: "babel-plugin-jest-hoist@npm:27.5.1" @@ -26793,6 +27034,18 @@ __metadata: languageName: node linkType: hard +"babel-preset-jest@npm:^26.6.2": + version: 26.6.2 + resolution: "babel-preset-jest@npm:26.6.2" + dependencies: + babel-plugin-jest-hoist: "npm:^26.6.2" + babel-preset-current-node-syntax: "npm:^1.0.0" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/b6e0efe33b485eb2fba019026933e46d680605b3bf84a6b7378f1df8344b890f66318c49129921dd98bf5819694316312a97b50b16d9aa377faf8624f9f0db5b + languageName: node + linkType: hard + "babel-preset-jest@npm:^27.5.1": version: 27.5.1 resolution: "babel-preset-jest@npm:27.5.1" @@ -28100,6 +28353,15 @@ __metadata: languageName: node linkType: hard +"capture-exit@npm:^2.0.0": + version: 2.0.0 + resolution: "capture-exit@npm:2.0.0" + dependencies: + rsvp: "npm:^4.8.4" + checksum: 10c0/d68df1e15937809501644a49c0267ef323b5b6a0cae5c08bbdceafd718aa08241844798bfdd762cf6756bc2becd83122aabc25b5222192f18093113bec670617 + languageName: node + linkType: hard + "cardinal@npm:^2.1.1": version: 2.1.1 resolution: "cardinal@npm:2.1.1" @@ -29991,7 +30253,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^6.0.5": +"cross-spawn@npm:^6.0.0, cross-spawn@npm:^6.0.5": version: 6.0.6 resolution: "cross-spawn@npm:6.0.6" dependencies: @@ -32340,6 +32602,19 @@ __metadata: languageName: node linkType: hard +"esbuild-jest@npm:^0.5.0": + version: 0.5.0 + resolution: "esbuild-jest@npm:0.5.0" + dependencies: + "@babel/core": "npm:^7.12.17" + "@babel/plugin-transform-modules-commonjs": "npm:^7.12.13" + babel-jest: "npm:^26.6.3" + peerDependencies: + esbuild: ">=0.8.50" + checksum: 10c0/32921608498bf23928c4ca4db3b41775cf6d2ef9c1a5a1fa166bacfb3c3c83792e1192d50d8022cbef744241b6fa2fc2d9c34d36eaed988e8d52bde46761c37e + languageName: node + linkType: hard + "esbuild-plugin-alias@npm:^0.2.1": version: 0.2.1 resolution: "esbuild-plugin-alias@npm:0.2.1" @@ -33566,6 +33841,28 @@ __metadata: languageName: node linkType: hard +"exec-sh@npm:^0.3.2": + version: 0.3.6 + resolution: "exec-sh@npm:0.3.6" + checksum: 10c0/de29ed40c263989ea151cfc8561c9a41a443185d1998b0ff7aee248323af3b46db3a1dc5341816297d0c02dca472b188640490aa4ba3cae017f531f98102607d + languageName: node + linkType: hard + +"execa@npm:^1.0.0": + version: 1.0.0 + resolution: "execa@npm:1.0.0" + dependencies: + cross-spawn: "npm:^6.0.0" + get-stream: "npm:^4.0.0" + is-stream: "npm:^1.1.0" + npm-run-path: "npm:^2.0.0" + p-finally: "npm:^1.0.0" + signal-exit: "npm:^3.0.0" + strip-eof: "npm:^1.0.0" + checksum: 10c0/cc71707c9aa4a2552346893ee63198bf70a04b5a1bc4f8a0ef40f1d03c319eae80932c59191f037990d7d102193e83a38ec72115fff814ec2fb3099f3661a590 + languageName: node + linkType: hard + "execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -35229,7 +35526,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": +"fsevents@npm:^2.1.2, fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -35258,7 +35555,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.1.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -35509,6 +35806,7 @@ __metadata: email-addresses: "npm:5.0.0" emittery: "npm:^0.13.1" esbuild: "npm:^0.17.15" + esbuild-jest: "npm:^0.5.0" esbuild-register: "npm:^3.5.0" eslint: "npm:^8.57.1" fxa-customs-server: "workspace:*" @@ -35563,6 +35861,7 @@ __metadata: p-retry: "npm:^4.2.0" pm2: "npm:^6.0.14" poolee: "npm:^1.0.1" + portfinder: "npm:^1.0.38" prettier: "npm:^3.5.3" proxyquire: "npm:^2.1.3" punycode.js: "npm:2.3.0" @@ -36726,6 +37025,15 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^4.0.0": + version: 4.1.0 + resolution: "get-stream@npm:4.1.0" + dependencies: + pump: "npm:^3.0.0" + checksum: 10c0/294d876f667694a5ca23f0ca2156de67da950433b6fb53024833733975d32582896dbc7f257842d331809979efccf04d5e0b6b75ad4d45744c45f193fd497539 + languageName: node + linkType: hard + "get-stream@npm:^5.1.0": version: 5.2.0 resolution: "get-stream@npm:5.2.0" @@ -40268,6 +40576,13 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^1.1.0": + version: 1.1.0 + resolution: "is-stream@npm:1.1.0" + checksum: 10c0/b8ae7971e78d2e8488d15f804229c6eed7ed36a28f8807a1815938771f4adff0e705218b7dab968270433f67103e4fef98062a0beea55d64835f705ee72c7002 + languageName: node + linkType: hard + "is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": version: 2.0.1 resolution: "is-stream@npm:2.0.1" @@ -41108,6 +41423,31 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:^26.6.2": + version: 26.6.2 + resolution: "jest-haste-map@npm:26.6.2" + dependencies: + "@jest/types": "npm:^26.6.2" + "@types/graceful-fs": "npm:^4.1.2" + "@types/node": "npm:*" + anymatch: "npm:^3.0.3" + fb-watchman: "npm:^2.0.0" + fsevents: "npm:^2.1.2" + graceful-fs: "npm:^4.2.4" + jest-regex-util: "npm:^26.0.0" + jest-serializer: "npm:^26.6.2" + jest-util: "npm:^26.6.2" + jest-worker: "npm:^26.6.2" + micromatch: "npm:^4.0.2" + sane: "npm:^4.0.3" + walker: "npm:^1.0.7" + dependenciesMeta: + fsevents: + optional: true + checksum: 10c0/85a40d8ecf4bfb659613f107c963c7366cdf6dcceb0ca73dc8ca09fbe0e2a63b976940f573db6260c43011993cb804275f447f268c3bc4b680c08baed300701d + languageName: node + linkType: hard + "jest-haste-map@npm:^27.5.1": version: 27.5.1 resolution: "jest-haste-map@npm:27.5.1" @@ -41367,6 +41707,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:^26.0.0": + version: 26.0.0 + resolution: "jest-regex-util@npm:26.0.0" + checksum: 10c0/988675764a08945b90f48e6f5a8640b0d9885a977f100a168061d10037d53808a6cdb7dc8cb6fe9b1332f0523b42bf3edbb6d2cc6c7f7ba582d05d432efb3e60 + languageName: node + linkType: hard + "jest-regex-util@npm:^27.0.0, jest-regex-util@npm:^27.5.1": version: 27.5.1 resolution: "jest-regex-util@npm:27.5.1" @@ -41562,6 +41909,16 @@ __metadata: languageName: node linkType: hard +"jest-serializer@npm:^26.6.2": + version: 26.6.2 + resolution: "jest-serializer@npm:26.6.2" + dependencies: + "@types/node": "npm:*" + graceful-fs: "npm:^4.2.4" + checksum: 10c0/1c67aa1acefdc0b244f2629aaef12a56e563a5c5cb817970d2b97bdad5e8aae187b269c8d356c42ff9711436499c4da71ec8400e6280dab110be8cc5300884b0 + languageName: node + linkType: hard + "jest-serializer@npm:^27.5.1": version: 27.5.1 resolution: "jest-serializer@npm:27.5.1" @@ -41644,6 +42001,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:^26.6.2": + version: 26.6.2 + resolution: "jest-util@npm:26.6.2" + dependencies: + "@jest/types": "npm:^26.6.2" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.4" + is-ci: "npm:^2.0.0" + micromatch: "npm:^4.0.2" + checksum: 10c0/ab93709840f87bdf478d082f5465467c27a20a422cbe456cc2a56961d8c950ea52511995fb6063f62a113737f3dd714b836a1fbde51abef96642a5975e835a01 + languageName: node + linkType: hard + "jest-util@npm:^27.5.1": version: 27.5.1 resolution: "jest-util@npm:27.5.1" @@ -41795,7 +42166,7 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:^26.2.1": +"jest-worker@npm:^26.2.1, jest-worker@npm:^26.6.2": version: 26.6.2 resolution: "jest-worker@npm:26.6.2" dependencies: @@ -46688,6 +47059,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^2.0.0": + version: 2.0.2 + resolution: "npm-run-path@npm:2.0.2" + dependencies: + path-key: "npm:^2.0.0" + checksum: 10c0/95549a477886f48346568c97b08c4fda9cdbf7ce8a4fbc2213f36896d0d19249e32d68d7451bdcbca8041b5fba04a6b2c4a618beaf19849505c05b700740f1de + languageName: node + linkType: hard + "npm-run-path@npm:^4.0.1": version: 4.0.1 resolution: "npm-run-path@npm:4.0.1" @@ -47605,6 +47985,13 @@ __metadata: languageName: node linkType: hard +"p-finally@npm:^1.0.0": + version: 1.0.0 + resolution: "p-finally@npm:1.0.0" + checksum: 10c0/6b8552339a71fe7bd424d01d8451eea92d379a711fc62f6b2fe64cad8a472c7259a236c9a22b4733abca0b5666ad503cb497792a0478c5af31ded793d00937e7 + languageName: node + linkType: hard + "p-limit@npm:3.1.0, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": version: 3.1.0 resolution: "p-limit@npm:3.1.0" @@ -48147,7 +48534,7 @@ __metadata: languageName: node linkType: hard -"path-key@npm:^2.0.1": +"path-key@npm:^2.0.0, path-key@npm:^2.0.1": version: 2.0.1 resolution: "path-key@npm:2.0.1" checksum: 10c0/dd2044f029a8e58ac31d2bf34c34b93c3095c1481942960e84dd2faa95bbb71b9b762a106aead0646695330936414b31ca0bd862bf488a937ad17c8c5d73b32b @@ -48821,6 +49208,16 @@ __metadata: languageName: node linkType: hard +"portfinder@npm:^1.0.38": + version: 1.0.38 + resolution: "portfinder@npm:1.0.38" + dependencies: + async: "npm:^3.2.6" + debug: "npm:^4.3.6" + checksum: 10c0/59b2f2aa0b620c90ce0d477241e62c277f38bfd4fb6074106c23560248dd5e5c2c629dd048ef721f32b19df4213d09b77234880e4f0ab04abf1ab70b6d8048fa + languageName: node + linkType: hard + "posix-character-classes@npm:^0.1.0": version: 0.1.1 resolution: "posix-character-classes@npm:0.1.1" @@ -53131,6 +53528,13 @@ __metadata: languageName: node linkType: hard +"rsvp@npm:^4.8.4": + version: 4.8.5 + resolution: "rsvp@npm:4.8.5" + checksum: 10c0/7978f01060a48204506a8ebe15cdbd468498f5ae538b1d7ee3e7630375ba7cb2f98df2f596c12d3f4d5d5c21badc1c6ca8009f5142baded8511609a28eabd19a + languageName: node + linkType: hard + "run-applescript@npm:^7.0.0": version: 7.0.0 resolution: "run-applescript@npm:7.0.0" @@ -53298,6 +53702,25 @@ __metadata: languageName: node linkType: hard +"sane@npm:^4.0.3": + version: 4.1.0 + resolution: "sane@npm:4.1.0" + dependencies: + "@cnakazawa/watch": "npm:^1.0.3" + anymatch: "npm:^2.0.0" + capture-exit: "npm:^2.0.0" + exec-sh: "npm:^0.3.2" + execa: "npm:^1.0.0" + fb-watchman: "npm:^2.0.0" + micromatch: "npm:^3.1.4" + minimist: "npm:^1.1.1" + walker: "npm:~1.0.5" + bin: + sane: ./src/cli.js + checksum: 10c0/7d0991ecaa10b02c6d0339a6f7e31db776971f3b659a351916dcc7ce3464671e72b54d80bcce118e39d4343e1e56c699fe35f6cb89fbd88b07095b72841cbfb0 + languageName: node + linkType: hard + "sanitize.css@npm:*, sanitize.css@npm:13.0.0": version: 13.0.0 resolution: "sanitize.css@npm:13.0.0" @@ -55663,6 +56086,13 @@ __metadata: languageName: node linkType: hard +"strip-eof@npm:^1.0.0": + version: 1.0.0 + resolution: "strip-eof@npm:1.0.0" + checksum: 10c0/f336beed8622f7c1dd02f2cbd8422da9208fae81daf184f73656332899978919d5c0ca84dc6cfc49ad1fc4dd7badcde5412a063cf4e0d7f8ed95a13a63f68f45 + languageName: node + linkType: hard + "strip-final-newline@npm:^2.0.0": version: 2.0.0 resolution: "strip-final-newline@npm:2.0.0" @@ -59179,7 +59609,7 @@ __metadata: languageName: node linkType: hard -"walker@npm:^1.0.7, walker@npm:^1.0.8": +"walker@npm:^1.0.7, walker@npm:^1.0.8, walker@npm:~1.0.5": version: 1.0.8 resolution: "walker@npm:1.0.8" dependencies: From 54fecb28c85d6af0258bf958175273f995c33487 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Wed, 4 Feb 2026 10:39:23 -0500 Subject: [PATCH 6/6] updates --- .circleci/config.yml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a7a37cbc9f8..4ba10b972b8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1304,16 +1304,6 @@ workflows: nx_run: run-many --skipRemoteCache requires: - Build (nightly) - - integration-test: - name: Integration Test Jest - Servers - Auth (nightly) - projects: --exclude '*,!tag:scope:server:auth' - start_customs: true - target: -t test-integration-jest - test_suite: servers-auth-jest-integration - workflow: nightly - nx_run: run-many --skipRemoteCache - requires: - - Build (nightly) - integration-test: name: Integration Test - Libraries (nightly) projects: --exclude '*,!tag:scope:shared:*' @@ -1340,7 +1330,6 @@ workflows: - Integration Test - Servers (nightly) - Integration Test - Servers - Auth (nightly) - Integration Test - Servers - Auth V2 (nightly) - - Integration Test Jest - Servers - Auth (nightly) - Integration Test - Libraries (nightly) - Firefox Functional Tests - Playwright (nightly) - create-fxa-image: