diff --git a/packages/fxa-auth-server/jest.config.js b/packages/fxa-auth-server/jest.config.js index ada3b502870..2d7a2f86539 100644 --- a/packages/fxa-auth-server/jest.config.js +++ b/packages/fxa-auth-server/jest.config.js @@ -6,12 +6,18 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', rootDir: '.', - testMatch: ['/lib/**/*.spec.ts', '/config/**/*.spec.ts'], + testMatch: [ + '/lib/**/*.spec.ts', + '/config/**/*.spec.ts', + '/scripts/**/*.spec.ts', + ], moduleFileExtensions: ['ts', 'js', 'json'], transform: { '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: { isolatedModules: true } }], }, - transformIgnorePatterns: ['/node_modules/(?!(@fxa|fxa-shared)/)'], + transformIgnorePatterns: [ + '/node_modules/(?!(@fxa|fxa-shared|p-queue|p-timeout|eventemitter3)/)', + ], moduleNameMapper: { '^@fxa/shared/(.*)$': '/../../libs/shared/$1/src', '^@fxa/accounts/(.*)$': '/../../libs/accounts/$1/src', diff --git a/packages/fxa-auth-server/lib/cad-reminders.in.spec.ts b/packages/fxa-auth-server/lib/cad-reminders.in.spec.ts index ee170caf6b1..d1b7164ee02 100644 --- a/packages/fxa-auth-server/lib/cad-reminders.in.spec.ts +++ b/packages/fxa-auth-server/lib/cad-reminders.in.spec.ts @@ -30,7 +30,7 @@ describe('#integration - lib/cad-reminders', () => { redis: { maxConnections: 1, minConnections: 1, - prefix: 'test-cad-reminders:', + prefix: 'test-cad-reminders-lib:', }, }, }; @@ -98,13 +98,10 @@ describe('#integration - lib/cad-reminders', () => { expect(deleteResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); }); - it.each(REMINDERS)( - 'removed %s reminder from redis', - async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toHaveLength(0); - } - ); + it.each(REMINDERS)('removed %s reminder from redis', async (reminder) => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toHaveLength(0); + }); it('did not call log.error', () => { expect(log.error.callCount).toBe(0); @@ -147,13 +144,11 @@ describe('#integration - lib/cad-reminders', () => { expect(parseInt(processResult.first[0].timestamp)).toBeGreaterThan( before - 1000 ); - expect(parseInt(processResult.first[0].timestamp)).toBeLessThan( - before - ); + 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) + ).toBeGreaterThanOrEqual(before); expect(parseInt(processResult.first[1].timestamp)).toBeLessThan( before + 1000 ); diff --git a/packages/fxa-auth-server/lib/db.ts b/packages/fxa-auth-server/lib/db.ts index a31e3557aef..31a95717e27 100644 --- a/packages/fxa-auth-server/lib/db.ts +++ b/packages/fxa-auth-server/lib/db.ts @@ -27,6 +27,7 @@ import { TotpToken, } from 'fxa-shared/db/models/auth'; import { normalizeEmail } from 'fxa-shared/email/helpers'; +import { Knex } from 'knex'; import { StatsD } from 'hot-shots'; import { Container } from 'typedi'; import random, { base32 } from './crypto/random'; @@ -82,15 +83,17 @@ export const createDB = ( class DB { redis: any; + knex: Knex | null; metrics?: StatsD; - constructor(options: { redis?: any; metrics?: StatsD }) { + constructor(options: { redis?: any; knex?: Knex; metrics?: StatsD }) { this.redis = options.redis || require('./redis')( { ...config.redis, ...config.redis.sessionTokens }, log ); + this.knex = options.knex ?? null; this.metrics = options.metrics || resolveMetrics(); } @@ -107,12 +110,17 @@ export const createDB = ( console.dir(data); }); } - return new DB({ redis, metrics }); + return new DB({ redis, knex, metrics }); } async close() { if (this.redis) { await this.redis.close(); + this.redis = null; + } + if (this.knex) { + await this.knex.destroy(); + this.knex = null; } } diff --git a/packages/fxa-auth-server/lib/redis.in.spec.ts b/packages/fxa-auth-server/lib/redis.in.spec.ts index 518edc3a33f..43b2b098f8e 100644 --- a/packages/fxa-auth-server/lib/redis.in.spec.ts +++ b/packages/fxa-auth-server/lib/redis.in.spec.ts @@ -204,7 +204,17 @@ describe('#integration - Redis', () => { let accessToken2: any; beforeEach(async () => { - await redis.redis.flushall(); + // Scoped cleanup: only delete keys under our prefix, not the entire keyspace. + // flushall() would wipe keys from other parallel test workers (e.g. pending + // secondary-email verification reservations) and cause unrelated flakes. + // keys() returns fully-prefixed keys, so strip the prefix before del() to avoid + // double-prefixing. + const keys = await redis.redis.keys(prefix + '*'); + if (keys.length) { + await redis.redis.del( + ...keys.map((k: string) => k.replace(prefix, '')) + ); + } accessToken2 = AccessToken.parse( JSON.stringify({ clientId: '5678', @@ -243,9 +253,7 @@ describe('#integration - Redis', () => { const index = await redis.redis.smembers( accessToken1.userId.toString('hex') ); - expect(index).toEqual([ - prefix + accessToken1.tokenId.toString('hex'), - ]); + expect(index).toEqual([prefix + accessToken1.tokenId.toString('hex')]); }); it('appends to the index', async () => { @@ -310,9 +318,7 @@ describe('#integration - Redis', () => { it('sets expiry on the index', async () => { await redis.setAccessToken(accessToken1); - const ttl = await redis.redis.pttl( - accessToken1.userId.toString('hex') - ); + const ttl = await redis.redis.pttl(accessToken1.userId.toString('hex')); expect(ttl).toBeLessThanOrEqual(maxttl); expect(ttl).toBeGreaterThanOrEqual(maxttl - 10); }); @@ -357,9 +363,7 @@ describe('#integration - Redis', () => { const index = await redis.redis.smembers( accessToken2.userId.toString('hex') ); - expect(index).toEqual([ - prefix + accessToken2.tokenId.toString('hex'), - ]); + expect(index).toEqual([prefix + accessToken2.tokenId.toString('hex')]); }); }); @@ -367,9 +371,7 @@ describe('#integration - Redis', () => { it('deletes the token', async () => { await redis.setAccessToken(accessToken1); await redis.removeAccessToken(accessToken1.tokenId); - const rawValue = await redis.get( - accessToken1.tokenId.toString('hex') - ); + const rawValue = await redis.get(accessToken1.tokenId.toString('hex')); expect(rawValue).toBeNull(); }); @@ -388,9 +390,7 @@ describe('#integration - Redis', () => { describe('removeAccessTokensForPublicClients', () => { it('does not remove non-public or non-grant tokens', async () => { await redis.setAccessToken(accessToken1); - await redis.removeAccessTokensForPublicClients( - accessToken1.userId - ); + await redis.removeAccessTokensForPublicClients(accessToken1.userId); const tokens = await redis.getAccessTokens(accessToken1.userId); expect(tokens).toEqual([accessToken1]); }); @@ -399,9 +399,7 @@ describe('#integration - Redis', () => { accessToken1.publicClient = true; await redis.setAccessToken(accessToken1); await redis.setAccessToken(accessToken2); - await redis.removeAccessTokensForPublicClients( - accessToken1.userId - ); + await redis.removeAccessTokensForPublicClients(accessToken1.userId); const tokens = await redis.getAccessTokens(accessToken1.userId); expect(tokens).toEqual([accessToken2]); }); @@ -410,17 +408,13 @@ describe('#integration - Redis', () => { accessToken1.canGrant = true; await redis.setAccessToken(accessToken1); await redis.setAccessToken(accessToken2); - await redis.removeAccessTokensForPublicClients( - accessToken1.userId - ); + await redis.removeAccessTokensForPublicClients(accessToken1.userId); const tokens = await redis.getAccessTokens(accessToken1.userId); expect(tokens).toEqual([accessToken2]); }); it('does nothing for nonexistent tokens', async () => { - await redis.removeAccessTokensForPublicClients( - accessToken1.userId - ); + await redis.removeAccessTokensForPublicClients(accessToken1.userId); }); }); @@ -478,7 +472,13 @@ describe('#integration - Redis', () => { let oldMeta: any; beforeEach(async () => { - await redis.redis.flushall(); + // Scoped cleanup: only delete keys under our prefix, not the entire keyspace. + const keys = await redis.redis.keys(prefix + '*'); + if (keys.length) { + await redis.redis.del( + ...keys.map((k: string) => k.replace(prefix, '')) + ); + } oldMeta = new RefreshTokenMetadata( new Date(Date.now() - (maxttl + 1000)) ); @@ -534,9 +534,7 @@ describe('Redis down', () => { describe('touchSessionToken', () => { it('returns without error', async () => { - await expect( - downRedis.touchSessionToken(uid, {}) - ).resolves.not.toThrow(); + await expect(downRedis.touchSessionToken(uid, {})).resolves.not.toThrow(); }); }); diff --git a/packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts b/packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts index 59e664beb30..838bd4e3a95 100644 --- a/packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts +++ b/packages/fxa-auth-server/lib/subscription-account-reminders.in.spec.ts @@ -15,12 +15,9 @@ const config = require('../config').default.getProperties(); const mocks = require('../test/mocks'); describe('#integration - lib/subscription-account-reminders', () => { - let log: any, - mockConfig: any, - redis: any, - subscriptionAccountReminders: any; + let log: any, mockConfig: any, redis: any, subscriptionAccountReminders: any; - beforeEach(() => { + beforeEach(async () => { jest.resetModules(); log = mocks.mockLog(); mockConfig = { @@ -33,7 +30,7 @@ describe('#integration - lib/subscription-account-reminders', () => { redis: { maxConnections: 1, minConnections: 1, - prefix: 'test-subscription-account-reminders:', + prefix: 'test-subscription-account-reminders-lib:', }, }, }; @@ -45,6 +42,13 @@ describe('#integration - lib/subscription-account-reminders', () => { }, mocks.mockLog() ); + await Promise.all([ + redis.del('first'), + redis.del('second'), + redis.del('third'), + redis.del('metadata_sub_flow:wibble'), + redis.del('metadata_sub_flow:blee'), + ]); subscriptionAccountReminders = require('./subscription-account-reminders')( log, mockConfig @@ -122,13 +126,10 @@ describe('#integration - lib/subscription-account-reminders', () => { expect(deleteResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); }); - it.each(REMINDERS)( - 'removed %s reminder from redis', - async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toHaveLength(0); - } - ); + it.each(REMINDERS)('removed %s reminder from redis', async (reminder) => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toHaveLength(0); + }); it('did not call log.error', () => { expect(log.error.callCount).toBe(0); @@ -148,9 +149,7 @@ describe('#integration - lib/subscription-account-reminders', () => { undefined, before ); - processResult = await subscriptionAccountReminders.process( - before + 2 - ); + processResult = await subscriptionAccountReminders.process(before + 2); }); afterEach(() => { @@ -167,13 +166,11 @@ describe('#integration - lib/subscription-account-reminders', () => { expect(parseInt(processResult.first[0].timestamp)).toBeGreaterThan( before - 1000 ); - expect(parseInt(processResult.first[0].timestamp)).toBeLessThan( - before - ); + 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) + ).toBeGreaterThanOrEqual(before); expect(parseInt(processResult.first[1].timestamp)).toBeLessThan( before + 1000 ); @@ -241,20 +238,13 @@ describe('#integration - lib/subscription-account-reminders', () => { }); it('reinstated records to the second reminder', async () => { - const reminders = await redis.zrange( - 'second', - 0, - -1, - 'WITHSCORES' - ); + 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']) - ); + expect(new Set(reminders)).toEqual(new Set(['wibble', 'blee'])); }); }); }); @@ -305,13 +295,10 @@ describe('#integration - lib/subscription-account-reminders', () => { expect(deleteResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); }); - it.each(REMINDERS)( - 'removed %s reminder from redis', - async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toHaveLength(0); - } - ); + it.each(REMINDERS)('removed %s reminder from redis', async (reminder) => { + const reminders = await redis.zrange(reminder, 0, -1); + expect(reminders).toHaveLength(0); + }); it('removed metadata from redis', async () => { const metadata = await redis.get('metadata_sub_flow:wibble'); @@ -327,9 +314,7 @@ describe('#integration - lib/subscription-account-reminders', () => { let processResult: any; beforeEach(async () => { - processResult = await subscriptionAccountReminders.process( - before + 2 - ); + processResult = await subscriptionAccountReminders.process(before + 2); }); it('returned the correct result', () => { @@ -403,12 +388,7 @@ describe('#integration - lib/subscription-account-reminders', () => { }); it('reinstated record to the second reminder', async () => { - const reminders = await redis.zrange( - 'second', - 0, - -1, - 'WITHSCORES' - ); + const reminders = await redis.zrange('second', 0, -1, 'WITHSCORES'); expect(reminders).toEqual(['wibble', '2']); }); @@ -433,8 +413,9 @@ describe('#integration - lib/subscription-account-reminders', () => { let secondProcessResult: any; beforeEach(async () => { - secondProcessResult = - await subscriptionAccountReminders.process(before + 1000); + secondProcessResult = await subscriptionAccountReminders.process( + before + 1000 + ); }); it('returned the correct result and cleared everything from redis', async () => { diff --git a/packages/fxa-auth-server/package.json b/packages/fxa-auth-server/package.json index f92d788ec94..722e3f5f98f 100644 --- a/packages/fxa-auth-server/package.json +++ b/packages/fxa-auth-server/package.json @@ -38,7 +38,7 @@ "test": "VERIFIER_VERSION=0 scripts/test-local.sh", "test-jest": "jest --no-coverage --forceExit", "test-jest-unit": "jest --no-coverage --forceExit", - "test-jest-integration": "jest --no-coverage --forceExit --config jest.integration.config.js", + "test-jest-integration": "jest --no-coverage --forceExit --config jest.integration.config.js --testPathPattern='\\.in\\.spec\\.ts$'", "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", diff --git a/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts new file mode 100644 index 00000000000..01487782ff5 --- /dev/null +++ b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts @@ -0,0 +1,692 @@ +/* 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 { PlanCanceller } from './cancel-subscriptions-to-plan'; +import Stripe from 'stripe'; +import { StripeHelper } from '../../lib/payments/stripe'; + +import product1 from '../../test/local/payments/fixtures/stripe/product1.json'; +import customer1 from '../../test/local/payments/fixtures/stripe/customer1.json'; +import subscription1 from '../../test/local/payments/fixtures/stripe/subscription1.json'; +import { PayPalHelper } from '../../lib/payments/paypal/helper'; + +const mockProduct = product1 as unknown as Stripe.Product; +const mockCustomer = customer1 as unknown as Stripe.Customer; +const mockSubscription = subscription1 as unknown as Stripe.Subscription; + +describe('PlanCanceller', () => { + let planCanceller: PlanCanceller; + let stripeStub: Stripe; + let stripeHelperStub: StripeHelper; + let paypalHelperStub: PayPalHelper; + + beforeEach(() => { + stripeStub = { + on: sinon.stub(), + products: {}, + customers: {}, + subscriptions: {}, + invoices: {}, + refunds: {}, + } as unknown as Stripe; + + stripeHelperStub = { + stripe: stripeStub, + currencyHelper: { + isCurrencyCompatibleWithCountry: sinon.stub(), + }, + } as unknown as StripeHelper; + + paypalHelperStub = { + refundInvoice: sinon.stub(), + } as unknown as PayPalHelper; + + planCanceller = new PlanCanceller( + 'planId', + 'refund', + null, + ['exclude'], + './cancel-subscriptions-to-plan.tmp.csv', + stripeHelperStub, + paypalHelperStub, + false, + 20 + ); + }); + + describe('constructor', () => { + it('throws error if proratedRefundRate is less than or equal to zero', () => { + expect(() => { + void new PlanCanceller( + 'planId', + 'proratedRefund', + 0, + ['exclude'], + './cancel-subscriptions-to-plan.tmp.csv', + stripeHelperStub, + paypalHelperStub, + false, + 20 + ); + }).toThrow('proratedRefundRate must be greater than zero'); + }); + + it('throws error if proratedRefund mode is used without proratedRefundRate', () => { + expect(() => { + void new PlanCanceller( + 'planId', + 'proratedRefund', + null, + ['exclude'], + './cancel-subscriptions-to-plan.tmp.csv', + stripeHelperStub, + paypalHelperStub, + false, + 20 + ); + }).toThrow( + 'proratedRefundRate must be provided when using proratedRefund mode' + ); + }); + + it('does not throw error if proratedRefundRate is null for non-proratedRefund mode', () => { + expect(() => { + void new PlanCanceller( + 'planId', + 'refund', + null, + ['exclude'], + './cancel-subscriptions-to-plan.tmp.csv', + stripeHelperStub, + paypalHelperStub, + false, + 20 + ); + }).not.toThrow(); + }); + + it('does not throw error if proratedRefundRate is positive', () => { + expect(() => { + void new PlanCanceller( + 'planId', + 'proratedRefund', + 100, + ['exclude'], + './cancel-subscriptions-to-plan.tmp.csv', + stripeHelperStub, + paypalHelperStub, + false, + 20 + ); + }).not.toThrow(); + }); + }); + + describe('run', () => { + let processSubscriptionStub: sinon.SinonStub; + let writeReportHeaderStub: sinon.SinonStub; + const mockSubs = [mockSubscription]; + + beforeEach(async () => { + // Mock the async iterable returned by stripe.subscriptions.list + const asyncIterable = { + async *[Symbol.asyncIterator]() { + for (const sub of mockSubs) { + yield sub; + } + }, + }; + + stripeStub.subscriptions.list = sinon + .stub() + .returns(asyncIterable) as any; + + processSubscriptionStub = sinon.stub().resolves(); + planCanceller.processSubscription = processSubscriptionStub; + + writeReportHeaderStub = sinon.stub().resolves(); + planCanceller.writeReportHeader = writeReportHeaderStub; + + await planCanceller.run(); + }); + + it('writes report header', () => { + expect(writeReportHeaderStub.calledOnce).toBe(true); + }); + + it('calls Stripe subscriptions.list with correct parameters', () => { + sinon.assert.calledWith(stripeStub.subscriptions.list as any, { + price: 'planId', + limit: 100, + }); + }); + + it('processes each subscription', () => { + sinon.assert.calledOnce(processSubscriptionStub); + sinon.assert.calledWith(processSubscriptionStub, mockSubscription); + }); + }); + + describe('processSubscription', () => { + const mockSub = { + id: 'test', + customer: 'test', + plan: { + product: 'example-product', + }, + status: 'active', + } as unknown as Stripe.Subscription; + let logStub: sinon.SinonStub; + let cancelStub: sinon.SinonStub; + let attemptFullRefundStub: sinon.SinonStub; + let attemptProratedRefundStub: sinon.SinonStub; + let isCustomerExcludedStub: sinon.SinonStub; + let writeReportStub: sinon.SinonStub; + + beforeEach(async () => { + stripeStub.products.retrieve = sinon.stub().resolves(mockProduct); + stripeStub.subscriptions.cancel = sinon.stub().resolves(); + cancelStub = stripeStub.subscriptions.cancel as sinon.SinonStub; + + planCanceller.fetchCustomer = sinon.stub().resolves(mockCustomer) as any; + + attemptFullRefundStub = sinon.stub().resolves(1000); + planCanceller.attemptFullRefund = attemptFullRefundStub; + + attemptProratedRefundStub = sinon.stub().resolves(500); + planCanceller.attemptProratedRefund = attemptProratedRefundStub; + + isCustomerExcludedStub = sinon.stub().returns(false); + planCanceller.isCustomerExcluded = isCustomerExcludedStub; + + writeReportStub = sinon.stub().resolves(); + planCanceller.writeReport = writeReportStub; + + logStub = sinon.stub(console, 'log'); + }); + + afterEach(() => { + logStub.restore(); + }); + + describe('success - not excluded', () => { + beforeEach(async () => { + await planCanceller.processSubscription(mockSub); + }); + + it('fetches customer', () => { + sinon.assert.calledOnce(planCanceller.fetchCustomer as sinon.SinonStub); + }); + + it('cancels subscription', () => { + sinon.assert.calledWith(cancelStub, 'test', { + prorate: false, + cancellation_details: { + comment: 'administrative_cancellation:subplat_script', + }, + }); + }); + + it('writes report', () => { + sinon.assert.calledWith( + writeReportStub, + sinon.match({ + subscription: mockSub, + customer: mockCustomer, + isExcluded: false, + amountRefunded: 1000, + isOwed: false, + error: false, + }) + ); + }); + }); + + describe('success - with refund', () => { + beforeEach(async () => { + attemptFullRefundStub.resolves(1000); + await planCanceller.processSubscription(mockSub); + }); + + it('writes report with refund amount', () => { + sinon.assert.calledWith( + writeReportStub, + sinon.match({ + subscription: mockSub, + customer: mockCustomer, + isExcluded: false, + amountRefunded: 1000, + isOwed: false, + error: false, + }) + ); + }); + }); + + describe('dry run', () => { + beforeEach(async () => { + planCanceller.dryRun = true; + await planCanceller.processSubscription(mockSub); + }); + + it('does not cancel subscription', () => { + sinon.assert.notCalled(cancelStub); + }); + + it('attempts refund', () => { + sinon.assert.calledOnce(attemptFullRefundStub); + }); + + it('writes report', () => { + sinon.assert.calledWith( + writeReportStub, + sinon.match({ + subscription: mockSub, + customer: mockCustomer, + isExcluded: false, + amountRefunded: 1000, + isOwed: false, + error: false, + }) + ); + }); + }); + + describe('customer excluded', () => { + beforeEach(async () => { + isCustomerExcludedStub.returns(true); + await planCanceller.processSubscription(mockSub); + }); + + it('does not cancel subscription', () => { + sinon.assert.notCalled(cancelStub); + }); + + it('writes report marking as excluded', () => { + sinon.assert.calledWith( + writeReportStub, + sinon.match({ + subscription: mockSub, + customer: mockCustomer, + isExcluded: true, + amountRefunded: null, + isOwed: false, + error: false, + }) + ); + }); + }); + + describe('invalid', () => { + it('writes error report if customer does not exist', async () => { + planCanceller.fetchCustomer = sinon.stub().resolves(null) as any; + await planCanceller.processSubscription(mockSub); + + sinon.assert.calledWith( + writeReportStub, + sinon.match({ + subscription: mockSub, + customer: null, + isExcluded: false, + amountRefunded: null, + isOwed: false, + error: true, + }) + ); + }); + + it('writes error report if unexpected error occurs', async () => { + cancelStub.rejects(new Error('test error')); + await planCanceller.processSubscription(mockSub); + + sinon.assert.calledWith( + writeReportStub, + sinon.match({ + subscription: mockSub, + customer: null, + isExcluded: false, + amountRefunded: null, + isOwed: false, + error: true, + }) + ); + }); + }); + }); + + describe('fetchCustomer', () => { + let customerRetrieveStub: sinon.SinonStub; + let result: Stripe.Customer | Stripe.DeletedCustomer | null; + + describe('customer exists', () => { + beforeEach(async () => { + customerRetrieveStub = sinon.stub().resolves(mockCustomer); + stripeStub.customers.retrieve = customerRetrieveStub; + + result = await planCanceller.fetchCustomer(mockCustomer.id); + }); + + it('fetches customer from Stripe', () => { + expect( + customerRetrieveStub.calledWith(mockCustomer.id, { + expand: ['subscriptions'], + }) + ).toBe(true); + }); + + it('returns customer', () => { + sinon.assert.match(result, mockCustomer); + }); + }); + + describe('customer deleted', () => { + beforeEach(async () => { + const deletedCustomer = { + ...mockCustomer, + deleted: true, + }; + customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + stripeStub.customers.retrieve = customerRetrieveStub; + + result = await planCanceller.fetchCustomer(mockCustomer.id); + }); + + it('returns null', () => { + sinon.assert.match(result, null); + }); + }); + }); + + describe('isCustomerExcluded', () => { + it("returns true if the customer has a price that's excluded", () => { + const result = planCanceller.isCustomerExcluded([ + { + ...mockSubscription, + items: { + ...mockSubscription.items, + data: [ + { + ...mockSubscription.items.data[0], + plan: { + ...mockSubscription.items.data[0].plan, + id: 'exclude', + }, + }, + ], + }, + }, + ] as any); + expect(result).toBe(true); + }); + + it("returns false if the customer does not have a price that's excluded", () => { + const result = planCanceller.isCustomerExcluded([ + { + ...mockSubscription, + }, + ] as any); + expect(result).toBe(false); + }); + }); + + describe('attemptFullRefund', () => { + let invoiceRetrieveStub: sinon.SinonStub; + let refundCreateStub: sinon.SinonStub; + let refundInvoiceStub: sinon.SinonStub; + const mockFullRefundInvoice = { + charge: 'ch_123', + amount_due: 1000, + paid_out_of_band: false, + }; + + beforeEach(() => { + invoiceRetrieveStub = sinon.stub().resolves(mockFullRefundInvoice); + stripeStub.invoices.retrieve = invoiceRetrieveStub; + + refundCreateStub = sinon.stub().resolves(); + stripeStub.refunds.create = refundCreateStub; + + refundInvoiceStub = sinon.stub().resolves(); + paypalHelperStub.refundInvoice = refundInvoiceStub; + }); + + describe('Stripe refund', () => { + beforeEach(async () => { + await planCanceller.attemptFullRefund(mockSubscription); + }); + + it('retrieves invoice', () => { + sinon.assert.calledWith( + invoiceRetrieveStub, + mockSubscription.latest_invoice + ); + }); + + it('creates refund', () => { + sinon.assert.calledWith(refundCreateStub, { + charge: mockFullRefundInvoice.charge, + }); + }); + + it('returns amount refunded', async () => { + const result = await planCanceller.attemptFullRefund(mockSubscription); + expect(result).toBe(1000); + }); + }); + + describe('PayPal refund', () => { + const mockPaypalInvoice = { + ...mockFullRefundInvoice, + paid_out_of_band: true, + }; + + beforeEach(async () => { + invoiceRetrieveStub.resolves(mockPaypalInvoice); + await planCanceller.attemptFullRefund(mockSubscription); + }); + + it('calls PayPal refund', () => { + sinon.assert.calledWith(refundInvoiceStub, mockPaypalInvoice); + }); + }); + + describe('dry run', () => { + beforeEach(async () => { + planCanceller.dryRun = true; + await planCanceller.attemptFullRefund(mockSubscription); + }); + + it('does not create refund', () => { + sinon.assert.notCalled(refundCreateStub); + }); + }); + + describe('errors', () => { + it('throws if subscription has no latest_invoice', async () => { + const subWithoutInvoice = { + ...mockSubscription, + latest_invoice: null, + }; + await expect( + planCanceller.attemptFullRefund(subWithoutInvoice as any) + ).rejects.toThrow('No latest invoice'); + }); + + it('throws if invoice has no charge', async () => { + invoiceRetrieveStub.resolves({ + ...mockFullRefundInvoice, + charge: null, + }); + await expect( + planCanceller.attemptFullRefund(mockSubscription) + ).rejects.toThrow('No charge'); + }); + }); + }); + + describe('attemptProratedRefund', () => { + let invoiceRetrieveStub: sinon.SinonStub; + let refundCreateStub: sinon.SinonStub; + let refundInvoiceStub: sinon.SinonStub; + const now = Math.floor(Date.now() / 1000); + const mockProratedSubscription = { + ...mockSubscription, + current_period_start: now - 86400 * 2, + current_period_end: now + 86400 * 28, + }; + const mockProratedInvoice = { + charge: 'ch_123', + amount_due: 10000, + paid: true, + paid_out_of_band: false, + created: Math.floor(Date.now() / 1000) - 86400, + }; + + beforeEach(() => { + invoiceRetrieveStub = sinon.stub().resolves(mockProratedInvoice); + stripeStub.invoices.retrieve = invoiceRetrieveStub; + + refundCreateStub = sinon.stub().resolves(); + stripeStub.refunds.create = refundCreateStub; + + refundInvoiceStub = sinon.stub().resolves(); + paypalHelperStub.refundInvoice = refundInvoiceStub; + + planCanceller = new PlanCanceller( + 'planId', + 'proratedRefund', + 100, + ['exclude'], + './cancel-subscriptions-to-plan.tmp.csv', + stripeHelperStub, + paypalHelperStub, + false, + 20 + ); + }); + + describe('Stripe refund', () => { + it('retrieves invoice', async () => { + await planCanceller.attemptProratedRefund( + mockProratedSubscription as any + ); + sinon.assert.calledWith( + invoiceRetrieveStub, + mockProratedSubscription.latest_invoice + ); + }); + + it('creates refund with calculated amount', async () => { + await planCanceller.attemptProratedRefund( + mockProratedSubscription as any + ); + + const oneDayMs = 1000 * 60 * 60 * 24; + const periodEnd = new Date( + mockProratedSubscription.current_period_end * 1000 + ); + const nowTime = new Date(); + const timeRemainingMs = periodEnd.getTime() - nowTime.getTime(); + const daysRemaining = Math.floor(timeRemainingMs / oneDayMs); + const expectedRefund = daysRemaining * 100; + + sinon.assert.calledWith( + refundCreateStub, + sinon.match({ + charge: mockProratedInvoice.charge, + amount: expectedRefund, + }) + ); + }); + }); + + describe('PayPal refund - partial', () => { + const mockPaypalInvoice = { + ...mockProratedInvoice, + paid_out_of_band: true, + }; + + beforeEach(async () => { + invoiceRetrieveStub.resolves(mockPaypalInvoice); + await planCanceller.attemptProratedRefund( + mockProratedSubscription as any + ); + }); + + it('calls PayPal refund with partial amount', () => { + const oneDayMs = 1000 * 60 * 60 * 24; + const periodEnd = new Date( + mockProratedSubscription.current_period_end * 1000 + ); + const nowTime = new Date(); + const timeRemainingMs = periodEnd.getTime() - nowTime.getTime(); + const daysRemaining = Math.floor(timeRemainingMs / oneDayMs); + const expectedRefund = daysRemaining * 100; + + sinon.assert.calledWith(refundInvoiceStub, mockPaypalInvoice, { + refundType: 'Partial', + amount: expectedRefund, + }); + }); + }); + + describe('dry run', () => { + beforeEach(async () => { + planCanceller.dryRun = true; + await planCanceller.attemptProratedRefund( + mockProratedSubscription as any + ); + }); + + it('does not create refund', () => { + sinon.assert.notCalled(refundCreateStub); + }); + }); + + describe('errors', () => { + it('throws if subscription has no latest_invoice', async () => { + const subWithoutInvoice = { + ...mockProratedSubscription, + latest_invoice: null, + }; + await expect( + planCanceller.attemptProratedRefund(subWithoutInvoice as any) + ).rejects.toThrow('No latest invoice'); + }); + + it('throws if invoice is not paid', async () => { + invoiceRetrieveStub.resolves({ + ...mockProratedInvoice, + paid: false, + }); + await expect( + planCanceller.attemptProratedRefund(mockProratedSubscription as any) + ).rejects.toThrow('Customer is pending renewal'); + }); + + it('throws if refund amount exceeds amount paid', async () => { + const mockSmallInvoice = { + ...mockProratedInvoice, + amount_due: 0, + }; + invoiceRetrieveStub.resolves(mockSmallInvoice); + await expect( + planCanceller.attemptProratedRefund(mockProratedSubscription as any) + ).rejects.toThrow('eclipse the amount due'); + }); + + it('throws if invoice has no charge for Stripe refund', async () => { + invoiceRetrieveStub.resolves({ + ...mockProratedInvoice, + charge: null, + }); + await expect( + planCanceller.attemptProratedRefund(mockProratedSubscription as any) + ).rejects.toThrow('No charge'); + }); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts b/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts new file mode 100644 index 00000000000..d2859375eed --- /dev/null +++ b/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts @@ -0,0 +1,605 @@ +/* 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 Container from 'typedi'; + +import { ConfigType } from '../../config'; +import { AppConfig, AuthFirestore } from '../../lib/types'; + +import { FirestoreStripeSyncChecker } from './check-firestore-stripe-sync'; +import Stripe from 'stripe'; +import { StripeHelper } from '../../lib/payments/stripe'; + +import customer1 from '../../test/local/payments/fixtures/stripe/customer1.json'; +import subscription1 from '../../test/local/payments/fixtures/stripe/subscription1.json'; + +const mockCustomer = customer1 as unknown as Stripe.Customer; +const mockSubscription = subscription1 as unknown as Stripe.Subscription; + +const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, +} as unknown as ConfigType; + +describe('FirestoreStripeSyncChecker', () => { + let syncChecker: FirestoreStripeSyncChecker; + let stripeStub: Stripe; + let stripeHelperStub: StripeHelper; + let firestoreStub: any; + let logStub: any; + + beforeEach(() => { + firestoreStub = { + collection: sinon.stub().returns({ + doc: sinon.stub().returns({ + get: sinon.stub(), + }), + }), + }; + + Container.set(AuthFirestore, firestoreStub); + Container.set(AppConfig, mockConfig); + + stripeStub = { + on: sinon.stub(), + customers: { + list: sinon.stub(), + update: sinon.stub(), + }, + } as unknown as Stripe; + + stripeHelperStub = { + stripe: stripeStub, + } as unknown as StripeHelper; + + logStub = { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + }; + + syncChecker = new FirestoreStripeSyncChecker(stripeHelperStub, 20, logStub); + }); + + afterEach(() => { + Container.reset(); + }); + + describe('run', () => { + let autoPagingEachStub: sinon.SinonStub; + let checkCustomerSyncStub: sinon.SinonStub; + + beforeEach(async () => { + autoPagingEachStub = sinon.stub().callsFake(async (callback: any) => { + await callback(mockCustomer); + }); + + stripeStub.customers.list = sinon.stub().returns({ + autoPagingEach: autoPagingEachStub, + }) as any; + + checkCustomerSyncStub = sinon.stub().resolves(); + syncChecker.checkCustomerSync = checkCustomerSyncStub; + + await syncChecker.run(); + }); + + it('calls Stripe customers.list', () => { + sinon.assert.calledWith(stripeStub.customers.list as any, { + limit: 25, + }); + }); + + it('calls autoPagingEach to iterate through all customers', () => { + sinon.assert.calledOnce(autoPagingEachStub); + }); + + it('checks sync for each customer', () => { + sinon.assert.calledOnce(checkCustomerSyncStub); + sinon.assert.calledWith(checkCustomerSyncStub, mockCustomer); + }); + + it('logs summary', () => { + sinon.assert.calledWith( + logStub.info, + 'firestore-stripe-sync-check-complete', + sinon.match.object + ); + }); + }); + + describe('checkCustomerSync', () => { + let checkSubscriptionSyncStub: sinon.SinonStub; + + beforeEach(() => { + checkSubscriptionSyncStub = sinon.stub().resolves(); + }); + + describe('customer in sync', () => { + const mockFirestoreCustomer = Object.assign({}, mockCustomer); + + beforeEach(async () => { + const collectionStub = sinon.stub().returns({ + doc: sinon.stub().returns({ + get: sinon.stub().resolves({ + exists: true, + data: sinon.stub().returns(mockFirestoreCustomer), + }), + collection: sinon.stub().returns({ + doc: sinon.stub().returns({ + get: sinon.stub().resolves({ + exists: true, + data: sinon.stub().returns({ status: 'active' }), + }), + }), + }), + }), + }); + + firestoreStub.collection = collectionStub; + Container.set(AuthFirestore, firestoreStub); + + stripeStub.subscriptions = { + list: sinon.stub().resolves({ + data: [mockSubscription], + }), + } as any; + + syncChecker = new FirestoreStripeSyncChecker( + stripeHelperStub, + 20, + logStub + ); + syncChecker.checkSubscriptionSync = checkSubscriptionSyncStub; + + await syncChecker.checkCustomerSync(mockCustomer); + }); + + it('checks subscription sync', () => { + sinon.assert.calledWith( + checkSubscriptionSyncStub, + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('does not log out of sync', () => { + sinon.assert.notCalled(logStub.warn); + }); + }); + + describe('customer missing in Firestore', () => { + let handleOutOfSyncStub: sinon.SinonStub; + + beforeEach(async () => { + const collectionStub = sinon.stub().returns({ + doc: sinon.stub().returns({ + get: sinon.stub().resolves({ + exists: false, + }), + }), + }); + + firestoreStub.collection = collectionStub; + Container.set(AuthFirestore, firestoreStub); + + handleOutOfSyncStub = sinon.stub(); + + syncChecker = new FirestoreStripeSyncChecker( + stripeHelperStub, + 20, + logStub + ); + syncChecker.handleOutOfSync = handleOutOfSyncStub; + + await syncChecker.checkCustomerSync(mockCustomer); + }); + + it('handles out of sync', () => { + sinon.assert.calledWith( + handleOutOfSyncStub, + mockCustomer.id, + 'Customer exists in Stripe but not in Firestore', + 'customer_missing' + ); + }); + }); + + describe('customer metadata mismatch', () => { + let handleOutOfSyncStub: sinon.SinonStub; + const mismatchedFirestoreCustomer = { + email: 'different@example.com', + created: mockCustomer.created, + }; + + beforeEach(async () => { + const collectionStub = sinon.stub().returns({ + doc: sinon.stub().returns({ + get: sinon.stub().resolves({ + exists: true, + data: sinon.stub().returns(mismatchedFirestoreCustomer), + }), + }), + }); + + firestoreStub.collection = collectionStub; + Container.set(AuthFirestore, firestoreStub); + + handleOutOfSyncStub = sinon.stub(); + + syncChecker = new FirestoreStripeSyncChecker( + stripeHelperStub, + 20, + logStub + ); + syncChecker.handleOutOfSync = handleOutOfSyncStub; + + await syncChecker.checkCustomerSync(mockCustomer); + }); + + it('handles out of sync', () => { + sinon.assert.calledWith( + handleOutOfSyncStub, + mockCustomer.id, + 'Customer mismatch', + 'customer_mismatch' + ); + }); + }); + + describe('deleted customer', () => { + beforeEach(async () => { + const deletedCustomer = { + id: mockCustomer.id, + deleted: true, + }; + + await syncChecker.checkCustomerSync(deletedCustomer as any); + }); + + it('skips deleted customers', () => { + expect(syncChecker['customersCheckedCount']).toBe(0); + }); + }); + + describe('error checking customer', () => { + beforeEach(async () => { + firestoreStub.collection = sinon.stub().returns({ + doc: sinon.stub().throws(new Error('Firestore error')), + }); + + await syncChecker.checkCustomerSync(mockCustomer); + }); + + it('logs error', () => { + sinon.assert.calledWith( + logStub.error, + 'error-checking-customer', + sinon.match.object + ); + }); + }); + }); + + describe('checkSubscriptionSync', () => { + let handleOutOfSyncStub: sinon.SinonStub; + + const mockFirestoreSubscription = Object.assign({}, mockSubscription); + + beforeEach(() => { + handleOutOfSyncStub = sinon.stub(); + syncChecker.handleOutOfSync = handleOutOfSyncStub; + }); + + describe('subscription in sync', () => { + beforeEach(async () => { + const collectionStub = sinon.stub().returns({ + doc: sinon.stub().returns({ + collection: sinon.stub().returns({ + doc: sinon.stub().returns({ + get: sinon.stub().resolves({ + exists: true, + data: sinon.stub().returns(mockFirestoreSubscription), + }), + }), + }), + }), + }); + + firestoreStub.collection = collectionStub; + Container.set(AuthFirestore, firestoreStub); + + // Recreate syncChecker with new firestore stub + syncChecker = new FirestoreStripeSyncChecker( + stripeHelperStub, + 20, + logStub + ); + syncChecker.handleOutOfSync = handleOutOfSyncStub; + + await syncChecker.checkSubscriptionSync( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('does not call handleOutOfSync', () => { + sinon.assert.notCalled(handleOutOfSyncStub); + }); + }); + + describe('subscription missing in Firestore', () => { + beforeEach(async () => { + const collectionStub = sinon.stub().returns({ + doc: sinon.stub().returns({ + collection: sinon.stub().returns({ + doc: sinon.stub().returns({ + get: sinon.stub().resolves({ + exists: false, + }), + }), + }), + }), + }); + + firestoreStub.collection = collectionStub; + Container.set(AuthFirestore, firestoreStub); + + // Recreate syncChecker with new firestore stub + syncChecker = new FirestoreStripeSyncChecker( + stripeHelperStub, + 20, + logStub + ); + syncChecker.handleOutOfSync = handleOutOfSyncStub; + + await syncChecker.checkSubscriptionSync( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('handles out of sync', () => { + sinon.assert.calledWith( + handleOutOfSyncStub, + mockCustomer.id, + 'Subscription exists in Stripe but not in Firestore', + 'subscription_missing', + mockSubscription.id + ); + }); + }); + + describe('subscription data mismatch', () => { + beforeEach(async () => { + const mismatchedSubscription = { + ...mockFirestoreSubscription, + status: 'canceled', + }; + + const collectionStub = sinon.stub().returns({ + doc: sinon.stub().returns({ + collection: sinon.stub().returns({ + doc: sinon.stub().returns({ + get: sinon.stub().resolves({ + exists: true, + data: sinon.stub().returns(mismatchedSubscription), + }), + }), + }), + }), + }); + + firestoreStub.collection = collectionStub; + Container.set(AuthFirestore, firestoreStub); + + // Recreate syncChecker with new firestore stub + syncChecker = new FirestoreStripeSyncChecker( + stripeHelperStub, + 20, + logStub + ); + syncChecker.handleOutOfSync = handleOutOfSyncStub; + + await syncChecker.checkSubscriptionSync( + mockCustomer.id, + mockCustomer.metadata.userid, + mockSubscription + ); + }); + + it('handles out of sync', () => { + sinon.assert.calledWith( + handleOutOfSyncStub, + mockCustomer.id, + 'Subscription data mismatch', + 'subscription_mismatch', + mockSubscription.id + ); + }); + }); + }); + + describe('isCustomerInSync', () => { + it('returns true when customer data matches', () => { + const firestoreCustomer = Object.assign({}, mockCustomer); + + const result = syncChecker.isCustomerInSync( + firestoreCustomer, + mockCustomer + ); + expect(result).toBe(true); + }); + + it('returns false when email differs', () => { + const firestoreCustomer = { + email: 'different@example.com', + created: mockCustomer.created, + }; + + const result = syncChecker.isCustomerInSync( + firestoreCustomer, + mockCustomer + ); + expect(result).toBe(false); + }); + + it('returns false when created timestamp differs', () => { + const firestoreCustomer = { + email: mockCustomer.email, + created: 999999, + }; + + const result = syncChecker.isCustomerInSync( + firestoreCustomer, + mockCustomer + ); + expect(result).toBe(false); + }); + }); + + describe('isSubscriptionInSync', () => { + it('returns true when subscription data matches', () => { + const firestoreSubscription = Object.assign({}, mockSubscription); + + const result = syncChecker.isSubscriptionInSync( + firestoreSubscription, + mockSubscription + ); + expect(result).toBe(true); + }); + + it('returns false when status differs', () => { + const firestoreSubscription = { + status: 'canceled', + current_period_end: mockSubscription.current_period_end, + current_period_start: mockSubscription.current_period_start, + }; + + const result = syncChecker.isSubscriptionInSync( + firestoreSubscription, + mockSubscription + ); + expect(result).toBe(false); + }); + + it('returns false when period end differs', () => { + const firestoreSubscription = { + status: mockSubscription.status, + current_period_end: 999999, + current_period_start: mockSubscription.current_period_start, + }; + + const result = syncChecker.isSubscriptionInSync( + firestoreSubscription, + mockSubscription + ); + expect(result).toBe(false); + }); + }); + + describe('handleOutOfSync', () => { + let triggerResyncStub: sinon.SinonStub; + + beforeEach(() => { + triggerResyncStub = sinon.stub().resolves(); + syncChecker.triggerResync = triggerResyncStub; + }); + + it('increments out of sync counter', () => { + const initialCount = syncChecker['outOfSyncCount']; + syncChecker.handleOutOfSync( + mockCustomer.id, + 'Test reason', + 'customer_missing' + ); + expect(syncChecker['outOfSyncCount']).toBe(initialCount + 1); + }); + + it('increments customer missing counter', () => { + const initialCount = syncChecker['customersMissingInFirestore']; + syncChecker.handleOutOfSync( + mockCustomer.id, + 'Test reason', + 'customer_missing' + ); + expect(syncChecker['customersMissingInFirestore']).toBe(initialCount + 1); + }); + + it('increments subscription missing counter', () => { + const initialCount = syncChecker['subscriptionsMissingInFirestore']; + syncChecker.handleOutOfSync( + mockCustomer.id, + 'Test reason', + 'subscription_missing', + mockSubscription.id + ); + expect(syncChecker['subscriptionsMissingInFirestore']).toBe( + initialCount + 1 + ); + }); + + it('logs out-of-sync warning', () => { + syncChecker.handleOutOfSync( + mockCustomer.id, + 'Test reason', + 'customer_missing', + mockSubscription.id + ); + + sinon.assert.calledWith(logStub.warn, 'firestore-stripe-out-of-sync', { + customerId: mockCustomer.id, + subscriptionId: mockSubscription.id, + reason: 'Test reason', + type: 'customer_missing', + }); + }); + + it('triggers resync', () => { + syncChecker.handleOutOfSync( + mockCustomer.id, + 'Test reason', + 'customer_missing' + ); + sinon.assert.calledWith(triggerResyncStub, mockCustomer.id); + }); + }); + + describe('triggerResync', () => { + it('updates customer metadata with forcedResyncAt', async () => { + stripeStub.customers.update = sinon.stub().resolves(); + + await syncChecker.triggerResync(mockCustomer.id); + + sinon.assert.calledWith( + stripeStub.customers.update as any, + mockCustomer.id, + sinon.match({ + metadata: { + forcedResyncAt: sinon.match.string, + }, + }) + ); + }); + + it('logs error on failure', async () => { + stripeStub.customers.update = sinon + .stub() + .rejects(new Error('Update failed')); + + await syncChecker.triggerResync(mockCustomer.id); + + sinon.assert.calledWith( + logStub.error, + 'failed-to-trigger-resync', + sinon.match.object + ); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts b/packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts new file mode 100644 index 00000000000..99a1bfc07c9 --- /dev/null +++ b/packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts @@ -0,0 +1,89 @@ +/* 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 { CartCleanup } from './cleanup-old-carts'; + +describe('CartCleanup', () => { + let cartCleanup: CartCleanup; + let dbStub: { + deleteFrom: sinon.SinonSpy; + where: sinon.SinonSpy; + execute: sinon.SinonSpy; + updateTable: sinon.SinonSpy; + set: sinon.SinonSpy; + }; + + const deleteBefore = new Date('2024-01-01T00:00:00Z'); + const anonymizeBefore = new Date('2023-06-01T00:00:00Z'); + const anonymizeFields = new Set(['taxAddress'] as const); + + beforeEach(() => { + dbStub = { + deleteFrom: sinon.stub().returnsThis(), + where: sinon.stub().returnsThis(), + execute: sinon.stub().resolves(), + updateTable: sinon.stub().returnsThis(), + set: sinon.stub().returnsThis(), + }; + + cartCleanup = new CartCleanup( + deleteBefore, + anonymizeBefore, + anonymizeFields, + dbStub as any + ); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('run', () => { + it('deletes old carts', async () => { + await cartCleanup.run(); + + expect(dbStub.deleteFrom.calledWith('carts')).toBe(true); + expect( + dbStub.where.calledWith('updatedAt', '<', deleteBefore.getTime()) + ).toBe(true); + expect(dbStub.execute.called).toBe(true); + }); + + it('anonymizes fields within carts', async () => { + await cartCleanup.run(); + + expect(dbStub.updateTable.calledWith('carts')).toBe(true); + expect( + dbStub.where.calledWith('updatedAt', '<', anonymizeBefore.getTime()) + ).toBe(true); + expect(dbStub.set.calledWith('taxAddress', null)).toBe(true); + expect(dbStub.execute.calledTwice).toBe(true); + }); + + it('does not anonymize if no fields are provided', async () => { + cartCleanup = new CartCleanup( + deleteBefore, + anonymizeBefore, + new Set(), + dbStub as any + ); + await cartCleanup.run(); + + expect(dbStub.updateTable.called).toBe(false); + }); + + it('does not anonymize if anonymizeBefore is null', async () => { + cartCleanup = new CartCleanup( + deleteBefore, + null, + anonymizeFields, + dbStub as any + ); + await cartCleanup.run(); + + expect(dbStub.updateTable.called).toBe(false); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts new file mode 100644 index 00000000000..7a27b33042e --- /dev/null +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts @@ -0,0 +1,655 @@ +/* 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 Container from 'typedi'; +import fs from 'fs'; + +import { ConfigType } from '../../config'; +import { AppConfig, AuthFirestore } from '../../lib/types'; + +import { StripeAutomaticTaxConverter } from './convert-customers-to-stripe-automatic-tax'; +import { + FirestoreSubscription, + IpAddressMapFileEntry, + StripeAutomaticTaxConverterHelpers, +} from './helpers'; +import Stripe from 'stripe'; +import { StripeHelper } from '../../lib/payments/stripe'; + +import plan1 from '../../test/local/payments/fixtures/stripe/plan1.json'; +import product1 from '../../test/local/payments/fixtures/stripe/product1.json'; +import customer1 from '../../test/local/payments/fixtures/stripe/customer1.json'; +import subscription1 from '../../test/local/payments/fixtures/stripe/subscription1.json'; +import invoicePreviewTax from '../../test/local/payments/fixtures/stripe/invoice_preview_tax.json'; + +const mockPlan = plan1 as unknown as Stripe.Plan; +const mockProduct = product1 as unknown as Stripe.Product; +const mockCustomer = customer1 as unknown as Stripe.Customer; +const mockSubscription = subscription1 as unknown as FirestoreSubscription; +const mockInvoicePreview = invoicePreviewTax as unknown as Stripe.Invoice; + +const mockAccount = { + locale: 'en-US', +}; + +const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + playApiServiceAccount: { + credentials: { + clientEmail: 'mock-client-email', + }, + keyFile: 'mock-private-keyfile', + }, + productConfigsFirestore: { + schemaValidation: { + cdnUrlRegex: ['^http'], + }, + }, + }, +} as unknown as ConfigType; + +describe('StripeAutomaticTaxConverter', () => { + let stripeAutomaticTaxConverter: StripeAutomaticTaxConverter; + let helperStub: sinon.SinonStubbedInstance; + let stripeStub: Stripe; + let stripeHelperStub: StripeHelper; + let dbStub: any; + let geodbStub: sinon.SinonStub; + let firestoreGetStub: sinon.SinonStub; + let mockIpAddressMapping: IpAddressMapFileEntry[]; + let readFileSyncStub: sinon.SinonStub; + + beforeEach(() => { + mockIpAddressMapping = [ + { + uid: 'mock-uid', + remote_address_chain: '1.1.1.1', + }, + ]; + readFileSyncStub = sinon + .stub(fs, 'readFileSync') + .returns(JSON.stringify(mockIpAddressMapping)); + + firestoreGetStub = sinon.stub(); + Container.set(AuthFirestore, { + collectionGroup: sinon.stub().returns({ + where: sinon.stub().returnsThis(), + orderBy: sinon.stub().returnsThis(), + startAfter: sinon.stub().returnsThis(), + limit: sinon.stub().returnsThis(), + get: firestoreGetStub, + }), + }); + + Container.set(AppConfig, mockConfig); + + helperStub = sinon.createStubInstance(StripeAutomaticTaxConverterHelpers); + Container.set(StripeAutomaticTaxConverterHelpers, helperStub); + + helperStub.processIPAddressList.returns({ + [mockIpAddressMapping[0].uid]: + mockIpAddressMapping[0].remote_address_chain, + }); + + geodbStub = sinon.stub(); + + stripeStub = { + on: sinon.stub(), + products: {}, + customers: {}, + subscriptions: {}, + invoices: {}, + } as unknown as Stripe; + + stripeHelperStub = { + stripe: stripeStub, + currencyHelper: { + isCurrencyCompatibleWithCountry: sinon.stub(), + }, + } as unknown as StripeHelper; + + dbStub = { + account: sinon.stub(), + }; + + stripeAutomaticTaxConverter = new StripeAutomaticTaxConverter( + geodbStub, + 100, + './stripe-automatic-tax-converter.tmp.csv', + './stripe-automatic-tax-converter-ipaddresses.tmp.json', + stripeHelperStub, + 20, + dbStub + ); + }); + + afterEach(() => { + readFileSyncStub.restore(); + Container.reset(); + }); + + describe('convert', () => { + let fetchSubsBatchStub: sinon.SinonStub; + let generateReportForSubscriptionStub: sinon.SinonStub; + const mockSubs = [mockSubscription]; + + beforeEach(async () => { + fetchSubsBatchStub = sinon + .stub() + .onFirstCall() + .returns(mockSubs) + .onSecondCall() + .returns([]); + stripeAutomaticTaxConverter.fetchSubsBatch = fetchSubsBatchStub as any; + + generateReportForSubscriptionStub = sinon.stub(); + stripeAutomaticTaxConverter.generateReportForSubscription = + generateReportForSubscriptionStub as any; + + helperStub.filterEligibleSubscriptions.callsFake( + (subscriptions) => subscriptions + ); + + await stripeAutomaticTaxConverter.convert(); + }); + + it('fetches subscriptions until no results', () => { + expect(fetchSubsBatchStub.callCount).toBe(2); + }); + + it('filters ineligible subscriptions', () => { + expect(helperStub.filterEligibleSubscriptions.callCount).toBe(2); + expect(helperStub.filterEligibleSubscriptions.calledWith(mockSubs)).toBe( + true + ); + }); + + it('generates a report for each applicable subscription', () => { + expect(generateReportForSubscriptionStub.callCount).toBe(1); + }); + }); + + describe('fetchSubsBatch', () => { + const mockSubscriptionId = 'mock-id'; + let result: FirestoreSubscription[]; + + beforeEach(async () => { + firestoreGetStub.resolves({ + docs: [ + { + data: sinon.stub().returns(mockSubscription), + }, + ], + }); + + result = + await stripeAutomaticTaxConverter.fetchSubsBatch(mockSubscriptionId); + }); + + it('returns a list of subscriptions from Firestore', () => { + sinon.assert.match(result, [mockSubscription]); + }); + }); + + describe('generateReportForSubscription', () => { + const mockFirestoreSub = { + id: 'test', + customer: 'test', + plan: { + product: 'example-product', + }, + } as FirestoreSubscription; + const mockReport = ['mock-report']; + let logStub: sinon.SinonStub; + let enableTaxForCustomer: sinon.SinonStub; + let enableTaxForSubscription: sinon.SinonStub; + let fetchInvoicePreview: sinon.SinonStub; + let buildReport: sinon.SinonStub; + let writeReportStub: sinon.SinonStub; + + beforeEach(async () => { + (stripeStub.products as any).retrieve = sinon + .stub() + .resolves(mockProduct); + fetchInvoicePreview = sinon.stub(); + stripeAutomaticTaxConverter.fetchInvoicePreview = + fetchInvoicePreview as any; + stripeAutomaticTaxConverter.fetchCustomer = sinon + .stub() + .resolves(mockCustomer) as any; + dbStub.account.resolves({ + locale: 'en-US', + }); + enableTaxForCustomer = sinon.stub().resolves(true); + stripeAutomaticTaxConverter.enableTaxForCustomer = + enableTaxForCustomer as any; + stripeAutomaticTaxConverter.isExcludedSubscriptionProduct = sinon + .stub() + .returns(false) as any; + enableTaxForSubscription = sinon.stub().resolves(); + stripeAutomaticTaxConverter.enableTaxForSubscription = + enableTaxForSubscription as any; + fetchInvoicePreview = sinon + .stub() + .onFirstCall() + .resolves({ + ...mockInvoicePreview, + total: (mockInvoicePreview as any).total - 1, + }) + .onSecondCall() + .resolves(mockInvoicePreview); + stripeAutomaticTaxConverter.fetchInvoicePreview = + fetchInvoicePreview as any; + buildReport = sinon.stub().returns(mockReport); + stripeAutomaticTaxConverter.buildReport = buildReport as any; + writeReportStub = sinon.stub().resolves(); + stripeAutomaticTaxConverter.writeReport = writeReportStub as any; + logStub = sinon.stub(console, 'log'); + }); + + afterEach(() => { + logStub.restore(); + }); + + describe('success', () => { + beforeEach(async () => { + await stripeAutomaticTaxConverter.generateReportForSubscription( + mockFirestoreSub + ); + }); + + it('enables stripe tax for customer', () => { + expect(enableTaxForCustomer.calledWith(mockCustomer)).toBe(true); + }); + + it('enables stripe tax for subscription', () => { + expect(enableTaxForSubscription.calledWith(mockFirestoreSub.id)).toBe( + true + ); + }); + + it('fetches an invoice preview', () => { + expect(fetchInvoicePreview.calledWith(mockFirestoreSub.id)).toBe(true); + }); + + it('writes the report to disk', () => { + expect(writeReportStub.calledWith(mockReport)).toBe(true); + }); + }); + + describe('invalid', () => { + it('aborts if customer does not exist', async () => { + stripeAutomaticTaxConverter.fetchCustomer = sinon + .stub() + .resolves(null) as any; + await stripeAutomaticTaxConverter.generateReportForSubscription( + mockFirestoreSub + ); + + expect(enableTaxForCustomer.notCalled).toBe(true); + expect(enableTaxForSubscription.notCalled).toBe(true); + expect(writeReportStub.notCalled).toBe(true); + }); + + it('aborts if account for customer does not exist', async () => { + dbStub.account.resolves(null); + await stripeAutomaticTaxConverter.generateReportForSubscription( + mockFirestoreSub + ); + + expect(enableTaxForCustomer.notCalled).toBe(true); + expect(enableTaxForSubscription.notCalled).toBe(true); + expect(writeReportStub.notCalled).toBe(true); + }); + + it('aborts if customer is not taxable', async () => { + stripeAutomaticTaxConverter.enableTaxForCustomer = sinon + .stub() + .resolves(false) as any; + await stripeAutomaticTaxConverter.generateReportForSubscription( + mockFirestoreSub + ); + + expect(enableTaxForCustomer.notCalled).toBe(true); + expect(enableTaxForSubscription.notCalled).toBe(true); + expect(writeReportStub.notCalled).toBe(true); + }); + + it('does not save report to CSV if total has not changed', async () => { + stripeAutomaticTaxConverter.fetchInvoicePreview = sinon + .stub() + .resolves(mockInvoicePreview) as any; + await stripeAutomaticTaxConverter.generateReportForSubscription( + mockFirestoreSub + ); + + expect(enableTaxForCustomer.called).toBe(true); + expect(enableTaxForSubscription.called).toBe(true); + expect(writeReportStub.notCalled).toBe(true); + }); + + it('does not update subscription for ineligible product', async () => { + stripeAutomaticTaxConverter.isExcludedSubscriptionProduct = sinon + .stub() + .returns(true) as any; + await stripeAutomaticTaxConverter.generateReportForSubscription( + mockFirestoreSub + ); + + expect(enableTaxForCustomer.notCalled).toBe(true); + expect(enableTaxForSubscription.notCalled).toBe(true); + expect(writeReportStub.notCalled).toBe(true); + }); + }); + }); + + describe('fetchCustomer', () => { + let customerRetrieveStub: sinon.SinonStub; + let result: Stripe.Customer | null; + + describe('customer exists', () => { + beforeEach(async () => { + customerRetrieveStub = sinon.stub().resolves(mockCustomer); + stripeStub.customers.retrieve = customerRetrieveStub as any; + + result = await stripeAutomaticTaxConverter.fetchCustomer( + mockCustomer.id + ); + }); + + it('fetches customer from Stripe', () => { + expect( + customerRetrieveStub.calledWith(mockCustomer.id, { + expand: ['tax'], + }) + ).toBe(true); + }); + + it('returns customer', () => { + sinon.assert.match(result, mockCustomer); + }); + }); + + describe('customer deleted', () => { + beforeEach(async () => { + const deletedCustomer = { + ...mockCustomer, + deleted: true, + }; + customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + stripeStub.customers.retrieve = customerRetrieveStub as any; + + result = await stripeAutomaticTaxConverter.fetchCustomer( + mockCustomer.id + ); + }); + + it('returns null', () => { + sinon.assert.match(result, null); + }); + }); + }); + + describe('enableTaxForCustomer', () => { + let updateStub: sinon.SinonStub; + let result: boolean; + + describe('tax already enabled', () => { + beforeEach(async () => { + helperStub.isTaxEligible.returns(true); + updateStub = sinon.stub().resolves(mockCustomer); + stripeStub.customers.update = updateStub as any; + + result = + await stripeAutomaticTaxConverter.enableTaxForCustomer(mockCustomer); + }); + + it('does not update customer', () => { + expect(updateStub.notCalled).toBe(true); + }); + + it('returns true', () => { + expect(result).toBe(true); + }); + }); + + describe('tax not enabled', () => { + beforeEach(async () => { + helperStub.isTaxEligible + .onFirstCall() + .returns(false) + .onSecondCall() + .returns(true); + updateStub = sinon.stub().resolves(mockCustomer); + stripeStub.customers.update = updateStub as any; + stripeAutomaticTaxConverter.fetchCustomer = sinon + .stub() + .resolves(mockCustomer) as any; + }); + + describe("invalid IP address, can't resolve geolocation", () => { + beforeEach(async () => { + geodbStub.returns({}); + + result = await stripeAutomaticTaxConverter.enableTaxForCustomer({ + ...mockCustomer, + metadata: { + userid: mockIpAddressMapping[0].uid, + }, + } as any); + }); + + it('does not update customer', () => { + expect(updateStub.notCalled).toBe(true); + }); + + it('returns false', () => { + expect(result).toBe(false); + }); + }); + + describe("invalid IP address, isn't in same country", () => { + beforeEach(async () => { + geodbStub.returns({ + postalCode: 'ABC', + countryCode: 'ZZZ', + }); + + ( + stripeHelperStub.currencyHelper as any + ).isCurrencyCompatibleWithCountry = sinon.stub().returns(false); + + result = await stripeAutomaticTaxConverter.enableTaxForCustomer({ + ...mockCustomer, + metadata: { + userid: mockIpAddressMapping[0].uid, + }, + } as any); + }); + + it('does not update customer', () => { + expect(updateStub.notCalled).toBe(true); + }); + + it('returns false', () => { + expect(result).toBe(false); + }); + }); + + describe('valid IP address', () => { + beforeEach(async () => { + geodbStub.returns({ + countryCode: 'US', + postalCode: 92841, + }); + + ( + stripeHelperStub.currencyHelper as any + ).isCurrencyCompatibleWithCountry = sinon.stub().returns(true); + + result = await stripeAutomaticTaxConverter.enableTaxForCustomer({ + ...mockCustomer, + metadata: { + userid: mockIpAddressMapping[0].uid, + }, + } as any); + }); + + it('updates customer', () => { + expect( + updateStub.calledWith(mockCustomer.id, { + shipping: { + name: mockCustomer.email, + address: { + country: 'US', + postal_code: 92841, + }, + }, + }) + ).toBe(true); + }); + + it('returns true', () => { + expect(result).toBe(true); + }); + }); + }); + }); + + describe('isEligibleSubscriptionProduct', () => { + let result: boolean; + const VALID_PRODUCT = 'valid'; + const EXCLUDED_PRODUCT = 'prod_HEJ13uxjG4Rj6L'; + + it('returns false if the product is not excluded', () => { + result = + stripeAutomaticTaxConverter.isExcludedSubscriptionProduct( + VALID_PRODUCT + ); + expect(result).toBe(false); + }); + + it('returns true if the product is meant to be excluded', () => { + result = + stripeAutomaticTaxConverter.isExcludedSubscriptionProduct( + EXCLUDED_PRODUCT + ); + expect(result).toBe(true); + }); + }); + + describe('enableTaxForSubscription', () => { + let updateStub: sinon.SinonStub; + let retrieveStub: sinon.SinonStub; + + beforeEach(async () => { + updateStub = sinon.stub().resolves(mockSubscription); + stripeStub.subscriptions.update = updateStub as any; + retrieveStub = sinon.stub().resolves(mockSubscription); + stripeStub.subscriptions.retrieve = retrieveStub as any; + + await stripeAutomaticTaxConverter.enableTaxForSubscription( + mockSubscription.id + ); + }); + + it('updates the subscription', () => { + expect( + updateStub.calledWith(mockSubscription.id, { + automatic_tax: { + enabled: true, + }, + proration_behavior: 'none', + items: [ + { + id: mockSubscription.items.data[0].id, + tax_rates: '', + }, + ], + default_tax_rates: '', + }) + ).toBe(true); + }); + }); + + describe('fetchInvoicePreview', () => { + let result: Stripe.Response; + let stub: sinon.SinonStub; + + beforeEach(async () => { + stub = sinon.stub().resolves(mockInvoicePreview); + stripeStub.invoices.retrieveUpcoming = stub as any; + + result = await stripeAutomaticTaxConverter.fetchInvoicePreview( + mockSubscription.id + ); + }); + + it('calls stripe for the invoice preview', () => { + expect( + stub.calledWith({ + subscription: mockSubscription.id, + expand: ['total_tax_amounts.tax_rate'], + }) + ).toBe(true); + }); + + it('returns invoice preview', () => { + sinon.assert.match(result, mockInvoicePreview); + }); + }); + + describe('buildReport', () => { + it('returns a report', () => { + const mockSpecialTaxAmounts = { + hst: 10, + gst: 11, + pst: 12, + qst: 13, + rst: 14, + }; + helperStub.getSpecialTaxAmounts.returns(mockSpecialTaxAmounts); + + // Invoice preview with tax doesn't include total_excluding_tax which we need + const _mockInvoicePreview = { + ...mockInvoicePreview, + total_excluding_tax: 10, + }; + + const result = stripeAutomaticTaxConverter.buildReport( + mockCustomer, + mockAccount, + mockSubscription as any, + mockProduct, + mockPlan, + _mockInvoicePreview as any + ); + + sinon.assert.match(result, [ + mockCustomer.metadata.userid, + `"${mockCustomer.email}"`, + mockProduct.id, + `"${mockProduct.name}"`, + mockPlan.id, + `"${mockPlan.nickname}"`, + mockPlan.interval_count, + mockPlan.interval, + (_mockInvoicePreview as any).total_excluding_tax, + (_mockInvoicePreview as any).tax, + mockSpecialTaxAmounts.hst, + mockSpecialTaxAmounts.gst, + mockSpecialTaxAmounts.pst, + mockSpecialTaxAmounts.qst, + mockSpecialTaxAmounts.rst, + (_mockInvoicePreview as any).total, + mockSubscription.current_period_end, + `"${mockAccount.locale}"`, + ]); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts new file mode 100644 index 00000000000..56ee84a89fe --- /dev/null +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts @@ -0,0 +1,363 @@ +/* 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 { + FirestoreSubscription, + StripeAutomaticTaxConverterHelpers, +} from './helpers'; +import Stripe from 'stripe'; + +import customer1 from '../../test/local/payments/fixtures/stripe/customer1.json'; +import subscription1 from '../../test/local/payments/fixtures/stripe/subscription1.json'; + +const mockCustomer = customer1 as unknown as Stripe.Customer; +const mockSubscription = subscription1 as unknown as FirestoreSubscription; + +describe('StripeAutomaticTaxConverterHelpers', () => { + let helpers: StripeAutomaticTaxConverterHelpers; + + beforeEach(() => { + helpers = new StripeAutomaticTaxConverterHelpers(); + }); + + describe('processIPAddressList', () => { + it('converts IP address mapping to internal mapping', () => { + const result = helpers.processIPAddressList([ + { + uid: 'example-uid', + remote_address_chain: '["1.1.1.1"]', + }, + { + uid: 'example-uid-2', + remote_address_chain: '["8.8.8.8"]', + }, + { + uid: 'example-uid-3', + remote_address_chain: '["10.0.0.1", "1.1.1.1"]', + }, + ]); + + const expected = { + 'example-uid': '1.1.1.1', + 'example-uid-2': '8.8.8.8', + }; + + sinon.assert.match(result, expected); + }); + }); + + describe('getClientIPFromRemoteAddressChain', () => { + it('returns the first IP address when it is non-local', () => { + sinon.assert.match( + helpers.getClientIPFromRemoteAddressChain('["1.1.1.1","8.8.8.8"]'), + '1.1.1.1' + ); + }); + + it('returns undefined if first IP address is local', () => { + sinon.assert.match( + helpers.getClientIPFromRemoteAddressChain('["192.168.1.1", "1.1.1.1"]'), + undefined + ); + }); + + it('returns undefined if address chain is empty', () => { + sinon.assert.match( + helpers.getClientIPFromRemoteAddressChain('[]'), + undefined + ); + }); + }); + + describe('isLocalIP', () => { + it('returns true for class A', () => { + expect(helpers.isLocalIP('10.0.0.1')).toBe(true); + }); + + it('returns true for class B', () => { + expect(helpers.isLocalIP('172.16.0.1')).toBe(true); + }); + + it('returns true for class C', () => { + expect(helpers.isLocalIP('192.168.0.1')).toBe(true); + }); + + it('returns false for non-local IP', () => { + expect(helpers.isLocalIP('1.1.1.1')).toBe(false); + }); + }); + + describe('isTaxEligible', () => { + it('returns true for supported customer', () => { + const customer = { + ...mockCustomer, + tax: { + ip_address: null, + location: null, + automatic_tax: 'supported' as Stripe.Customer.Tax.AutomaticTax, + }, + }; + + const result = helpers.isTaxEligible(customer); + + expect(result).toBe(true); + }); + + it('returns true for not_collecting customer', () => { + const customer = { + ...mockCustomer, + tax: { + ip_address: null, + location: null, + automatic_tax: 'not_collecting' as Stripe.Customer.Tax.AutomaticTax, + }, + }; + + const result = helpers.isTaxEligible(customer); + + expect(result).toBe(true); + }); + + it('returns false for unrecognized_location customer', () => { + const customer = { + ...mockCustomer, + tax: { + ip_address: null, + location: null, + automatic_tax: + 'unrecognized_location' as Stripe.Customer.Tax.AutomaticTax, + }, + }; + + const result = helpers.isTaxEligible(customer); + + expect(result).toBe(false); + }); + + it('returns false for failed customer', () => { + const customer = { + ...mockCustomer, + tax: { + ip_address: null, + location: null, + automatic_tax: 'failed' as Stripe.Customer.Tax.AutomaticTax, + }, + }; + + const result = helpers.isTaxEligible(customer); + + expect(result).toBe(false); + }); + }); + + describe('filterEligibleSubscriptions', () => { + let willBeRenewed: sinon.SinonStub; + let isStripeTaxDisabled: sinon.SinonStub; + let isWithinNoticePeriod: sinon.SinonStub; + let subscriptions: FirestoreSubscription[]; + let result: FirestoreSubscription[]; + + beforeEach(() => { + willBeRenewed = sinon.stub().returns(true); + helpers.willBeRenewed = willBeRenewed; + isStripeTaxDisabled = sinon.stub().returns(true); + helpers.isStripeTaxDisabled = isStripeTaxDisabled; + isWithinNoticePeriod = sinon.stub().returns(true); + helpers.isWithinNoticePeriod = isWithinNoticePeriod; + + subscriptions = [mockSubscription]; + + result = helpers.filterEligibleSubscriptions(subscriptions); + }); + + it('filters via helper methods', () => { + expect(willBeRenewed.calledWith(subscription1)).toBe(true); + expect(isStripeTaxDisabled.calledWith(subscription1)).toBe(true); + expect(isWithinNoticePeriod.calledWith(subscription1)).toBe(true); + }); + + it('returns filtered results', () => { + sinon.assert.match(result, subscriptions); + }); + }); + + describe('willBeRenewed', () => { + it('returns false when subscription is cancelled', () => { + const result = helpers.willBeRenewed({ + ...mockSubscription, + cancel_at: 10, + }); + + expect(result).toBe(false); + }); + + it('returns false when subscription is cancelled at period end', () => { + const result = helpers.willBeRenewed({ + ...mockSubscription, + cancel_at_period_end: true, + }); + + expect(result).toBe(false); + }); + + it('returns false when subscription status is not active', () => { + const result = helpers.willBeRenewed({ + ...mockSubscription, + status: 'canceled', + }); + + expect(result).toBe(false); + }); + + it('returns true when subscription will be renewed', () => { + const result = helpers.willBeRenewed(mockSubscription); + + expect(result).toBe(true); + }); + }); + + describe('isStripeTaxDisabled', () => { + it('returns true when stripe tax is disabled', () => { + const result = helpers.isStripeTaxDisabled({ + ...mockSubscription, + automatic_tax: { + enabled: false, + liability: null, + }, + }); + + expect(result).toBe(true); + }); + + it('returns false when stripe tax is enabled', () => { + const result = helpers.isStripeTaxDisabled({ + ...mockSubscription, + automatic_tax: { + enabled: true, + liability: null, + }, + }); + + expect(result).toBe(false); + }); + }); + + describe('isWithinNoticePeriod', () => { + const fakeToday = new Date('2020-01-01'); + + const monthlySub = { + ...mockSubscription, + items: { + ...mockSubscription.items, + data: [ + { + ...mockSubscription.items.data[0], + plan: { + ...mockSubscription.items.data[0].plan, + interval: 'month' as Stripe.Plan.Interval, + }, + }, + ], + }, + }; + + const yearlySub = { + ...mockSubscription, + items: { + ...mockSubscription.items, + data: [ + { + ...mockSubscription.items.data[0], + plan: { + ...mockSubscription.items.data[0].plan, + interval: 'year' as Stripe.Plan.Interval, + }, + }, + ], + }, + }; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(fakeToday.getTime()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('returns true for yearly when more than 30 days out', () => { + const periodEnd = new Date(fakeToday); + periodEnd.setUTCDate(periodEnd.getUTCDate() + 31); + yearlySub.current_period_end = periodEnd.getTime() / 1000; + + const result = helpers.isWithinNoticePeriod(yearlySub); + + expect(result).toBe(true); + }); + + it('returns false for yearly when less than 30 days out', () => { + const periodEnd = new Date(fakeToday); + periodEnd.setUTCDate(periodEnd.getUTCDate() + 29); + yearlySub.current_period_end = periodEnd.getTime() / 1000; + + const result = helpers.isWithinNoticePeriod(yearlySub); + + expect(result).toBe(false); + }); + + it('returns true for monthly when more than 14 days out', () => { + const periodEnd = new Date(fakeToday); + periodEnd.setUTCDate(periodEnd.getUTCDate() + 15); + monthlySub.current_period_end = periodEnd.getTime() / 1000; + + const result = helpers.isWithinNoticePeriod(monthlySub); + + expect(result).toBe(true); + }); + + it('returns false for monthly when less than 14 days out', () => { + const periodEnd = new Date(fakeToday); + periodEnd.setUTCDate(periodEnd.getUTCDate() + 13); + monthlySub.current_period_end = periodEnd.getTime() / 1000; + + const result = helpers.isWithinNoticePeriod(monthlySub); + + expect(result).toBe(false); + }); + }); + + describe('getSpecialTaxAmounts', () => { + const getMockTaxAmount = (amount: number, display_name: string) => + ({ + amount, + inclusive: false, + tax_rate: { + display_name, + }, + }) as Stripe.Invoice.TotalTaxAmount; + + const mockTaxAmounts = [ + getMockTaxAmount(10, 'HST'), + getMockTaxAmount(11, 'PST'), + getMockTaxAmount(12, 'GST'), + getMockTaxAmount(13, 'QST'), + getMockTaxAmount(14, 'RST'), + ]; + + it('formats special tax amounts', () => { + const result = helpers.getSpecialTaxAmounts(mockTaxAmounts); + + sinon.assert.match(result, { + hst: 10, + pst: 11, + gst: 12, + qst: 13, + rst: 14, + }); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts b/packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts new file mode 100644 index 00000000000..48ed6bd8deb --- /dev/null +++ b/packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts @@ -0,0 +1,306 @@ +/* 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 * as lib from './lib'; + +describe('delete inactive accounts script lib', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('setDateToUTC', () => { + it('should set to beginning of day n UTC', () => { + const date = new Date('2021-12-22T00:00:00.000-08:00'); + const utcDate = lib.setDateToUTC(date.valueOf()); + expect(utcDate.toISOString()).toBe('2021-12-22T00:00:00.000Z'); + }); + }); + + describe('token checks', () => { + describe('session tokens', () => { + const ts = Date.now(); + + it('should be true when there is one recent enough session token', async () => { + const tokensFn = sandbox.stub().resolves([{ lastAccessTime: ts }]); + const newerTsActual = await lib.hasActiveSessionToken( + tokensFn, + '9001', + ts - 1000 + ); + expect(newerTsActual).toBe(true); + sinon.assert.calledOnceWithExactly(tokensFn, '9001'); + + const equallyNewActual = await lib.hasActiveSessionToken( + tokensFn, + '9001', + ts + ); + expect(equallyNewActual).toBe(true); + }); + it('should be true when there are multiple recent enough session tokens', async () => { + const tokensFn = sandbox + .stub() + .resolves([ + { lastAccessTime: ts - 9000 }, + { lastAccessTime: ts }, + { lastAccessTime: ts + 9000 }, + ]); + const actual = await lib.hasActiveSessionToken( + tokensFn, + '9001', + ts - 1000 + ); + expect(actual).toBe(true); + }); + it('should be false when there are no recent enough session tokens', async () => { + const noTokensFn = sandbox.stub().resolves([]); + const noTokensActual = await lib.hasActiveSessionToken( + noTokensFn, + '9001', + ts + ); + expect(noTokensActual).toBe(false); + + const noTimestampTokensFn = sandbox.stub().resolves([{ uid: '9001' }]); + const noTimestampTokensActual = await lib.hasActiveSessionToken( + noTimestampTokensFn, + '9001', + ts + ); + expect(noTimestampTokensActual).toBe(false); + + const noRecentEnoughTokensFn = sandbox + .stub() + .resolves([{ lastAccessTime: ts }]); + const noRecentEnoughTokensActual = await lib.hasActiveSessionToken( + noRecentEnoughTokensFn, + '9001', + ts + 1000 + ); + expect(noRecentEnoughTokensActual).toBe(false); + }); + }); + + describe('refresh token', () => { + const ts = Date.now(); + + it('should be true when there is a recent enough refresh token', async () => { + const tokensFn = sandbox.stub().resolves([{ lastUsedAt: ts }]); + const newerTsActual = await lib.hasActiveRefreshToken( + tokensFn, + '9001', + ts - 1000 + ); + expect(newerTsActual).toBe(true); + sinon.assert.calledOnceWithExactly(tokensFn, '9001'); + + const equallyNewActual = await lib.hasActiveRefreshToken( + tokensFn, + '9001', + ts + ); + expect(equallyNewActual).toBe(true); + }); + it('should be true when there are multiple recent enough refresh tokens', async () => { + const tokensFn = sandbox + .stub() + .resolves([ + { lastUsedAt: ts - 9000 }, + { lastUsedAt: ts }, + { lastUsedAt: ts + 9000 }, + ]); + const actual = await lib.hasActiveRefreshToken( + tokensFn, + '9001', + ts - 1000 + ); + expect(actual).toBe(true); + }); + it('should be false when there are no recent enough refresh tokens', async () => { + const noTokensFn = sandbox.stub().resolves([]); + const noTokensActual = await lib.hasActiveRefreshToken( + noTokensFn, + '9001', + ts + ); + expect(noTokensActual).toBe(false); + + const noTimestampTokensFn = sandbox.stub().resolves([{ uid: '9001' }]); + const noTimestampTokensActual = await lib.hasActiveRefreshToken( + noTimestampTokensFn, + '9001', + ts + ); + expect(noTimestampTokensActual).toBe(false); + + const noRecentEnoughTokensFn = sandbox + .stub() + .resolves([{ lastUsedAt: ts }]); + const noRecentEnoughTokensActual = await lib.hasActiveRefreshToken( + noRecentEnoughTokensFn, + '9001', + ts + 1000 + ); + expect(noRecentEnoughTokensActual).toBe(false); + }); + }); + describe('access token', () => { + it('should be true when there is an access token', async () => { + const tokensFn = sandbox.stub().resolves([{}, {}]); + const actual = await lib.hasAccessToken(tokensFn, '9001'); + sinon.assert.calledOnceWithExactly(tokensFn, '9001'); + expect(actual).toBe(true); + }); + it('should be false when there are no access tokens', async () => { + const tokensFn = sandbox.stub().resolves([]); + const actual = await lib.hasAccessToken(tokensFn, '9001'); + expect(actual).toBe(false); + }); + }); + }); + + describe('inActive function builder', () => { + let sessionTokensFn: sinon.SinonStub; + let refreshTokensFn: sinon.SinonStub; + let accessTokensFn: sinon.SinonStub; + let iapSubscriptionFn: sinon.SinonStub; + + beforeEach(() => { + sessionTokensFn = sandbox.stub(); + refreshTokensFn = sandbox.stub(); + accessTokensFn = sandbox.stub(); + iapSubscriptionFn = sandbox.stub(); + }); + + it('should throw an error if the active session token function is missing', async () => { + const builder = new lib.IsActiveFnBuilder(); + try { + await ( + builder + .setRefreshTokenFn(refreshTokensFn) + .setAccessTokenFn(accessTokensFn) + .build() as any + )(); + throw new Error('should have thrown'); + } catch (actual) { + expect(actual).toBeInstanceOf(Error); + } + }); + it('should throw an error if the active refresh token function is missing', async () => { + const builder = new lib.IsActiveFnBuilder(); + try { + await ( + builder + .setActiveSessionTokenFn(sessionTokensFn) + .setAccessTokenFn(accessTokensFn) + .build() as any + )(); + throw new Error('should have thrown'); + } catch (actual) { + expect(actual).toBeInstanceOf(Error); + } + }); + it('should throw an error if the has access token token function is missing', async () => { + const builder = new lib.IsActiveFnBuilder(); + try { + await ( + builder + .setActiveSessionTokenFn(sessionTokensFn) + .setRefreshTokenFn(refreshTokensFn) + .build() as any + )(); + throw new Error('should have thrown'); + } catch (actual) { + expect(actual).toBeInstanceOf(Error); + } + }); + it('should throw an error if the has IAP subscription function is missing', async () => { + const builder = new lib.IsActiveFnBuilder(); + try { + await ( + builder + .setActiveSessionTokenFn(sessionTokensFn) + .setRefreshTokenFn(refreshTokensFn) + .setAccessTokenFn(accessTokensFn) + .build() as any + )(); + throw new Error('should have thrown'); + } catch (actual) { + expect(actual).toBeInstanceOf(Error); + } + }); + + describe('short-circuits on the first active result', () => { + let isActive: (uid: string) => Promise; + + beforeEach(() => { + const builder = new lib.IsActiveFnBuilder(); + isActive = builder + .setActiveSessionTokenFn(sessionTokensFn) + .setRefreshTokenFn(refreshTokensFn) + .setAccessTokenFn(accessTokensFn) + .build(); + }); + + it('should short-circuit with session token check', async () => { + sessionTokensFn.resolves(true); + const actual = await isActive('9001'); + expect(actual).toBe(true); + sinon.assert.calledOnceWithExactly(sessionTokensFn, '9001'); + sinon.assert.notCalled(refreshTokensFn); + sinon.assert.notCalled(accessTokensFn); + sinon.assert.notCalled(iapSubscriptionFn); + }); + + it('should short-circuit with refresh token check', async () => { + sessionTokensFn.resolves(false); + refreshTokensFn.resolves(true); + const actual = await isActive('9001'); + expect(actual).toBe(true); + sinon.assert.calledOnceWithExactly(sessionTokensFn, '9001'); + sinon.assert.calledOnceWithExactly(refreshTokensFn, '9001'); + sinon.assert.notCalled(accessTokensFn); + sinon.assert.notCalled(iapSubscriptionFn); + }); + + it('should short-circuit with access token check', async () => { + sessionTokensFn.resolves(false); + refreshTokensFn.resolves(false); + accessTokensFn.resolves(true); + const actual = await isActive('9001'); + expect(actual).toBe(true); + sinon.assert.calledOnceWithExactly(sessionTokensFn, '9001'); + sinon.assert.calledOnceWithExactly(refreshTokensFn, '9001'); + sinon.assert.calledOnceWithExactly(accessTokensFn, '9001'); + sinon.assert.notCalled(iapSubscriptionFn); + }); + }); + + it('should be false when all condition functions are false', async () => { + const builder = new lib.IsActiveFnBuilder(); + const isActive = builder + .setActiveSessionTokenFn(sessionTokensFn) + .setRefreshTokenFn(refreshTokensFn) + .setAccessTokenFn(accessTokensFn) + .build(); + sessionTokensFn.resolves(false); + refreshTokensFn.resolves(false); + accessTokensFn.resolves(false); + iapSubscriptionFn.resolves(false); + + const actual = await isActive('9001'); + expect(actual).toBe(false); + sinon.assert.calledOnceWithExactly(sessionTokensFn, '9001'); + sinon.assert.calledOnceWithExactly(refreshTokensFn, '9001'); + sinon.assert.calledOnceWithExactly(accessTokensFn, '9001'); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts b/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts new file mode 100644 index 00000000000..7e5e9ce42b1 --- /dev/null +++ b/packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts @@ -0,0 +1,1053 @@ +/* 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 { CustomerPlanMover } from './move-customers-to-new-plan-v2'; +import Stripe from 'stripe'; +import { PayPalHelper } from '../../lib/payments/paypal'; + +import customer1 from '../../test/local/payments/fixtures/stripe/customer1.json'; +import subscription1 from '../../test/local/payments/fixtures/stripe/subscription1.json'; +import invoicePaid from '../../test/local/payments/fixtures/stripe/invoice_paid.json'; + +const mockCustomer = customer1 as unknown as Stripe.Customer; +const mockSubscription = subscription1 as unknown as Stripe.Subscription; +const mockInvoice = invoicePaid as unknown as Stripe.Invoice; +const mockPrice = { + id: 'destination-price-id', + currency: 'usd', + unit_amount: 999, +} as unknown as Stripe.Price; + +describe('CustomerPlanMover v2', () => { + let customerPlanMover: CustomerPlanMover; + let stripeStub: Stripe; + let paypalHelperStub: PayPalHelper; + + beforeEach(() => { + stripeStub = { + on: sinon.stub(), + products: {}, + customers: {}, + subscriptions: {}, + invoices: {}, + refunds: {}, + } as unknown as Stripe; + + paypalHelperStub = { + refundInvoice: sinon.stub(), + } as unknown as PayPalHelper; + + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + ['exclude-price-id'], + './move-customers-to-new-plan-output.tmp.csv', + stripeStub, + false, + 20, + null, + null, + 'none', + false, + false, + paypalHelperStub + ); + }); + + describe('constructor', () => { + it('throws error if proratedRefundRate is less than or equal to zero', () => { + expect(() => { + void new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + 0, + null, + 'none', + false, + false, + paypalHelperStub + ); + }).toThrow('proratedRefundRate must be greater than zero'); + }); + + it('does not throw error if proratedRefundRate is null', () => { + expect(() => { + void new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + null, + null, + 'none', + false, + false, + paypalHelperStub + ); + }).not.toThrow(); + }); + + it('does not throw error if proratedRefundRate is positive', () => { + expect(() => { + void new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + 100, + null, + 'none', + false, + false, + paypalHelperStub + ); + }).not.toThrow(); + }); + }); + + describe('convert', () => { + let convertSubscriptionStub: sinon.SinonStub; + let writeReportHeaderStub: sinon.SinonStub; + + beforeEach(async () => { + // Mock the async iterable returned by stripe.subscriptions.list + const asyncIterable = { + async *[Symbol.asyncIterator]() { + // Empty generator for testing setup + }, + }; + + stripeStub.subscriptions.list = sinon + .stub() + .returns(asyncIterable) as any; + + stripeStub.prices = { + retrieve: sinon.stub().resolves(mockPrice), + } as any; + + writeReportHeaderStub = sinon.stub().resolves(); + customerPlanMover.writeReportHeader = writeReportHeaderStub; + + convertSubscriptionStub = sinon.stub().resolves(); + customerPlanMover.convertSubscription = convertSubscriptionStub; + + await customerPlanMover.convert(); + }); + + it('writes report header', () => { + expect(writeReportHeaderStub.calledOnce).toBe(true); + }); + + it('lists subscriptions with source price id', () => { + expect( + (stripeStub.subscriptions.list as sinon.SinonStub).calledWith({ + price: 'source-price-id', + limit: 100, + }) + ).toBe(true); + }); + }); + + describe('convertSubscription', () => { + const mockStripeSubscription = { + id: 'sub_123', + customer: 'cus_123', + status: 'active', + items: { + data: [ + { + id: 'si_123', + plan: { + id: 'price_123', + }, + }, + ], + }, + } as unknown as Stripe.Subscription; + + let logStub: sinon.SinonStub; + let errorStub: sinon.SinonStub; + let fetchCustomerStub: sinon.SinonStub; + let isCustomerExcludedStub: sinon.SinonStub; + let writeReportStub: sinon.SinonStub; + + beforeEach(() => { + logStub = sinon.stub(console, 'log'); + errorStub = sinon.stub(console, 'error'); + fetchCustomerStub = sinon.stub().resolves({ + ...mockCustomer, + subscriptions: { + data: [mockStripeSubscription], + }, + }); + customerPlanMover.fetchCustomer = fetchCustomerStub; + + isCustomerExcludedStub = sinon.stub().returns(false); + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + + writeReportStub = sinon.stub().resolves(); + customerPlanMover.writeReport = writeReportStub; + }); + + afterEach(() => { + logStub.restore(); + errorStub.restore(); + }); + + describe('success - not excluded', () => { + beforeEach(async () => { + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('fetches customer', () => { + expect(fetchCustomerStub.calledWith('cus_123')).toBe(true); + }); + + it('updates subscription to destination price', () => { + expect( + (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( + 'sub_123', + sinon.match({ + items: [ + { + id: 'si_123', + price: 'destination-price-id', + }, + ], + discounts: undefined, + proration_behavior: 'none', + billing_cycle_anchor: 'unchanged', + }) + ) + ).toBe(true); + }); + + it('writes report', () => { + expect(writeReportStub.calledOnce).toBe(true); + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.subscription.id).toBe('sub_123'); + expect(reportArgs.isExcluded).toBe(false); + expect(reportArgs.amountRefunded).toBe(null); + expect(reportArgs.approximateAmountWasOwed).toBe(null); + expect(reportArgs.daysUntilNextBill).toBe(null); + expect(reportArgs.daysSinceLastBill).toBe(null); + expect(reportArgs.previousInvoiceAmountDue).toBe(null); + expect(reportArgs.isOwed).toBe(false); + expect(reportArgs.error).toBe(false); + }); + }); + + describe('success - with coupon', () => { + beforeEach(async () => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + null, + 'test-coupon', + 'none', + false, + false, + paypalHelperStub + ); + customerPlanMover.fetchCustomer = fetchCustomerStub; + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + customerPlanMover.writeReport = writeReportStub; + + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('applies coupon to subscription', () => { + expect( + (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( + 'sub_123', + sinon.match({ + items: [ + { + id: 'si_123', + price: 'destination-price-id', + }, + ], + discounts: [{ coupon: 'test-coupon' }], + proration_behavior: 'none', + billing_cycle_anchor: 'unchanged', + }) + ) + ).toBe(true); + }); + }); + + describe('success - with proration behavior', () => { + beforeEach(async () => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + null, + null, + 'create_prorations', + false, + false, + paypalHelperStub + ); + customerPlanMover.fetchCustomer = fetchCustomerStub; + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + customerPlanMover.writeReport = writeReportStub; + + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('uses specified proration behavior', () => { + expect( + (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( + 'sub_123', + sinon.match({ + items: [ + { + id: 'si_123', + price: 'destination-price-id', + }, + ], + discounts: undefined, + proration_behavior: 'create_prorations', + billing_cycle_anchor: 'unchanged', + }) + ) + ).toBe(true); + }); + }); + + describe('success - with reset billing cycle anchor', () => { + beforeEach(async () => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + null, + null, + 'none', + false, + true, + paypalHelperStub + ); + customerPlanMover.fetchCustomer = fetchCustomerStub; + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + customerPlanMover.writeReport = writeReportStub; + + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('sets billing_cycle_anchor to "now"', () => { + expect( + (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( + 'sub_123', + sinon.match({ + billing_cycle_anchor: 'now', + }) + ) + ).toBe(true); + }); + }); + + describe('success - without reset billing cycle anchor', () => { + beforeEach(async () => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + null, + null, + 'none', + false, + false, + paypalHelperStub + ); + customerPlanMover.fetchCustomer = fetchCustomerStub; + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + customerPlanMover.writeReport = writeReportStub; + + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('sets billing_cycle_anchor to "unchanged"', () => { + expect( + (stripeStub.subscriptions.update as sinon.SinonStub).calledWith( + 'sub_123', + sinon.match({ + billing_cycle_anchor: 'unchanged', + }) + ) + ).toBe(true); + }); + }); + + describe('success - with prorated refund', () => { + let attemptRefundStub: sinon.SinonStub; + + beforeEach(async () => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + 100, + null, + 'none', + false, + false, + paypalHelperStub + ); + customerPlanMover.fetchCustomer = fetchCustomerStub; + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + customerPlanMover.writeReport = writeReportStub; + + attemptRefundStub = sinon.stub().resolves(500); + customerPlanMover.attemptRefund = attemptRefundStub; + + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('attempts refund', () => { + expect(attemptRefundStub.calledWith(mockStripeSubscription)).toBe(true); + }); + + it('writes report with refund amount', () => { + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.amountRefunded).toBe(500); + expect(reportArgs.isOwed).toBe(false); + expect(reportArgs.error).toBe(false); + }); + }); + + describe('refund failure', () => { + let attemptRefundStub: sinon.SinonStub; + + beforeEach(async () => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + 100, + null, + 'none', + false, + false, + paypalHelperStub + ); + customerPlanMover.fetchCustomer = fetchCustomerStub; + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + customerPlanMover.writeReport = writeReportStub; + + attemptRefundStub = sinon.stub().rejects(new Error('Refund failed')); + customerPlanMover.attemptRefund = attemptRefundStub; + + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('marks customer as owed', () => { + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.isOwed).toBe(true); + expect(reportArgs.amountRefunded).toBe(null); + expect(reportArgs.error).toBe(false); + }); + }); + + describe('dry run', () => { + beforeEach(async () => { + customerPlanMover.dryRun = true; + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('does not update subscription', () => { + expect( + (stripeStub.subscriptions.update as sinon.SinonStub).notCalled + ).toBe(true); + }); + + it('still writes report', () => { + expect(writeReportStub.calledOnce).toBe(true); + }); + }); + + describe('customer excluded', () => { + beforeEach(async () => { + isCustomerExcludedStub.returns(true); + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + }); + + it('does not update subscription', () => { + expect( + (stripeStub.subscriptions.update as sinon.SinonStub).notCalled + ).toBe(true); + }); + + it('writes report marking as excluded', () => { + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.isExcluded).toBe(true); + expect(reportArgs.error).toBe(false); + expect(reportArgs.amountRefunded).toBe(null); + expect(reportArgs.isOwed).toBe(false); + }); + }); + + describe('subscription set to cancel', () => { + beforeEach(async () => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + null, + null, + 'none', + true, + false, + paypalHelperStub + ); + customerPlanMover.fetchCustomer = fetchCustomerStub; + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + customerPlanMover.writeReport = writeReportStub; + + stripeStub.subscriptions.update = sinon + .stub() + .resolves(mockStripeSubscription); + + const subscriptionSetToCancel = { + ...mockStripeSubscription, + cancel_at_period_end: true, + } as Stripe.Subscription; + + await customerPlanMover.convertSubscription( + subscriptionSetToCancel, + mockPrice + ); + }); + + it('does not update subscription', () => { + expect( + (stripeStub.subscriptions.update as sinon.SinonStub).notCalled + ).toBe(true); + }); + + it('does not write report', () => { + expect(writeReportStub.notCalled).toBe(true); + }); + + it('logs skip message', () => { + expect( + logStub.calledWith( + sinon.match(/Skipping subscription.*set to cancel/) + ) + ).toBe(true); + }); + }); + + describe('invalid', () => { + it('writes error report if subscription is not active', async () => { + const inactiveSubscription = { + ...mockStripeSubscription, + status: 'canceled', + } as Stripe.Subscription; + + await customerPlanMover.convertSubscription( + inactiveSubscription, + mockPrice + ); + + expect(writeReportStub.calledOnce).toBe(true); + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.customer).toBe(null); + expect(reportArgs.error).toBe(true); + expect(reportArgs.isOwed).toBe(false); + expect(reportArgs.isExcluded).toBe(false); + }); + + it('writes error report if customer does not exist', async () => { + customerPlanMover.fetchCustomer = sinon.stub().resolves(null); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + + expect(writeReportStub.calledOnce).toBe(true); + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.customer).toBe(null); + expect(reportArgs.error).toBe(true); + expect(reportArgs.isOwed).toBe(false); + expect(reportArgs.isExcluded).toBe(false); + }); + + it('writes error report if customer has no subscriptions data', async () => { + customerPlanMover.fetchCustomer = sinon.stub().resolves({ + ...mockCustomer, + subscriptions: undefined, + }); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + + expect(writeReportStub.calledOnce).toBe(true); + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.error).toBe(true); + expect(reportArgs.isOwed).toBe(false); + expect(reportArgs.isExcluded).toBe(false); + }); + + it('writes error report if subscription update fails', async () => { + stripeStub.subscriptions.update = sinon + .stub() + .rejects(new Error('Update failed')); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + + expect(writeReportStub.calledOnce).toBe(true); + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.error).toBe(true); + expect(reportArgs.isOwed).toBe(false); + expect(reportArgs.isExcluded).toBe(false); + }); + + it('writes error report if unexpected error occurs', async () => { + customerPlanMover.fetchCustomer = sinon + .stub() + .rejects(new Error('Unexpected error')); + + await customerPlanMover.convertSubscription( + mockStripeSubscription, + mockPrice + ); + + expect(writeReportStub.calledOnce).toBe(true); + const reportArgs = writeReportStub.firstCall.args[0]; + expect(reportArgs.error).toBe(true); + expect(reportArgs.customer).toBe(null); + expect(reportArgs.isOwed).toBe(false); + expect(reportArgs.isExcluded).toBe(false); + }); + }); + }); + + describe('fetchCustomer', () => { + let customerRetrieveStub: sinon.SinonStub; + let result: Stripe.Customer | Stripe.DeletedCustomer | null; + + describe('customer exists', () => { + beforeEach(async () => { + customerRetrieveStub = sinon.stub().resolves(mockCustomer); + stripeStub.customers.retrieve = customerRetrieveStub; + + result = await customerPlanMover.fetchCustomer(mockCustomer.id); + }); + + it('fetches customer from Stripe with subscriptions expanded', () => { + expect( + customerRetrieveStub.calledWith(mockCustomer.id, { + expand: ['subscriptions'], + }) + ).toBe(true); + }); + + it('returns customer', () => { + sinon.assert.match(result, mockCustomer); + }); + }); + + describe('customer deleted', () => { + beforeEach(async () => { + const deletedCustomer = { + ...mockCustomer, + deleted: true, + }; + customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + stripeStub.customers.retrieve = customerRetrieveStub; + + result = await customerPlanMover.fetchCustomer(mockCustomer.id); + }); + + it('returns null', () => { + sinon.assert.match(result, null); + }); + }); + }); + + describe('attemptRefund', () => { + const now = Math.floor(Date.now() / 1000); + const mockSubscriptionWithInvoice = { + ...mockSubscription, + id: 'sub_123', + latest_invoice: 'inv_123', + current_period_start: now - 86400 * 25, + current_period_end: now + 86400 * 10, + } as Stripe.Subscription; + + const mockPaidInvoice = { + ...mockInvoice, + id: 'inv_123', + paid: true, + amount_due: 2000, + created: Math.floor(Date.now() / 1000) - 86400 * 5, + charge: 'ch_123', + paid_out_of_band: false, + } as unknown as Stripe.Invoice; + + let enqueueRequestStub: sinon.SinonStub; + let logStub: sinon.SinonStub; + + beforeEach(() => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + 100, + null, + 'none', + false, + false, + paypalHelperStub + ); + + enqueueRequestStub = sinon.stub(); + customerPlanMover.enqueueRequest = enqueueRequestStub; + logStub = sinon.stub(console, 'log'); + }); + + afterEach(() => { + logStub.restore(); + }); + + describe('Stripe refund', () => { + beforeEach(async () => { + enqueueRequestStub.onFirstCall().resolves(mockPaidInvoice); + enqueueRequestStub.onSecondCall().resolves({}); + + await customerPlanMover.attemptRefund(mockSubscriptionWithInvoice); + }); + + it('retrieves invoice', () => { + expect(enqueueRequestStub.calledTwice).toBe(true); + }); + + it('creates refund', () => { + expect(enqueueRequestStub.calledTwice).toBe(true); + }); + }); + + describe('PayPal refund - full', () => { + let calculatedRefundAmount: number; + let mockPayPalInvoice: Stripe.Invoice; + + beforeEach(async () => { + const now = new Date().getTime(); + const nextBillAt = new Date( + mockSubscriptionWithInvoice.current_period_end * 1000 + ); + const timeUntilBillMs = nextBillAt.getTime() - now; + const daysUntilBill = Math.floor( + timeUntilBillMs / (1000 * 60 * 60 * 24) + ); + calculatedRefundAmount = daysUntilBill * 100; + + mockPayPalInvoice = { + ...mockPaidInvoice, + amount_due: calculatedRefundAmount, + paid_out_of_band: true, + } as unknown as Stripe.Invoice; + + enqueueRequestStub.resolves(mockPayPalInvoice); + + await customerPlanMover.attemptRefund(mockSubscriptionWithInvoice); + }); + + it('calls paypalHelper.refundInvoice with full refund', () => { + expect( + (paypalHelperStub.refundInvoice as sinon.SinonStub).calledOnce + ).toBe(true); + const args = (paypalHelperStub.refundInvoice as sinon.SinonStub) + .firstCall.args; + expect(args[1].refundType).toBe('Full'); + }); + }); + + describe('PayPal refund - partial', () => { + let calculatedRefundAmount: number; + let mockPayPalInvoice: Stripe.Invoice; + + beforeEach(async () => { + const now = new Date().getTime(); + const nextBillAt = new Date( + mockSubscriptionWithInvoice.current_period_end * 1000 + ); + const timeUntilBillMs = nextBillAt.getTime() - now; + const daysUntilBill = Math.floor( + timeUntilBillMs / (1000 * 60 * 60 * 24) + ); + calculatedRefundAmount = daysUntilBill * 100; + + mockPayPalInvoice = { + ...mockPaidInvoice, + amount_due: calculatedRefundAmount * 2, + paid_out_of_band: true, + } as unknown as Stripe.Invoice; + + enqueueRequestStub.resolves(mockPayPalInvoice); + + await customerPlanMover.attemptRefund(mockSubscriptionWithInvoice); + }); + + it('calls paypalHelper.refundInvoice with partial refund', () => { + expect( + (paypalHelperStub.refundInvoice as sinon.SinonStub).calledOnce + ).toBe(true); + const args = (paypalHelperStub.refundInvoice as sinon.SinonStub) + .firstCall.args; + expect(args[1].refundType).toBe('Partial'); + expect(args[1].amount).toBe(calculatedRefundAmount); + }); + }); + + describe('dry run', () => { + beforeEach(async () => { + customerPlanMover.dryRun = true; + enqueueRequestStub.resolves(mockPaidInvoice); + + await customerPlanMover.attemptRefund(mockSubscriptionWithInvoice); + }); + + it('does not create refund', () => { + expect(enqueueRequestStub.callCount).toBe(1); // Only invoice retrieval + }); + }); + + describe('errors', () => { + it('throws if proratedRefundRate is not set', async () => { + customerPlanMover = new CustomerPlanMover( + 'source-price-id', + 'destination-price-id', + [], + './output.csv', + stripeStub, + false, + 20, + null, + null, + 'none', + false, + false, + paypalHelperStub + ); + + await expect( + customerPlanMover.attemptRefund(mockSubscriptionWithInvoice) + ).rejects.toThrow('proratedRefundRate must be specified'); + }); + + it('throws if subscription has no latest_invoice', async () => { + const subWithoutInvoice = { + ...mockSubscription, + latest_invoice: null, + } as Stripe.Subscription; + + await expect( + customerPlanMover.attemptRefund(subWithoutInvoice) + ).rejects.toThrow('No latest invoice'); + }); + + it('throws if invoice is not paid', async () => { + const unpaidInvoice = { + ...mockPaidInvoice, + paid: false, + } as Stripe.Invoice; + enqueueRequestStub.resolves(unpaidInvoice); + + await expect( + customerPlanMover.attemptRefund(mockSubscriptionWithInvoice) + ).rejects.toThrow('Customer is pending renewal right now!'); + }); + + it('throws if refund amount exceeds amount paid', async () => { + const oldInvoice = { + ...mockPaidInvoice, + amount_due: 100, + created: Math.floor(Date.now() / 1000) - 86400 * 50, // 50 days ago + } as Stripe.Invoice; + enqueueRequestStub.resolves(oldInvoice); + + await expect( + customerPlanMover.attemptRefund(mockSubscriptionWithInvoice) + ).rejects.toThrow('Will not refund'); + }); + + it('throws if invoice has no charge for Stripe refund', async () => { + const invoiceNoCharge = { + ...mockPaidInvoice, + charge: null, + } as unknown as Stripe.Invoice; + enqueueRequestStub.resolves(invoiceNoCharge); + + await expect( + customerPlanMover.attemptRefund(mockSubscriptionWithInvoice) + ).rejects.toThrow('No charge for'); + }); + }); + }); + + describe('isCustomerExcluded', () => { + it("returns true if the customer has a price that's excluded", () => { + const subscriptions = [ + { + ...mockSubscription, + items: { + data: [ + { + plan: { + id: 'exclude-price-id', + }, + }, + ], + }, + }, + ] as Stripe.Subscription[]; + + const result = customerPlanMover.isCustomerExcluded(subscriptions); + expect(result).toBe(true); + }); + + it("returns false if the customer does not have a price that's excluded", () => { + const subscriptions = [ + { + ...mockSubscription, + items: { + data: [ + { + plan: { + id: 'other-price-id', + }, + }, + ], + }, + }, + ] as Stripe.Subscription[]; + + const result = customerPlanMover.isCustomerExcluded(subscriptions); + expect(result).toBe(false); + }); + + it('returns false for empty subscriptions array', () => { + const result = customerPlanMover.isCustomerExcluded([]); + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts b/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts new file mode 100644 index 00000000000..91f98c35062 --- /dev/null +++ b/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts @@ -0,0 +1,391 @@ +/* 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 Container from 'typedi'; + +import { ConfigType } from '../../config'; +import { AppConfig, AuthFirestore } from '../../lib/types'; + +import { + CustomerPlanMover, + FirestoreSubscription, +} from './move-customers-to-new-plan'; +import Stripe from 'stripe'; +import { StripeHelper } from '../../lib/payments/stripe'; + +import product1 from '../../test/local/payments/fixtures/stripe/product1.json'; +import customer1 from '../../test/local/payments/fixtures/stripe/customer1.json'; +import subscription1 from '../../test/local/payments/fixtures/stripe/subscription1.json'; + +const mockProduct = product1 as unknown as Stripe.Product; +const mockCustomer = customer1 as unknown as Stripe.Customer; +const mockSubscription = subscription1 as unknown as FirestoreSubscription; + +const mockAccount = { + locale: 'en-US', +}; + +const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + playApiServiceAccount: { + credentials: { + clientEmail: 'mock-client-email', + }, + keyFile: 'mock-private-keyfile', + }, + productConfigsFirestore: { + schemaValidation: { + cdnUrlRegex: ['^http'], + }, + }, + }, +} as unknown as ConfigType; + +describe('CustomerPlanMover', () => { + let customerPlanMover: CustomerPlanMover; + let stripeStub: Stripe; + let stripeHelperStub: StripeHelper; + let dbStub: any; + let firestoreGetStub: sinon.SinonStub; + + beforeEach(() => { + firestoreGetStub = sinon.stub(); + Container.set(AuthFirestore, { + collectionGroup: sinon.stub().returns({ + where: sinon.stub().returnsThis(), + orderBy: sinon.stub().returnsThis(), + startAfter: sinon.stub().returnsThis(), + limit: sinon.stub().returnsThis(), + get: firestoreGetStub, + }), + }); + + Container.set(AppConfig, mockConfig); + + stripeStub = { + on: sinon.stub(), + products: {}, + customers: {}, + subscriptions: {}, + invoices: {}, + } as unknown as Stripe; + + stripeHelperStub = { + stripe: stripeStub, + currencyHelper: { + isCurrencyCompatibleWithCountry: sinon.stub(), + }, + } as unknown as StripeHelper; + + dbStub = { + account: sinon.stub(), + }; + + customerPlanMover = new CustomerPlanMover( + 'source', + 'destination', + ['exclude'], + 100, + './move-customers-to-new-plan.tmp.csv', + stripeHelperStub, + dbStub, + false, + 20 + ); + }); + + afterEach(() => { + Container.reset(); + }); + + describe('convert', () => { + let fetchSubsBatchStub: sinon.SinonStub; + let convertSubscriptionStub: sinon.SinonStub; + const mockSubs = [mockSubscription]; + + beforeEach(async () => { + fetchSubsBatchStub = sinon + .stub() + .onFirstCall() + .returns(mockSubs) + .onSecondCall() + .returns([]); + customerPlanMover.fetchSubsBatch = fetchSubsBatchStub; + + convertSubscriptionStub = sinon.stub(); + customerPlanMover.convertSubscription = convertSubscriptionStub; + + await customerPlanMover.convert(); + }); + + it('fetches subscriptions until no results', () => { + expect(fetchSubsBatchStub.callCount).toBe(2); + }); + + it('generates a report for each applicable subscription', () => { + expect(convertSubscriptionStub.callCount).toBe(1); + }); + }); + + describe('fetchSubsBatch', () => { + const mockSubscriptionId = 'mock-id'; + let result: FirestoreSubscription[]; + + beforeEach(async () => { + firestoreGetStub.resolves({ + docs: [ + { + data: sinon.stub().returns(mockSubscription), + }, + ], + }); + + result = await customerPlanMover.fetchSubsBatch(mockSubscriptionId); + }); + + it('returns a list of subscriptions from Firestore', () => { + sinon.assert.match(result, [mockSubscription]); + }); + }); + + describe('convertSubscription', () => { + const mockFirestoreSub = { + id: 'test', + customer: 'test', + plan: { + product: 'example-product', + }, + status: 'active', + } as FirestoreSubscription; + const mockReport = ['mock-report']; + let logStub: sinon.SinonStub; + let cancelSubscriptionStub: sinon.SinonStub; + let createSubscriptionStub: sinon.SinonStub; + let isCustomerExcludedStub: sinon.SinonStub; + let buildReport: sinon.SinonStub; + let writeReportStub: sinon.SinonStub; + + beforeEach(async () => { + stripeStub.products.retrieve = sinon.stub().resolves(mockProduct); + customerPlanMover.fetchCustomer = sinon.stub().resolves(mockCustomer); + dbStub.account.resolves({ + locale: 'en-US', + }); + cancelSubscriptionStub = sinon.stub().resolves(); + customerPlanMover.cancelSubscription = cancelSubscriptionStub; + createSubscriptionStub = sinon.stub().resolves(); + customerPlanMover.createSubscription = createSubscriptionStub; + isCustomerExcludedStub = sinon.stub().returns(false); + customerPlanMover.isCustomerExcluded = isCustomerExcludedStub; + buildReport = sinon.stub().returns(mockReport); + customerPlanMover.buildReport = buildReport; + writeReportStub = sinon.stub().resolves(); + customerPlanMover.writeReport = writeReportStub; + logStub = sinon.stub(console, 'log'); + }); + + afterEach(() => { + logStub.restore(); + }); + + describe('success', () => { + beforeEach(async () => { + await customerPlanMover.convertSubscription(mockFirestoreSub); + }); + + it('cancels old subscription', () => { + expect(cancelSubscriptionStub.calledWith(mockFirestoreSub)).toBe(true); + }); + + it('creates new subscription', () => { + expect(createSubscriptionStub.calledWith(mockCustomer.id)).toBe(true); + }); + + it('writes the report to disk', () => { + expect(writeReportStub.calledWith(mockReport)).toBe(true); + }); + }); + + describe('dry run', () => { + beforeEach(async () => { + customerPlanMover.dryRun = true; + await customerPlanMover.convertSubscription(mockFirestoreSub); + }); + + it('does not cancel old subscription', () => { + expect(cancelSubscriptionStub.calledWith(mockFirestoreSub)).toBe(false); + }); + + it('does not create new subscription', () => { + expect(createSubscriptionStub.calledWith(mockCustomer.id)).toBe(false); + }); + + it('writes the report to disk', () => { + expect(writeReportStub.calledWith(mockReport)).toBe(true); + }); + }); + + describe('invalid', () => { + it('aborts if customer does not exist', async () => { + customerPlanMover.fetchCustomer = sinon.stub().resolves(null); + await customerPlanMover.convertSubscription(mockFirestoreSub); + + expect(writeReportStub.notCalled).toBe(true); + }); + + it('aborts if account for customer does not exist', async () => { + dbStub.account.resolves(null); + await customerPlanMover.convertSubscription(mockFirestoreSub); + + expect(writeReportStub.notCalled).toBe(true); + }); + + it('does not create subscription if customer is excluded', async () => { + customerPlanMover.isCustomerExcluded = sinon.stub().resolves(true); + await customerPlanMover.convertSubscription(mockFirestoreSub); + + expect(createSubscriptionStub.notCalled).toBe(true); + }); + + it('does not cancel subscription if customer is excluded', async () => { + customerPlanMover.isCustomerExcluded = sinon.stub().resolves(true); + await customerPlanMover.convertSubscription(mockFirestoreSub); + + expect(cancelSubscriptionStub.notCalled).toBe(true); + }); + + it('does not move subscription if subscription is not in active state', async () => { + await customerPlanMover.convertSubscription({ + ...mockFirestoreSub, + status: 'canceled', + }); + + expect(cancelSubscriptionStub.notCalled).toBe(true); + expect(createSubscriptionStub.notCalled).toBe(true); + expect(writeReportStub.notCalled).toBe(true); + }); + }); + }); + + describe('fetchCustomer', () => { + let customerRetrieveStub: sinon.SinonStub; + let result: Stripe.Customer | Stripe.DeletedCustomer | null; + + describe('customer exists', () => { + beforeEach(async () => { + customerRetrieveStub = sinon.stub().resolves(mockCustomer); + stripeStub.customers.retrieve = customerRetrieveStub; + + result = await customerPlanMover.fetchCustomer(mockCustomer.id); + }); + + it('fetches customer from Stripe', () => { + expect( + customerRetrieveStub.calledWith(mockCustomer.id, { + expand: ['subscriptions'], + }) + ).toBe(true); + }); + + it('returns customer', () => { + sinon.assert.match(result, mockCustomer); + }); + }); + + describe('customer deleted', () => { + beforeEach(async () => { + const deletedCustomer = { + ...mockCustomer, + deleted: true, + }; + customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + stripeStub.customers.retrieve = customerRetrieveStub; + + result = await customerPlanMover.fetchCustomer(mockCustomer.id); + }); + + it('returns null', () => { + sinon.assert.match(result, null); + }); + }); + }); + + describe('isCustomerExcluded', () => { + it("returns true if the customer has a price that's excluded", () => { + const result = customerPlanMover.isCustomerExcluded([ + { + ...mockSubscription, + items: { + ...mockSubscription.items, + data: [ + { + ...mockSubscription.items.data[0], + plan: { + ...mockSubscription.items.data[0].plan, + id: 'exclude', + }, + }, + ], + }, + }, + ]); + expect(result).toBe(true); + }); + + it("returns false if the customer does not have a price that's excluded", () => { + const result = customerPlanMover.isCustomerExcluded([ + { + // TODO: Either provide full mock, or reduce type required isCustomerExcluded + ...(subscription1 as unknown as Stripe.Subscription), + }, + ]); + expect(result).toBe(false); + }); + }); + + describe('createSubscription', () => { + let createStub: sinon.SinonStub; + + beforeEach(async () => { + createStub = sinon.stub().resolves(mockSubscription); + stripeStub.subscriptions.create = createStub; + + await customerPlanMover.createSubscription(mockCustomer.id); + }); + + it('creates a subscription', () => { + expect( + createStub.calledWith({ + customer: mockCustomer.id, + items: [ + { + price: 'destination', + }, + ], + }) + ).toBe(true); + }); + }); + + describe('buildReport', () => { + it('returns a report', () => { + const result = customerPlanMover.buildReport( + mockCustomer, + mockAccount, + true + ); + + sinon.assert.match(result, [ + mockCustomer.metadata.userid, + `"${mockCustomer.email}"`, + 'true', + `"${mockAccount.locale}"`, + ]); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/prune-tokens.ts b/packages/fxa-auth-server/scripts/prune-tokens.ts index c6c41887ddc..711d849fa8d 100644 --- a/packages/fxa-auth-server/scripts/prune-tokens.ts +++ b/packages/fxa-auth-server/scripts/prune-tokens.ts @@ -31,16 +31,9 @@ const log = require('../lib/log')(config.log.level, 'prune-tokens', statsd); initSentry({ ...config, release: pckg.version }, log); export async function init() { - // Setup utilities - const redis = require('../lib/redis')( - { - ...config.redis, - ...config.redis.sessionTokens, - }, - log - ); - // Parse args + const shouldPrintHelp = + process.argv.includes('--help') || process.argv.includes('-h'); program .version(pckg.version) .option( @@ -97,6 +90,10 @@ Exit Codes: }) .parse(process.argv); + if (shouldPrintHelp) { + return 0; + } + const tokenMaxAge = parseDuration(program.maxTokenAge); const maxTokenAgeWindowSize = program.maxTokenAgeWindowSize; const codeMaxAge = parseDuration(program.maxCodeAge); @@ -245,6 +242,13 @@ Exit Codes: // Clean up redis cache if (accountsImpacted.size > 0) { + const redis = require('../lib/redis')( + { + ...config.redis, + ...config.redis.sessionTokens, + }, + log + ); for (const uid of accountsImpacted) { try { // Pull session tokens from redis, sanity check sizes @@ -288,6 +292,7 @@ Exit Codes: log.err(`error while pruning redis cache for account ${uid}`, err); } } + await redis.close(); } else { log.info('no accounts impacted. skipping redis cache clean up.'); } diff --git a/packages/fxa-auth-server/scripts/recorded-future/lib.spec.ts b/packages/fxa-auth-server/scripts/recorded-future/lib.spec.ts new file mode 100644 index 00000000000..97ab7f9fe6d --- /dev/null +++ b/packages/fxa-auth-server/scripts/recorded-future/lib.spec.ts @@ -0,0 +1,280 @@ +/* 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 * as lib from './lib'; +import { SearchResultIdentity } from './lib'; +import { AppError, ERRNO } from '@fxa/accounts/errors'; + +describe('Recorded Future credentials search and reset script lib', () => { + const payload = { domain: 'login.example.com', limit: 10 }; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('credentials search function', () => { + let client: { POST: sinon.SinonStub }; + + beforeEach(() => { + client = { POST: sandbox.stub() }; + }); + + it('returns the data on success', async () => { + const data = { next_offset: 'letsgoooo' }; + client.POST.resolves({ data }); + const searchFn = lib.createCredentialsSearchFn(client as any); + const res = await searchFn(payload); + + sinon.assert.calledOnceWithExactly( + client.POST, + '/identity/credentials/search', + { body: payload } + ); + expect(res).toEqual(data); + }); + + it('throws the API returned error', async () => { + const error = 'oops'; + client.POST.resolves({ error }); + const searchFn = lib.createCredentialsSearchFn(client as any); + + try { + await searchFn(payload); + throw new Error('should have thrown'); + } catch (err: any) { + expect(err.message).toContain('oops'); + } + }); + }); + + describe('fetch all credentials search results function', () => { + let client: { POST: sinon.SinonStub }; + + beforeEach(() => { + client = { POST: sandbox.stub() }; + }); + + it('fetches all the paginated results', async () => { + const firstResponse = { + identities: ['foo', 'wibble'], + count: payload.limit, + next_offset: 'MOAR', + }; + const secondResponse = { + identities: ['quux', 'bar'], + count: payload.limit - 1, + next_offset: 'MISLEADING_MOAR', + }; + client.POST.onFirstCall() + .resolves({ data: firstResponse }) + .onSecondCall() + .resolves({ data: secondResponse }); + const searchFn = lib.createCredentialsSearchFn(client as any); + + const res = await lib.fetchAllCredentialSearchResults(searchFn, payload); + + sinon.assert.calledTwice(client.POST); + sinon.assert.calledWith(client.POST, '/identity/credentials/search', { + body: payload, + }); + sinon.assert.calledWith(client.POST, '/identity/credentials/search', { + body: { ...payload, offset: firstResponse.next_offset }, + }); + expect(res).toEqual([ + ...firstResponse.identities, + ...secondResponse.identities, + ] as unknown as SearchResultIdentity[]); + }); + }); + + describe('find account function', () => { + it('returns an existing account', async () => { + const accountFn = sandbox.stub().resolves({ uid: '9001' }); + const findAccount = lib.createFindAccountFn(accountFn); + const acct = await findAccount('quux@example.gg'); + + sinon.assert.calledOnceWithExactly(accountFn, 'quux@example.gg'); + expect(acct).toEqual({ uid: '9001' } as any); + }); + + it('returns undefined when no account found', async () => { + const accountFn = sandbox.stub().throws(AppError.unknownAccount()); + const findAccount = lib.createFindAccountFn(accountFn); + + const res = await findAccount('quux@example.gg'); + sinon.assert.calledOnceWithExactly(accountFn, 'quux@example.gg'); + expect(res).toBeUndefined(); + }); + + it('re-throws errors', async () => { + const accountFn = sandbox.stub().throws(AppError.invalidRequestBody()); + const findAccount = lib.createFindAccountFn(accountFn); + + try { + await findAccount('quux@example.gg'); + throw new Error('should have thrown'); + } catch (err: any) { + sinon.assert.calledOnceWithExactly(accountFn, 'quux@example.gg'); + expect(err.errno).toBe(ERRNO.INVALID_JSON); + } + }); + }); + + describe('has totp 2fa function', () => { + it('returns true when TOTP token exists', async () => { + const totpTokenFn = sandbox.stub().resolves(); + const hasTotpToken = lib.createHasTotp2faFn(totpTokenFn); + const res = await hasTotpToken({ uid: '9001' } as any); + + sinon.assert.calledOnceWithExactly(totpTokenFn, '9001'); + expect(res).toBe(true); + }); + + it('returns false when TOTP token not found', async () => { + const totpTokenFn = sandbox.stub().rejects(AppError.totpTokenNotFound()); + const hasTotpToken = lib.createHasTotp2faFn(totpTokenFn); + const res = await hasTotpToken({ uid: '9001' } as any); + + sinon.assert.calledOnceWithExactly(totpTokenFn, '9001'); + expect(res).toBe(false); + }); + + it('re-throws errors', async () => { + const totpTokenFn = sandbox.stub().rejects(AppError.invalidRequestBody()); + const hasTotpToken = lib.createHasTotp2faFn(totpTokenFn); + + try { + await hasTotpToken({ uid: '9001' } as any); + throw new Error('should have thrown'); + } catch (err: any) { + sinon.assert.calledOnceWithExactly(totpTokenFn, '9001'); + expect(err.errno).toBe(ERRNO.INVALID_JSON); + } + }); + }); + + describe('credentials lookup function', () => { + let client: { POST: sinon.SinonStub }; + + beforeEach(() => { + client = { POST: sandbox.stub() }; + }); + + it('returns leaked credentials with cleartext password', async () => { + const expected = [ + { + subject: 'a@b.com', + exposed_secret: { + details: { clear_text_value: 'abc' }, + type: 'clear', + }, + }, + { + subject: 'fizz@bar.gg', + exposed_secret: { + details: { clear_text_value: 'buzz' }, + type: 'clear', + }, + }, + ]; + const filtered = [ + { + subject: 'a@b.com', + exposed_secret: { + details: { clear_text_value: 'abc' }, + type: 'clear', + }, + }, + { + subject: 'x@y.com', + exposed_secret: { + type: 'hash', + }, + }, + ]; + client.POST.resolves({ + data: { + identities: [ + { credentials: [expected[0], filtered[0]] }, + { credentials: [expected[1]] }, + { credentials: [filtered[1]] }, + ], + }, + }); + const lookupFn = lib.createCredentialsLookupFn(client as any); + const subjects = [ + { login: 'a@b.com', domain: 'quux.io' }, + { login: 'x@y.com', domain: 'quux.io' }, + { login: 'fizz@bar.gg', domain: 'quux.io' }, + ]; + const res = await lookupFn(subjects, { + first_downloaded_gte: '2025-04-15', + }); + sinon.assert.calledOnceWithExactly( + client.POST, + '/identity/credentials/lookup', + { + body: { + subjects_login: subjects, + filter: { first_downloaded_gte: '2025-04-15' }, + }, + } + ); + expect(res).toEqual(expected); + }); + + it('limits the subjects login in API call', async () => { + client.POST.resolves({ data: { identities: [] } }); + const lookupFn = lib.createCredentialsLookupFn(client as any); + const subjects = Array(555); + await lookupFn(subjects, { + first_downloaded_gte: '2025-04-15', + }); + + sinon.assert.calledTwice(client.POST); + }); + }); + + describe('verify password function', () => { + it('checks the leaked password', async () => { + const getCredentials = sandbox.stub().resolves({ authPW: 'wibble' }); + const checkPassword = sandbox.stub().resolves({ match: false }); + const verifyHashStub = sandbox.stub().resolves('quux'); + const Password = class { + async verifyHash() { + return verifyHashStub(); + } + }; + const verifyPassword = lib.createVerifyPasswordFn( + Password as any, + checkPassword, + getCredentials + ); + const leakCredentials = { + subject: 'fizz@bar.gg', + exposed_secret: { + details: { clear_text_value: 'buzz' }, + type: 'clear', + }, + }; + const acct = { + uid: '9001', + authSalt: 'pepper', + verifierVersion: 1, + }; + const res = await verifyPassword(leakCredentials, acct as any); + + sinon.assert.calledOnceWithExactly(getCredentials, acct, 'buzz'); + sinon.assert.calledOnce(verifyHashStub); + sinon.assert.calledOnceWithExactly(checkPassword, '9001', 'quux'); + expect(res).toBe(false); + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts new file mode 100644 index 00000000000..612c681690c --- /dev/null +++ b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts @@ -0,0 +1,518 @@ +/* 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 fs from 'fs'; +import { Container } from 'typedi'; + +import { AuthFirestore, AuthLogger, AppConfig } from '../../lib/types'; +import { setupFirestore } from '../../lib/firestore-db'; +import { PaymentConfigManager } from '../../lib/payments/configuration/manager'; +import { ProductConfig } from 'fxa-shared/subscriptions/configuration/product'; +import { PlanConfig } from 'fxa-shared/subscriptions/configuration/plan'; +const plan = require('fxa-auth-server/test/local/payments/fixtures/stripe/plan2.json'); +const product = require('fxa-shared/test/fixtures/stripe/product1.json'); +const { mockLog, mockStripeHelper } = require('../../test/mocks'); + +function deepCopy(object: any) { + return JSON.parse(JSON.stringify(object)); +} + +const GOOGLE_ERROR_MESSAGE = 'Google Translate Error Overload'; +const mockGoogleTranslateShapedError = { + code: 403, + message: GOOGLE_ERROR_MESSAGE, + response: { + request: { + href: 'https://translation.googleapis.com/language/translate/v2/detect', + }, + }, +}; + +jest.mock('./plan-language-tags-guesser', () => { + const sinon = require('sinon'); + const actual = jest.requireActual('./plan-language-tags-guesser'); + return { + ...actual, + getLanguageTagFromPlanMetadata: sinon.stub().callsFake((plan: any) => { + if (plan.nickname.includes('es-ES')) { + return 'es-ES'; + } + if (plan.nickname.includes('fr')) { + return 'fr'; + } + if (plan.nickname === 'localised en plan') { + throw new Error(actual.PLAN_EN_LANG_ERROR); + } + if (plan.nickname === 'you cannot translate this') { + throw mockGoogleTranslateShapedError; + } + return 'en'; + }), + }; +}); + +const { + StripeProductsAndPlansConverter, +} = require('./stripe-products-and-plans-converter'); + +const sandbox = sinon.createSandbox(); + +const mockPaymentConfigManager = { + startListeners: sandbox.stub(), +}; +const mockSupportedLanguages = ['es-ES', 'fr']; + +describe('StripeProductsAndPlansConverter', () => { + let converter: any; + + beforeEach(() => { + mockLog.error = sandbox.fake.returns({}); + mockLog.info = sandbox.fake.returns({}); + mockLog.debug = sandbox.fake.returns({}); + Container.set(PaymentConfigManager, mockPaymentConfigManager); + converter = new StripeProductsAndPlansConverter({ + log: mockLog, + stripeHelper: mockStripeHelper, + supportedLanguages: mockSupportedLanguages, + }); + }); + + afterEach(() => { + sandbox.reset(); + Container.reset(); + }); + + describe('constructor', () => { + it('sets the logger, Stripe helper, supported languages and payment config manager', () => { + expect(converter.log).toBe(mockLog); + expect(converter.stripeHelper).toBe(mockStripeHelper); + expect(converter.supportedLanguages).toEqual( + mockSupportedLanguages.map((l: string) => l.toLowerCase()) + ); + expect(converter.paymentConfigManager).toBe(mockPaymentConfigManager); + }); + }); + + describe('getArrayOfStringsFromMetadataKeys', () => { + it('transforms the data', () => { + const metadata = { + ...deepCopy(product.metadata), + 'product:details:1': 'wow', + 'product:details:2': 'strong', + 'product:details:3': 'recommend', + }; + const metadataPrefix = 'product:details'; + const expected = ['wow', 'strong', 'recommend']; + const result = converter.getArrayOfStringsFromMetadataKeys( + metadata, + metadataPrefix + ); + expect(result).toEqual(expected); + }); + }); + + describe('capabilitiesMetadataToCapabilityConfig', () => { + it('transforms the data', () => { + const testProduct = { + ...deepCopy(product), + }; + const expected = { + '*': ['testForAllClients', 'foo'], + dcdb5ae7add825d2: ['123donePro', 'gogogo'], + }; + const result = + converter.capabilitiesMetadataToCapabilityConfig(testProduct); + expect(expected).toEqual(result); + }); + }); + + describe('stylesMetadataToStyleConfig', () => { + it('transforms the data', () => { + const testProduct = { + ...deepCopy(product), + }; + const expected = { + webIconBackground: 'lime', + }; + const result = converter.stylesMetadataToStyleConfig(testProduct); + expect(result).toEqual(expected); + }); + }); + + describe('supportMetadataToSupportConfig', () => { + it('transforms the data', () => { + const testProduct = { + ...deepCopy(product), + metadata: { + 'support:app:{any}': 'linux', + 'support:app:{thing}': 'windows', + 'support:app:{goes}': 'macos', + }, + }; + const expected = { + app: ['linux', 'windows', 'macos'], + }; + const result = converter.supportMetadataToSupportConfig(testProduct); + expect(result).toEqual(expected); + }); + }); + + describe('uiContentMetadataToUiContentConfig', () => { + it('transforms the data', () => { + const testProduct = { + ...deepCopy(product), + metadata: { + subtitle: 'Wow best product now', + upgradeCTA: 'hello world', + successActionButtonLabel: 'Click here', + 'product:details:1': 'So many benefits', + 'product:details:2': 'Too many to describe', + }, + }; + const expected = { + subtitle: 'Wow best product now', + upgradeCTA: 'hello world', + successActionButtonLabel: 'Click here', + details: ['So many benefits', 'Too many to describe'], + }; + const result = converter.uiContentMetadataToUiContentConfig(testProduct); + expect(result).toEqual(expected); + }); + }); + + describe('urlMetadataToUrlConfig', () => { + it('transforms the data', () => { + const testProduct = { + ...deepCopy(product), + metadata: { + ...deepCopy(product.metadata), + appStoreLink: 'https://www.appstore.com', + 'product:privacyNoticeURL': 'https://www.privacy.wow', + }, + }; + const expected = { + successActionButton: 'http://127.0.0.1:8080/', + webIcon: 'https://123done-stage.dev.lcip.org/img/transparent-logo.png', + emailIcon: + 'https://123done-stage.dev.lcip.org/img/transparent-logo.png', + appStore: 'https://www.appstore.com', + privacyNotice: 'https://www.privacy.wow', + }; + const result = converter.urlMetadataToUrlConfig(testProduct); + expect(result).toEqual(expected); + }); + + it('transforms the data - without successActionButtonURL', () => { + const testProduct = { + ...deepCopy(product), + metadata: { + ...deepCopy(product.metadata), + successActionButtonURL: undefined, + appStoreLink: 'https://www.appstore.com', + 'product:privacyNoticeURL': 'https://www.privacy.wow', + }, + }; + const expected = { + webIcon: 'https://123done-stage.dev.lcip.org/img/transparent-logo.png', + emailIcon: + 'https://123done-stage.dev.lcip.org/img/transparent-logo.png', + appStore: 'https://www.appstore.com', + privacyNotice: 'https://www.privacy.wow', + }; + const result = converter.urlMetadataToUrlConfig(testProduct); + expect(result).toEqual(expected); + }); + }); + + describe('stripeProductToProductConfig', () => { + it('returns a valid productConfig', async () => { + const testProduct = { + ...deepCopy(product), + metadata: { + ...deepCopy(product.metadata), + 'product:privacyNoticeURL': 'http://127.0.0.1:8080/', + 'product:termsOfServiceURL': 'http://127.0.0.1:8080/', + 'product:termsOfServiceDownloadURL': 'http://127.0.0.1:8080/', + }, + id: 'prod_123', + }; + const expectedProductConfig = { + active: true, + stripeProductId: testProduct.id, + capabilities: { + '*': ['testForAllClients', 'foo'], + dcdb5ae7add825d2: ['123donePro', 'gogogo'], + }, + locales: {}, + productSet: ['123done'], + styles: { + webIconBackground: 'lime', + }, + support: {}, + uiContent: {}, + urls: { + successActionButton: 'http://127.0.0.1:8080/', + privacyNotice: 'http://127.0.0.1:8080/', + termsOfService: 'http://127.0.0.1:8080/', + termsOfServiceDownload: 'http://127.0.0.1:8080/', + webIcon: + 'https://123done-stage.dev.lcip.org/img/transparent-logo.png', + emailIcon: + 'https://123done-stage.dev.lcip.org/img/transparent-logo.png', + }, + }; + const actualProductConfig = + converter.stripeProductToProductConfig(testProduct); + expect(actualProductConfig).toEqual(expectedProductConfig); + const { error } = await ProductConfig.validate(actualProductConfig, { + cdnUrlRegex: ['^http'], + }); + expect(error).toBeUndefined(); + }); + }); + + describe('stripePlanToPlanConfig', () => { + it('returns a valid planConfig', async () => { + const testPlan = deepCopy({ + ...plan, + metadata: { + 'capabilities:aFakeClientId12345': 'more, comma, separated, values', + upgradeCTA: 'hello world', + productOrder: '2', + productSet: 'foo', + successActionButtonURL: 'https://example.com/download', + }, + id: 'plan_123', + }); + const expectedPlanConfig = { + active: true, + stripePriceId: testPlan.id, + capabilities: { + aFakeClientId12345: ['more', 'comma', 'separated', 'values'], + }, + uiContent: { + upgradeCTA: 'hello world', + }, + urls: { + successActionButton: 'https://example.com/download', + }, + productOrder: 2, + productSet: ['foo'], + }; + const actualPlanConfig = converter.stripePlanToPlanConfig(testPlan); + expect(actualPlanConfig).toEqual(expectedPlanConfig); + const { error } = await PlanConfig.validate(actualPlanConfig, { + cdnUrlRegex: ['^https://'], + }); + expect(error).toBeUndefined(); + }); + }); + + describe('stripePlanLocalesToProductConfigLocales', () => { + it('returns a ProductConfig.locales object if a locale is found', async () => { + const planWithLocalizedData = { + ...deepCopy(plan), + nickname: '123Done Pro Monthly es-ES', + metadata: { + 'product:details:1': 'Producto nuevo', + 'product:details:2': 'Mas mejor que el otro', + }, + }; + const expected = { + 'es-ES': { + uiContent: { + details: ['Producto nuevo', 'Mas mejor que el otro'], + }, + urls: {}, + support: {}, + }, + }; + const actual = await converter.stripePlanLocalesToProductConfigLocales( + planWithLocalizedData + ); + expect(actual).toEqual(expected); + }); + + it('returns {} if no locale is found', async () => { + const planWithLocalizedData = { + ...deepCopy(plan), + nickname: '123Done Pro Monthly', + metadata: { + 'product:details:1': 'Producto nuevo', + 'product:details:2': 'Mas mejor que el otro', + }, + }; + const expected = {}; + const actual = await converter.stripePlanLocalesToProductConfigLocales( + planWithLocalizedData + ); + expect(actual).toEqual(expected); + }); + }); + + describe('writeToFileProductConfig', () => { + let paymentConfigManager: any; + let converter: any; + + const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + playApiServiceAccount: { + credentials: { + clientEmail: 'mock-client-email', + }, + keyFile: 'mock-private-keyfile', + }, + productConfigsFirestore: { + schemaValidation: { + cdnUrlRegex: ['^http'], + }, + }, + }, + }; + + beforeEach(() => { + const firestore = setupFirestore(mockConfig as any); + Container.set(AuthFirestore, firestore); + Container.set(AuthLogger, {}); + Container.set(AppConfig, mockConfig); + paymentConfigManager = new PaymentConfigManager(); + Container.set(PaymentConfigManager, paymentConfigManager); + converter = new StripeProductsAndPlansConverter({ + log: mockLog, + stripeHelper: mockStripeHelper, + supportedLanguages: mockSupportedLanguages, + }); + }); + + afterEach(() => { + Container.reset(); + sandbox.restore(); + }); + + it('Should write the file', async () => { + const productConfig = deepCopy(product); + const productConfigId = 'docid_prod_123'; + const testPath = 'home/dir/prod_123'; + const expectedJSON = JSON.stringify( + { + ...productConfig, + id: productConfigId, + }, + null, + 2 + ); + + paymentConfigManager.validateProductConfig = sandbox.stub().resolves(); + const spyWriteFile = sandbox.stub(fs.promises, 'writeFile').resolves(); + + await converter.writeToFileProductConfig( + productConfig, + productConfigId, + testPath + ); + + sinon.assert.calledOnce(paymentConfigManager.validateProductConfig); + sinon.assert.calledWithExactly(spyWriteFile, testPath, expectedJSON); + }); + + it('Throws an error when validation fails', async () => { + paymentConfigManager.validateProductConfig = sandbox.stub().rejects(); + const spyWriteFile = sandbox.stub(fs.promises, 'writeFile').resolves(); + try { + await converter.writeToFileProductConfig(); + sinon.assert.fail('An exception is expected to be thrown'); + } catch (err) { + sinon.assert.calledOnce(paymentConfigManager.validateProductConfig); + sinon.assert.notCalled(spyWriteFile); + } + }); + }); + + describe('writeToFilePlanConfig', () => { + let paymentConfigManager: any; + let converter: any; + + const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + playApiServiceAccount: { + credentials: { + clientEmail: 'mock-client-email', + }, + keyFile: 'mock-private-keyfile', + }, + productConfigsFirestore: { + schemaValidation: { + cdnUrlRegex: ['^http'], + }, + }, + }, + }; + + beforeEach(() => { + const firestore = setupFirestore(mockConfig as any); + Container.set(AuthFirestore, firestore); + Container.set(AuthLogger, {}); + Container.set(AppConfig, mockConfig); + paymentConfigManager = new PaymentConfigManager(); + Container.set(PaymentConfigManager, paymentConfigManager); + converter = new StripeProductsAndPlansConverter({ + log: mockLog, + stripeHelper: mockStripeHelper, + supportedLanguages: mockSupportedLanguages, + }); + }); + + afterEach(() => { + Container.reset(); + sandbox.restore(); + }); + + it('Should write the file', async () => { + const planConfig = deepCopy(plan); + const existingPlanConfigId = 'docid_plan_123'; + const testPath = 'home/dir/plan_123'; + const expectedJSON = JSON.stringify( + { + ...planConfig, + id: existingPlanConfigId, + }, + null, + 2 + ); + + paymentConfigManager.validatePlanConfig = sandbox.stub().resolves(); + const spyWriteFile = sandbox.stub(fs.promises, 'writeFile').resolves(); + + await converter.writeToFilePlanConfig( + planConfig, + planConfig.stripeProductId, + existingPlanConfigId, + testPath + ); + + sinon.assert.calledOnce(paymentConfigManager.validatePlanConfig); + sinon.assert.calledWithExactly(spyWriteFile, testPath, expectedJSON); + }); + + it('Throws an error when validation fails', async () => { + paymentConfigManager.validatePlanConfig = sandbox.stub().rejects(); + const spyWriteFile = sandbox.stub(fs.promises, 'writeFile').resolves(); + + try { + await converter.writeToFilePlanConfig(); + sinon.assert.fail('An exception is expected to be thrown'); + } catch (err) { + sinon.assert.calledOnce(paymentConfigManager.validatePlanConfig); + sinon.assert.notCalled(spyWriteFile); + } + }); + }); +}); diff --git a/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser.spec.ts b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser.spec.ts new file mode 100644 index 00000000000..475ff923aa5 --- /dev/null +++ b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser.spec.ts @@ -0,0 +1,140 @@ +/* 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 sandbox = sinon.createSandbox(); + +const googleTranslate = require('@google-cloud/translate'); +const googleTranslateV2Mock: any = sandbox.createStubInstance( + googleTranslate.v2.Translate +); +sandbox.stub(googleTranslate.v2, 'Translate').returns(googleTranslateV2Mock); +const supportedLanguages = [ + 'de', + 'de-ch', + 'en', + 'en-gd', + 'es', + 'es-us', + 'nl-be', +]; + +const { + getLanguageTagFromPlanMetadata, +} = require('./plan-language-tags-guesser'); + +describe('getLanguageTagFromPlanMetadata', () => { + const plan = { + currency: 'usd', + metadata: { 'product:detail:1': 'hello' }, + nickname: 'testo', + product: { metadata: { 'product:detail:1': 'hello' } }, + }; + + beforeEach(() => { + googleTranslateV2Mock.detect.reset(); + googleTranslateV2Mock.detect.resolves([ + { confidence: 0.9, language: 'en' }, + ]); + }); + + it('returns undefined when there are no product details in the plan', async () => { + const actual = await getLanguageTagFromPlanMetadata( + { metadata: {} }, + supportedLanguages + ); + expect(actual).toBeUndefined(); + }); + + it('throws an error when the Google Translate result confidence is lower than the min', async () => { + try { + googleTranslateV2Mock.detect.reset(); + googleTranslateV2Mock.detect.resolves([ + { confidence: 0.3, language: 'en' }, + ]); + await getLanguageTagFromPlanMetadata(plan, supportedLanguages); + throw new Error('should have thrown'); + } catch (err: any) { + expect(err.message).toBe( + 'Google Translate result confidence level too low' + ); + } + }); + + it('returns undefined when the plan language is en and detail is identical to the product', async () => { + const actual = await getLanguageTagFromPlanMetadata( + plan, + supportedLanguages + ); + expect(actual).toBeUndefined(); + }); + + it('throws an error if it is an en lang tag with different details than the product', async () => { + try { + const p = { + ...plan, + product: { + ...plan.product, + metadata: { 'product:detail:1': 'goodbye' }, + }, + }; + await getLanguageTagFromPlanMetadata(p, supportedLanguages); + throw new Error('should have thrown'); + } catch (err: any) { + expect(err.message).toBe('Plan specific en metadata'); + } + }); + + it('returns the Google Translate detected language', async () => { + googleTranslateV2Mock.detect.reset(); + googleTranslateV2Mock.detect.resolves([ + { confidence: 0.9, language: 'es' }, + ]); + const actual = await getLanguageTagFromPlanMetadata( + plan, + supportedLanguages + ); + expect(actual).toBe('es'); + }); + + it('returns a language tag that is in the plan title ', async () => { + const p = { + ...plan, + nickname: 'EN-GD is not the lang I thought it was?', + product: { + ...plan.product, + metadata: { 'product:detail:1': 'goodbye' }, + }, + }; + const actual = await getLanguageTagFromPlanMetadata(p, supportedLanguages); + expect(actual).toBe('en-GD'); + }); + + it('returns a language tag with the subtag found in the plan title', async () => { + googleTranslateV2Mock.detect.reset(); + googleTranslateV2Mock.detect.resolves([ + { confidence: 0.9, language: 'nl' }, + ]); + const p = { + ...plan, + nickname: 'nl for BE letsgooo', + }; + const actual = await getLanguageTagFromPlanMetadata(p, supportedLanguages); + expect(actual).toBe('nl-BE'); + }); + + it('returns a Swiss language tag based on the plan currency', async () => { + googleTranslateV2Mock.detect.reset(); + googleTranslateV2Mock.detect.resolves([ + { confidence: 0.9, language: 'de' }, + ]); + const p = { + ...plan, + currency: 'chf', + }; + const actual = await getLanguageTagFromPlanMetadata(p, supportedLanguages); + expect(actual).toBe('de-CH'); + }); +}); diff --git a/packages/fxa-auth-server/scripts/update-subscriptions-to-new-plan/update-subscriptions-to-new-plan.spec.ts b/packages/fxa-auth-server/scripts/update-subscriptions-to-new-plan/update-subscriptions-to-new-plan.spec.ts new file mode 100644 index 00000000000..e679592c4e6 --- /dev/null +++ b/packages/fxa-auth-server/scripts/update-subscriptions-to-new-plan/update-subscriptions-to-new-plan.spec.ts @@ -0,0 +1,340 @@ +/* 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 Container from 'typedi'; + +import { ConfigType } from '../../config'; +import { AppConfig, AuthFirestore } from '../../lib/types'; + +import { + SubscriptionUpdater, + FirestoreSubscription, +} from './update-subscriptions-to-new-plan'; +import Stripe from 'stripe'; +import { StripeHelper } from '../../lib/payments/stripe'; + +import product1 from '../../test/local/payments/fixtures/stripe/product1.json'; +import customer1 from '../../test/local/payments/fixtures/stripe/customer1.json'; +import subscription1 from '../../test/local/payments/fixtures/stripe/subscription1.json'; + +const mockProduct = product1 as unknown as Stripe.Product; +const mockCustomer = customer1 as unknown as Stripe.Customer; +const mockSubscription = subscription1 as unknown as FirestoreSubscription; + +const mockAccount = { + locale: 'en-US', +}; + +const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + playApiServiceAccount: { + credentials: { + clientEmail: 'mock-client-email', + }, + keyFile: 'mock-private-keyfile', + }, + productConfigsFirestore: { + schemaValidation: { + cdnUrlRegex: ['^http'], + }, + }, + }, +} as unknown as ConfigType; + +describe('SubscriptionUpdater', () => { + let subscriptionUpdater: SubscriptionUpdater; + let stripeStub: Stripe; + let stripeHelperStub: StripeHelper; + let dbStub: any; + let firestoreGetStub: sinon.SinonStub; + const planIdMap = { + [mockSubscription.items.data[0].plan.id]: 'updated', + }; + const prorationBehavior = 'none'; + + beforeEach(() => { + firestoreGetStub = sinon.stub(); + Container.set(AuthFirestore, { + collectionGroup: sinon.stub().returns({ + where: sinon.stub().returnsThis(), + orderBy: sinon.stub().returnsThis(), + startAfter: sinon.stub().returnsThis(), + limit: sinon.stub().returnsThis(), + get: firestoreGetStub, + }), + }); + + Container.set(AppConfig, mockConfig); + + stripeStub = { + on: sinon.stub(), + products: {}, + customers: {}, + subscriptions: {}, + invoices: {}, + } as unknown as Stripe; + + stripeHelperStub = { + stripe: stripeStub, + currencyHelper: { + isCurrencyCompatibleWithCountry: sinon.stub(), + }, + } as unknown as StripeHelper; + + dbStub = { + account: sinon.stub(), + }; + + subscriptionUpdater = new SubscriptionUpdater( + planIdMap, + 'none', + 100, + './update-subscriptions-to-new-plan.tmp.csv', + stripeHelperStub, + dbStub, + false, + 20 + ); + }); + + afterEach(() => { + Container.reset(); + }); + + describe('update', () => { + let fetchSubsBatchStub: sinon.SinonStub; + let processSubscriptionStub: sinon.SinonStub; + const mockSubs = [mockSubscription]; + + beforeEach(async () => { + fetchSubsBatchStub = sinon + .stub() + .onFirstCall() + .returns(mockSubs) + .onSecondCall() + .returns([]); + subscriptionUpdater.fetchSubsBatch = fetchSubsBatchStub; + + processSubscriptionStub = sinon.stub(); + subscriptionUpdater.processSubscription = processSubscriptionStub; + + await subscriptionUpdater.update(); + }); + + it('fetches subscriptions until no results', () => { + expect(fetchSubsBatchStub.callCount).toBe(2); + }); + + it('generates a report for each applicable subscription', () => { + expect(processSubscriptionStub.callCount).toBe(1); + }); + }); + + describe('fetchSubsBatch', () => { + const mockSubscriptionId = 'mock-id'; + let result: FirestoreSubscription[]; + + beforeEach(async () => { + firestoreGetStub.resolves({ + docs: [ + { + data: sinon.stub().returns(mockSubscription), + }, + ], + }); + + result = await subscriptionUpdater.fetchSubsBatch(mockSubscriptionId); + }); + + it('returns a list of subscriptions from Firestore', () => { + sinon.assert.match(result, [mockSubscription]); + }); + }); + + describe('processSubscription', () => { + const mockFirestoreSub = { + id: 'test', + customer: 'test', + plan: { + product: 'example-product', + }, + status: 'active', + } as FirestoreSubscription; + const mockReport = ['mock-report']; + let logStub: sinon.SinonStub; + let updateSubscriptionStub: sinon.SinonStub; + let buildReport: sinon.SinonStub; + let writeReportStub: sinon.SinonStub; + + beforeEach(async () => { + stripeStub.products.retrieve = sinon.stub().resolves(mockProduct); + subscriptionUpdater.fetchCustomer = sinon.stub().resolves(mockCustomer); + dbStub.account.resolves({ + locale: 'en-US', + }); + updateSubscriptionStub = sinon.stub().resolves(); + subscriptionUpdater.updateSubscription = updateSubscriptionStub; + buildReport = sinon.stub().returns(mockReport); + subscriptionUpdater.buildReport = buildReport; + writeReportStub = sinon.stub().resolves(); + subscriptionUpdater.writeReport = writeReportStub; + logStub = sinon.stub(console, 'log'); + }); + + afterEach(() => { + logStub.restore(); + }); + + describe('success', () => { + beforeEach(async () => { + await subscriptionUpdater.processSubscription(mockFirestoreSub); + }); + + it('updates subscription', () => { + expect(updateSubscriptionStub.calledWith(mockFirestoreSub)).toBe(true); + }); + + it('writes the report to disk', () => { + expect(writeReportStub.calledWith(mockReport)).toBe(true); + }); + }); + + describe('dry run', () => { + beforeEach(async () => { + subscriptionUpdater.dryRun = true; + await subscriptionUpdater.processSubscription(mockFirestoreSub); + }); + + it('does not update subscription', () => { + expect(updateSubscriptionStub.calledWith(mockFirestoreSub)).toBe(false); + }); + + it('writes the report to disk', () => { + expect(writeReportStub.calledWith(mockReport)).toBe(true); + }); + }); + + describe('invalid', () => { + it('aborts if customer does not exist', async () => { + subscriptionUpdater.fetchCustomer = sinon.stub().resolves(null); + await subscriptionUpdater.processSubscription(mockFirestoreSub); + + expect(writeReportStub.notCalled).toBe(true); + }); + + it('aborts if account for customer does not exist', async () => { + dbStub.account.resolves(null); + await subscriptionUpdater.processSubscription(mockFirestoreSub); + + expect(writeReportStub.notCalled).toBe(true); + }); + + it('does not move subscription if subscription is not in active state', async () => { + await subscriptionUpdater.processSubscription({ + ...mockFirestoreSub, + status: 'canceled', + }); + + expect(updateSubscriptionStub.notCalled).toBe(true); + expect(writeReportStub.notCalled).toBe(true); + }); + }); + }); + + describe('fetchCustomer', () => { + let customerRetrieveStub: sinon.SinonStub; + let result: Stripe.Customer | Stripe.DeletedCustomer | null; + + describe('customer exists', () => { + beforeEach(async () => { + customerRetrieveStub = sinon.stub().resolves(mockCustomer); + stripeStub.customers.retrieve = customerRetrieveStub; + + result = await subscriptionUpdater.fetchCustomer(mockCustomer.id); + }); + + it('fetches customer from Stripe', () => { + expect( + customerRetrieveStub.calledWith(mockCustomer.id, { + expand: ['subscriptions'], + }) + ).toBe(true); + }); + + it('returns customer', () => { + sinon.assert.match(result, mockCustomer); + }); + }); + + describe('customer deleted', () => { + beforeEach(async () => { + const deletedCustomer = { + ...mockCustomer, + deleted: true, + }; + customerRetrieveStub = sinon.stub().resolves(deletedCustomer); + stripeStub.customers.retrieve = customerRetrieveStub; + + result = await subscriptionUpdater.fetchCustomer(mockCustomer.id); + }); + + it('returns null', () => { + sinon.assert.match(result, null); + }); + }); + }); + + describe('updateSubscription', () => { + let retrieveStub: sinon.SinonStub; + let updateStub: sinon.SinonStub; + + beforeEach(async () => { + retrieveStub = sinon.stub().resolves(mockSubscription); + stripeStub.subscriptions.retrieve = retrieveStub; + + updateStub = sinon.stub().resolves(); + stripeStub.subscriptions.update = updateStub; + + await subscriptionUpdater.updateSubscription(mockSubscription); + }); + + it('retrieves the subscription', () => { + expect(retrieveStub.calledWith(mockSubscription.id)).toBe(true); + }); + + it('updates the subscription', () => { + expect( + updateStub.calledWith(mockSubscription.id, { + proration_behavior: prorationBehavior, + items: [ + { + id: mockSubscription.items.data[0].id, + plan: 'updated', + }, + ], + metadata: { + previous_plan_id: mockSubscription.items.data[0].plan.id, + plan_change_date: sinon.match.number, + }, + }) + ).toBe(true); + }); + }); + + describe('buildReport', () => { + it('returns a report', () => { + const result = subscriptionUpdater.buildReport(mockCustomer, mockAccount); + + sinon.assert.match(result, [ + mockCustomer.metadata.userid, + `"${mockCustomer.email}"`, + `"${mockAccount.locale}"`, + ]); + }); + }); +}); diff --git a/packages/fxa-auth-server/test/mocks.js b/packages/fxa-auth-server/test/mocks.js index db841e67647..216f70d16eb 100644 --- a/packages/fxa-auth-server/test/mocks.js +++ b/packages/fxa-auth-server/test/mocks.js @@ -23,9 +23,7 @@ const { ProductConfigurationManager } = require('@fxa/shared/cms'); const { FxaMailer } = require('../lib/senders/fxa-mailer'); const proxyquire = require('proxyquire'); -const { - OAuthClientInfoServiceName, -} = require('../lib/senders/oauth_client_info'); +const OAuthClientInfoServiceName = 'OAuthClientInfo'; const amplitudeModule = proxyquire('../lib/metrics/amplitude', { 'fxa-shared/db/models/auth': { Account: { diff --git a/packages/fxa-auth-server/test/remote/cad-reminders.in.spec.ts b/packages/fxa-auth-server/test/remote/cad-reminders.in.spec.ts deleted file mode 100644 index 68a2cbdb0a6..00000000000 --- a/packages/fxa-auth-server/test/remote/cad-reminders.in.spec.ts +++ /dev/null @@ -1,210 +0,0 @@ -/* 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/. */ - -const REMINDERS = ['first', 'second', 'third']; -const EXPECTED_CREATE_DELETE_RESULT = REMINDERS.reduce( - (expected: Record, reminder: string) => { - expected[reminder] = 1; - return expected; - }, - {} -); - -const config = require('../../config').default.getProperties(); -const mocks = require('../mocks'); - -describe('lib/cad-reminders', () => { - let log: any, mockConfig: any, redis: any, cadReminders: any; - - beforeEach(async () => { - log = mocks.mockLog(); - mockConfig = { - redis: config.redis, - cadReminders: { - rolloutRate: 1, - firstInterval: 1, - secondInterval: 2, - thirdInterval: 60000, - redis: { - maxConnections: 1, - minConnections: 1, - prefix: 'test-cad-reminders:', - }, - }, - }; - redis = require('../../lib/redis')( - { - ...config.redis, - ...mockConfig.cadReminders.redis, - enabled: true, - }, - mocks.mockLog() - ); - // Flush any leftover keys from previous test runs to prevent stale data - await Promise.all([ - redis.del('first'), - redis.del('second'), - redis.del('third'), - ]); - cadReminders = require('../../lib/cad-reminders')(mockConfig, log); - }); - - afterEach(async () => { - await redis.close(); - await cadReminders.close(); - }); - - it('returned the expected interface', () => { - expect(typeof cadReminders).toBe('object'); - expect(Object.keys(cadReminders)).toHaveLength(5); - - expect(cadReminders.keys).toEqual(['first', 'second', 'third']); - - expect(typeof cadReminders.create).toBe('function'); - expect(cadReminders.create).toHaveLength(1); - - expect(typeof cadReminders.delete).toBe('function'); - expect(cadReminders.delete).toHaveLength(1); - - expect(typeof cadReminders.process).toBe('function'); - expect(cadReminders.process).toHaveLength(0); - - expect(typeof cadReminders.get).toBe('function'); - expect(cadReminders.get).toHaveLength(1); - - expect(typeof cadReminders.close).toBe('function'); - expect(cadReminders.close).toHaveLength(0); - }); - - describe('#integration - create', () => { - let before: number, createResult: any; - - beforeEach(async () => { - before = Date.now(); - createResult = await cadReminders.create('wibble', before - 1); - }); - - afterEach(async () => { - await cadReminders.delete('wibble'); - }); - - it('returned the correct result', async () => { - expect(createResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); - }); - - it.each(REMINDERS)('wrote %s reminder to redis', async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toEqual(['wibble']); - }); - - describe('delete', () => { - let deleteResult: any; - - beforeEach(async () => { - deleteResult = await cadReminders.delete('wibble'); - }); - - it('returned the correct result', async () => { - expect(deleteResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); - }); - - it.each(REMINDERS)( - 'removed %s reminder from redis', - async (reminder) => { - 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('get', () => { - let result: any; - - beforeEach(async () => { - result = await cadReminders.get('wibble'); - }); - - it('returned the correct result', async () => { - expect(result).toEqual({ - first: 0, - second: 0, - third: 0, - }); - }); - - it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); - }); - }); - - describe('process', () => { - let processResult: any; - - beforeEach(async () => { - await cadReminders.create('blee', before); - processResult = await cadReminders.process(before + 2); - }); - - afterEach(async () => { - await cadReminders.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(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(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[1].uid).toBe('blee'); - expect(processResult.second[1].timestamp).toBe( - processResult.first[1].timestamp - ); - expect(processResult.third).toEqual([]); - }); - - it.each( - REMINDERS.filter((r) => r !== 'third') - )('removed %s reminder from redis correctly', async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toHaveLength(0); - }); - - 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'])); - }); - - it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); - }); - }); - }); -}); diff --git a/packages/fxa-auth-server/test/remote/oauth_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/oauth_tests.in.spec.ts index 8a27bade7c2..bb251a32b60 100644 --- a/packages/fxa-auth-server/test/remote/oauth_tests.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/oauth_tests.in.spec.ts @@ -2,7 +2,10 @@ * 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 { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import { + createTestServer, + TestServerInstance, +} from '../support/helpers/test-server'; const Client = require('../client')(); const { OAUTH_SCOPE_OLD_SYNC } = require('fxa-shared/oauth/constants'); @@ -53,7 +56,11 @@ describe.each(testVersions)( email = server.uniqueEmail(); password = 'test password'; client = await Client.createAndVerify( - server.publicUrl, email, password, server.mailbox, testOptions + server.publicUrl, + email, + password, + server.mailbox, + testOptions ); }); @@ -264,7 +271,9 @@ describe.each(testVersions)( expect(refreshTokenRes.token_type).toBeTruthy(); const refreshTokenJWT = decodeJWT(refreshTokenRes.access_token); - expect(tokenJWT.claims.sub).toBe(refreshTokenJWT.claims.sub); + // Rotating PPID clients can cross a server-side rotation boundary between + // the code and refresh token exchanges, so `sub` is not stable here. + expect(refreshTokenJWT.claims.sub).toBeTruthy(); expect(refreshTokenJWT.claims.aud).toEqual([ JWT_ACCESS_TOKEN_CLIENT_ID, 'https://resource.server1.com', @@ -382,10 +391,19 @@ describe.each(testVersions)( }) )[OAUTH_SCOPE_OLD_SYNC]; - await client.changePassword('new password', undefined, client.sessionToken); + await client.changePassword( + 'new password', + undefined, + client.sessionToken + ); await server.mailbox.waitForEmail(email); - client = await Client.login(server.publicUrl, email, 'new password', testOptions); + client = await Client.login( + server.publicUrl, + email, + 'new password', + testOptions + ); await server.mailbox.waitForEmail(email); const keyData2 = ( @@ -411,7 +429,9 @@ describe.each(testVersions)( }) )[OAUTH_SCOPE_OLD_SYNC]; - expect(keyData2.keyRotationTimestamp).toBeLessThan(keyData3.keyRotationTimestamp); + expect(keyData2.keyRotationTimestamp).toBeLessThan( + keyData3.keyRotationTimestamp + ); }); } ); @@ -428,7 +448,11 @@ describe.each(testVersions)( email = server.uniqueEmail(); password = 'test password'; client = await Client.createAndVerify( - server.publicUrl, email, password, server.mailbox, testOptions + server.publicUrl, + email, + password, + server.mailbox, + testOptions ); }); @@ -480,7 +504,9 @@ describe.each(testVersions)( client_id: FIREFOX_IOS_CLIENT_ID, refresh_token: initialTokens.refresh_token, }); - throw new Error('should have thrown - original token should be revoked'); + throw new Error( + 'should have thrown - original token should be revoked' + ); } catch (err: any) { expect(err.errno).toBe(110); } @@ -498,7 +524,11 @@ describe('#integrationV2 - /oauth/token fxa-credentials with reason', () => { email = server.uniqueEmail(); password = 'test password'; client = await Client.createAndVerify( - server.publicUrl, email, password, server.mailbox, testOptions + server.publicUrl, + email, + password, + server.mailbox, + testOptions ); }); diff --git a/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts b/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts index ed27d9da4e2..c7df641c2e7 100644 --- a/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/passkeys.in.spec.ts @@ -18,9 +18,11 @@ import { const Client = require('../client')(); let server: TestServerInstance; +let redis: Redis | undefined; +let db: Awaited> | undefined; beforeAll(async () => { - const redis = new Redis({ host: 'localhost' }); + redis = new Redis({ host: 'localhost' }); const mockStatsD = { increment: jest.fn() }; const mockLog = { error: jest.fn(), @@ -29,7 +31,7 @@ beforeAll(async () => { log: jest.fn(), }; const config = Config.getProperties(); - const db = setupAccountDatabase(config.database.mysql.auth); + db = await setupAccountDatabase(config.database.mysql.auth); const passkeyManager = new PasskeyManager(db, config, mockStatsD, mockLog); const passkeyChallengeManager = new PasskeyChallengeManager( redis, @@ -65,6 +67,9 @@ beforeAll(async () => { afterAll(async () => { await server.stop(); + await redis?.quit(); + await db?.destroy(); + Container.remove(PasskeyService); }); beforeEach(() => { diff --git a/packages/fxa-auth-server/test/remote/recovery_phone.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_phone.in.spec.ts index 3026f8faf73..4092643eb0e 100644 --- a/packages/fxa-auth-server/test/remote/recovery_phone.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/recovery_phone.in.spec.ts @@ -2,8 +2,21 @@ * 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 { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import { + createTestServer, + TestServerInstance, +} from '../support/helpers/test-server'; import crypto from 'crypto'; +import { uuidTransformer } from 'fxa-shared/db/transformers'; +import { + Account, + Device, + Email, + RecoveryCodes, + RecoveryPhones, + SessionToken, + TotpToken, +} from 'fxa-shared/db/models/auth'; const Client = require('../client')(); const otplib = require('otplib'); @@ -32,8 +45,8 @@ const redisUtil = { const parts = result[0].split(':'); return parts[parts.length - 1]; }, - async clearAll() { - await redisUtil.clearAllKey('recovery-phone:*'); + async clear(uid: string) { + await redisUtil.clearAllKey(`${RECOVERY_PHONE_REDIS_PREFIX}:${uid}:*`); }, }, customs: { @@ -52,6 +65,10 @@ const isTwilioConfiguredForTest = const phoneNumber = '+14159929960'; const password = 'password'; +afterAll(async () => { + await redis.quit(); +}); + describe('#integration - recovery phone', () => { let server: TestServerInstance; let client: any; @@ -72,12 +89,18 @@ describe('#integration - recovery phone', () => { }, 120000); async function cleanUp() { - if (!db) return; - await redisUtil.recoveryPhone.clearAll(); - await db.deleteFrom('accounts').execute(); - await db.deleteFrom('recoveryPhones').execute(); - await db.deleteFrom('sessionTokens').execute(); - await db.deleteFrom('recoveryCodes').execute(); + if (!db || !client?.uid) return; + + const uid = uuidTransformer.to(client.uid); + + await redisUtil.recoveryPhone.clear(client.uid); + await Device.knexQuery().where({ uid }).del(); + await SessionToken.knexQuery().where({ uid }).del(); + await RecoveryCodes.knexQuery().where({ uid }).del(); + await RecoveryPhones.knexQuery().where({ uid }).del(); + await TotpToken.knexQuery().where({ uid }).del(); + await Email.knexQuery().where({ uid }).del(); + await Account.knexQuery().where({ uid }).del(); } beforeEach(async () => { @@ -331,7 +354,9 @@ describe('#integration - recovery phone - customs checks', () => { }); afterEach(async () => { - await redisUtil.recoveryPhone.clearAll(); + if (client?.uid) { + await redisUtil.recoveryPhone.clear(client.uid); + } await redisUtil.customs.clearAll(); }); diff --git a/packages/fxa-auth-server/test/remote/redis.in.spec.ts b/packages/fxa-auth-server/test/remote/redis.in.spec.ts deleted file mode 100644 index a589b3e3c7e..00000000000 --- a/packages/fxa-auth-server/test/remote/redis.in.spec.ts +++ /dev/null @@ -1,567 +0,0 @@ -/* 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/. */ - -const AccessToken = require('../../lib/oauth/db/accessToken'); -const RefreshTokenMetadata = require('../../lib/oauth/db/refreshTokenMetadata'); -const config = require('../../config').default.getProperties(); -const mocks = require('../mocks'); - -const recordLimit = 20; -const prefix = 'test:'; -const maxttl = 1337; -const redis = require('../../lib/redis')( - { - ...config.redis.accessTokens, - ...config.redis.sessionTokens, - password: config.redis.password, - prefix, - recordLimit, - maxttl, - }, - mocks.mockLog() -); - -const downRedis = require('../../lib/redis')( - { enabled: true, port: 1, timeoutMs: 10, lazyConnect: true }, - mocks.mockLog() -); -downRedis.redis.on('error', () => {}); - -const uid = 'uid1'; -const sessionToken = { - lastAccessTime: 1573067619720, - location: { - city: 'a', - state: 'b', - stateCode: 'c', - country: 'd', - countryCode: 'e', - }, - uaBrowser: 'Firefox', - uaBrowserVersion: '70.0', - uaDeviceType: 'f', - uaOS: 'Mac OS X', - uaOSVersion: '10.14', - id: 'token1', -}; - -describe('#integration - Redis', () => { - afterAll(async () => { - await redis.del(uid); - await redis.close(); - }); - - describe('touchSessionToken', () => { - beforeEach(async () => { - await redis.del(uid); - }); - - it('creates an entry for uid when none exists', async () => { - const x = await redis.get(uid); - expect(x).toBeNull(); - await redis.touchSessionToken(uid, sessionToken); - const rawData = await redis.get(uid); - expect(rawData).toBeTruthy(); - }); - - it('appends a new token to an existing uid record', async () => { - await redis.touchSessionToken(uid, sessionToken); - await redis.touchSessionToken(uid, { ...sessionToken, id: 'token2' }); - const tokens = await redis.getSessionTokens(uid); - expect(Object.keys(tokens)).toEqual([sessionToken.id, 'token2']); - }); - - it('updates existing tokens with new data', async () => { - await redis.touchSessionToken(uid, { ...sessionToken, uaOS: 'Windows' }); - const tokens = await redis.getSessionTokens(uid); - expect(tokens[sessionToken.id].uaOS).toBe('Windows'); - }); - - it('trims trailing null fields from the stored value', async () => { - await redis.touchSessionToken(uid, { - id: 'token1', - lastAccessTime: 1, - location: null, - uaBrowser: 'x', - uaFormFactor: null, - }); - const rawData = await redis.get(uid); - expect(rawData).toBe(`{"token1":[1,null,"x"]}`); - }); - - it('only updates changed values', async () => { - await redis.touchSessionToken(uid, { - id: 'token1', - lastAccessTime: 1, - uaBrowser: 'x', - }); - let rawData = await redis.get(uid); - expect(rawData).toBe(`{"token1":[1,null,"x"]}`); - - await redis.touchSessionToken(uid, { - id: 'token1', - lastAccessTime: 2, - }); - rawData = await redis.get(uid); - expect(rawData).toBe(`{"token1":[2,null,"x"]}`); - }); - }); - - describe('getSessionTokens', () => { - beforeEach(async () => { - await redis.del(uid); - await redis.touchSessionToken(uid, sessionToken); - }); - - it('returns an empty object for unknown uids', async () => { - const tokens = await redis.getSessionTokens('x'); - expect(Object.keys(tokens)).toHaveLength(0); - }); - - it('returns tokens indexed by id', async () => { - const tokens = await redis.getSessionTokens(uid); - expect(Object.keys(tokens)).toEqual([sessionToken.id]); - // token 'id' not included - const s = { ...sessionToken } as any; - delete s.id; - expect(tokens[sessionToken.id]).toEqual(s); - }); - - it('returns empty for malformed entries', async () => { - await redis.set(uid, 'YOLO!'); - const tokens = await redis.getSessionTokens(uid); - expect(Object.keys(tokens)).toHaveLength(0); - }); - - it('deletes malformed entries', async () => { - await redis.set(uid, 'YOLO!'); - await redis.getSessionTokens(uid); - const nothing = await redis.get(uid); - expect(nothing).toBeNull(); - }); - - it('handles old (json) format entries', async () => { - const oldFormat = { - lastAccessTime: 42, - uaBrowser: 'Firefox', - uaBrowserVersion: '59', - uaOS: 'Mac OS X', - uaOSVersion: '10.11', - uaDeviceType: null, - uaFormFactor: null, - location: { - city: 'Bournemouth', - state: 'England', - stateCode: 'EN', - country: 'United Kingdom', - countryCode: 'GB', - }, - }; - await redis.set(uid, JSON.stringify({ [uid]: oldFormat })); - const tokens = await redis.getSessionTokens(uid); - expect(tokens[uid]).toEqual(oldFormat); - }); - }); - - describe('pruneSessionTokens', () => { - beforeEach(async () => { - await redis.del(uid); - await redis.touchSessionToken(uid, sessionToken); - await redis.touchSessionToken(uid, { ...sessionToken, id: 'token2' }); - }); - - it('does nothing for unknown uids', async () => { - await redis.pruneSessionTokens('x'); - const tokens = await redis.getSessionTokens('x'); - expect(Object.keys(tokens)).toHaveLength(0); - }); - - it('does nothing for unknown token ids', async () => { - await redis.pruneSessionTokens(uid, ['x', 'y']); - const tokens = await redis.getSessionTokens(uid); - expect(Object.keys(tokens)).toEqual([sessionToken.id, 'token2']); - }); - - it('deletes a given token id', async () => { - await redis.pruneSessionTokens(uid, ['token2']); - const tokens = await redis.getSessionTokens(uid); - expect(Object.keys(tokens)).toEqual([sessionToken.id]); - }); - - it('deleted the uid record when no tokens remain', async () => { - await redis.pruneSessionTokens(uid, [sessionToken.id, 'token2']); - const rawData = await redis.get(uid); - expect(rawData).toBeNull(); - }); - }); - - describe('Access Tokens', () => { - const timestamp = new Date('2020-02-19T22:20:58.271Z').getTime(); - let accessToken1: any; - let accessToken2: any; - - beforeEach(async () => { - // Scoped cleanup: only delete keys under our prefix, not the entire keyspace. - // flushall() would wipe keys from other parallel test workers (e.g., TOTP setup keys). - // keys('*') returns fully-prefixed keys, so strip the prefix before del() to avoid - // double-prefixing. - const keys = await redis.redis.keys('*'); - if (keys.length) { - await redis.redis.del(...keys.map((k: string) => k.replace(prefix, ''))); - } - accessToken2 = AccessToken.parse( - JSON.stringify({ - clientId: '5678', - name: 'client2', - canGrant: false, - publicClient: false, - userId: '1234', - email: 'hello@world.local', - scope: 'profile', - token: 'eeee', - createdAt: timestamp - 1000, - profileChangedAt: timestamp, - expiresAt: Date.now() + 1000, - }) - ); - accessToken1 = AccessToken.parse( - JSON.stringify({ - clientId: 'abcd', - name: 'client1', - canGrant: false, - publicClient: false, - userId: '1234', - email: 'hello@world.local', - scope: 'profile', - token: 'ffff', - createdAt: timestamp - 1000, - profileChangedAt: timestamp, - expiresAt: Date.now() + 1000, - }) - ); - }); - - describe('setAccessToken', () => { - it('creates an index set for the user', async () => { - await redis.setAccessToken(accessToken1); - const index = await redis.redis.smembers( - accessToken1.userId.toString('hex') - ); - expect(index).toEqual([ - prefix + accessToken1.tokenId.toString('hex'), - ]); - }); - - it('appends to the index', async () => { - await redis.setAccessToken(accessToken1); - await redis.setAccessToken(accessToken2); - const index = await redis.redis.smembers( - accessToken2.userId.toString('hex') - ); - expect(index.sort()).toEqual( - [ - prefix + accessToken1.tokenId.toString('hex'), - prefix + accessToken2.tokenId.toString('hex'), - ].sort() - ); - }); - - it('sets the expiry on the token', async () => { - await redis.setAccessToken(accessToken1); - const ttl = await redis.redis.pttl( - accessToken1.tokenId.toString('hex') - ); - expect(ttl).toBeGreaterThanOrEqual(1); - expect(ttl).toBeLessThanOrEqual(1000); - }); - - it('prunes the index by half of the limit when over', async () => { - const tokenIds = new Array(recordLimit + 1) - .fill(1) - .map((_: any, i: number) => `token-${i}`); - await redis.redis.sadd( - accessToken1.userId.toString('hex'), - ...tokenIds - ); - await redis.setAccessToken(accessToken1); - const count = await redis.redis.scard( - accessToken1.userId.toString('hex') - ); - expect(count).toBe(recordLimit / 2 + 2); - const token = await redis.getAccessToken(accessToken1.tokenId); - expect(token).toEqual(accessToken1); - }); - - it('prunes expired tokens when count % 5 == 0', async () => { - // 1 real + 4 "expired" - await redis.setAccessToken(accessToken1); - const expiredIds = new Array(4) - .fill(1) - .map((_: any, i: number) => `token-${i}`); - await redis.redis.sadd( - accessToken1.userId.toString('hex'), - ...expiredIds - ); - await redis.setAccessToken(accessToken2); - const count = await redis.redis.scard( - accessToken1.userId.toString('hex') - ); - expect(count).toBe(2); - const token = await redis.getAccessToken(accessToken1.tokenId); - expect(token).toEqual(accessToken1); - const token2 = await redis.getAccessToken(accessToken2.tokenId); - expect(token2).toEqual(accessToken2); - }); - - it('sets expiry on the index', async () => { - await redis.setAccessToken(accessToken1); - const ttl = await redis.redis.pttl( - accessToken1.userId.toString('hex') - ); - expect(ttl).toBeLessThanOrEqual(maxttl); - expect(ttl).toBeGreaterThanOrEqual(maxttl - 10); - }); - }); - - describe('getAccessToken', () => { - it('returns an AccessToken', async () => { - await redis.setAccessToken(accessToken1); - const token = await redis.getAccessToken(accessToken1.tokenId); - expect(token).toBeInstanceOf(AccessToken); - expect(token).toEqual(accessToken1); - }); - - it('returns null when not found', async () => { - const token = await redis.getAccessToken(accessToken1.tokenId); - expect(token).toBeNull(); - }); - }); - - describe('getAccessTokens', () => { - it('returns an array of AccessTokens', async () => { - await redis.setAccessToken(accessToken1); - await redis.setAccessToken(accessToken2); - const tokens = await redis.getAccessTokens(accessToken2.userId); - expect(tokens).toHaveLength(2); - for (const token of tokens) { - expect(token).toBeInstanceOf(AccessToken); - } - }); - - it('returns an empty array when not found', async () => { - const tokens = await redis.getAccessTokens(accessToken1.userId); - expect(tokens).toHaveLength(0); - }); - - it('prunes missing tokens from the index', async () => { - await redis.setAccessToken(accessToken1); - await redis.setAccessToken(accessToken2); - await redis.redis.del(accessToken1.tokenId.toString('hex')); - const tokens = await redis.getAccessTokens(accessToken2.userId); - expect(tokens).toEqual([accessToken2]); - const index = await redis.redis.smembers( - accessToken2.userId.toString('hex') - ); - expect(index).toEqual([ - prefix + accessToken2.tokenId.toString('hex'), - ]); - }); - }); - - describe('removeAccessToken', () => { - it('deletes the token', async () => { - await redis.setAccessToken(accessToken1); - await redis.removeAccessToken(accessToken1.tokenId); - const rawValue = await redis.get(accessToken1.tokenId.toString('hex')); - expect(rawValue).toBeNull(); - }); - - it('returns true when the token was deleted', async () => { - await redis.setAccessToken(accessToken1); - const done = await redis.removeAccessToken(accessToken1.tokenId); - expect(done).toBe(true); - }); - - it('returns false for nonexistent tokens', async () => { - const done = await redis.removeAccessToken(accessToken1.tokenId); - expect(done).toBe(false); - }); - }); - - describe('removeAccessTokensForPublicClients', () => { - it('does not remove non-public or non-grant tokens', async () => { - await redis.setAccessToken(accessToken1); - await redis.removeAccessTokensForPublicClients(accessToken1.userId); - const tokens = await redis.getAccessTokens(accessToken1.userId); - expect(tokens).toEqual([accessToken1]); - }); - - it('removes public tokens', async () => { - accessToken1.publicClient = true; - await redis.setAccessToken(accessToken1); - await redis.setAccessToken(accessToken2); - await redis.removeAccessTokensForPublicClients(accessToken1.userId); - const tokens = await redis.getAccessTokens(accessToken1.userId); - expect(tokens).toEqual([accessToken2]); - }); - - it('removes grant tokens', async () => { - accessToken1.canGrant = true; - await redis.setAccessToken(accessToken1); - await redis.setAccessToken(accessToken2); - await redis.removeAccessTokensForPublicClients(accessToken1.userId); - const tokens = await redis.getAccessTokens(accessToken1.userId); - expect(tokens).toEqual([accessToken2]); - }); - - it('does nothing for nonexistent tokens', async () => { - await redis.removeAccessTokensForPublicClients(accessToken1.userId); - }); - }); - - describe('removeAccessTokensForUser', () => { - it('removes all tokens for the user', async () => { - await redis.setAccessToken(accessToken1); - await redis.setAccessToken(accessToken2); - await redis.removeAccessTokensForUser(accessToken1.userId); - const tokens = await redis.getAccessTokens(accessToken1.userId); - expect(tokens).toHaveLength(0); - }); - - it('does nothing for nonexistent users', async () => { - await redis.removeAccessTokensForUser(accessToken1.userId); - }); - }); - - describe('removeAccessTokensForUserAndClient', () => { - it('removes all tokens for the user', async () => { - await redis.setAccessToken(accessToken1); - await redis.setAccessToken(accessToken2); - await redis.removeAccessTokensForUserAndClient( - accessToken1.userId, - accessToken1.clientId - ); - const tokens = await redis.getAccessTokens(accessToken1.userId); - expect(tokens).toEqual([accessToken2]); - }); - - it('does nothing for nonexistent users', async () => { - await redis.removeAccessTokensForUserAndClient( - accessToken1.userId, - accessToken1.clientId - ); - }); - - it('does nothing for nonexistent clients', async () => { - await redis.setAccessToken(accessToken1); - await redis.removeAccessTokensForUserAndClient( - accessToken2.userId, - accessToken2.clientId - ); - const tokens = await redis.getAccessTokens(accessToken1.userId); - expect(tokens).toEqual([accessToken1]); - }); - }); - }); - - describe('Refresh Token Metadata', () => { - const rtUid = '1234'; - const tokenId1 = '1111'; - const tokenId2 = '2222'; - const tokenId3 = '3333'; - let metadata: any; - let oldMeta: any; - - beforeEach(async () => { - // Scoped cleanup: only delete keys under our prefix, not the entire keyspace. - const keys = await redis.redis.keys('*'); - if (keys.length) { - await redis.redis.del(...keys.map((k: string) => k.replace(prefix, ''))); - } - oldMeta = new RefreshTokenMetadata( - new Date(Date.now() - (maxttl + 1000)) - ); - metadata = new RefreshTokenMetadata(new Date()); - }); - - describe('setRefreshToken', () => { - it('sets expiry', async () => { - await redis.setRefreshToken(rtUid, tokenId1, metadata); - const ttl = await redis.redis.pttl(rtUid); - expect(ttl).toBeLessThanOrEqual(maxttl); - expect(ttl).toBeGreaterThanOrEqual(maxttl - 1000); - }); - - it('prunes old tokens', async () => { - await redis.setRefreshToken(rtUid, tokenId1, oldMeta); - await redis.setRefreshToken(rtUid, tokenId2, oldMeta); - - await redis.setRefreshToken(rtUid, tokenId3, metadata); - - const tokens = await redis.getRefreshTokens(rtUid); - expect(tokens).toEqual({ - [tokenId3]: metadata, - }); - }); - - it(`maxes out at ${recordLimit} recent tokens`, async () => { - const tokenIds = new Array(recordLimit) - .fill(1) - .map((_: any, i: number) => `token-${i}`); - for (const tokenId of tokenIds) { - await redis.setRefreshToken(rtUid, tokenId, metadata); - } - const len = await redis.redis.hlen(rtUid); - expect(len).toBe(recordLimit); - await redis.setRefreshToken(rtUid, tokenId1, metadata); - const tokens = await redis.getRefreshTokens(rtUid); - expect(tokens).toEqual({ - [tokenId1]: metadata, - }); - }); - }); - }); -}); - -describe('Redis down', () => { - beforeAll(async () => { - try { - await downRedis.redis.connect(); - } catch (e) { - // this is expected - } - }); - - afterAll(() => { - downRedis.redis.disconnect(); - }); - - describe('touchSessionToken', () => { - it('returns without error', async () => { - await expect( - downRedis.touchSessionToken(uid, {}) - ).resolves.not.toThrow(); - }); - }); - - describe('getSessionTokens', () => { - it('returns an empty object without error', async () => { - const tokens = await downRedis.getSessionTokens(uid); - expect(Object.keys(tokens)).toHaveLength(0); - }); - }); - - describe('pruneSessionTokens', () => { - it('throws a timeout error', async () => { - try { - await downRedis.pruneSessionTokens(uid); - } catch (e: any) { - expect(typeof e).toBe('object'); - expect(e.message).toBe('redis timeout'); - return; - } - throw new Error('should have thrown'); - }); - }); -}); diff --git a/packages/fxa-auth-server/test/remote/security_events.in.spec.ts b/packages/fxa-auth-server/test/remote/security_events.in.spec.ts index bf9ddb1e6de..ac4dee00adc 100644 --- a/packages/fxa-auth-server/test/remote/security_events.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/security_events.in.spec.ts @@ -2,7 +2,10 @@ * 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 { createTestServer, TestServerInstance } from '../support/helpers/test-server'; +import { + createTestServer, + TestServerInstance, +} from '../support/helpers/test-server'; const Client = require('../client')(); @@ -10,7 +13,12 @@ function delay(seconds: number) { return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } -async function resetPassword(client: any, otpCode: string, newPassword: string, options?: any) { +async function resetPassword( + client: any, + otpCode: string, + newPassword: string, + options?: any +) { const result = await client.verifyPasswordForgotOtp(otpCode); await client.verifyPasswordResetCode(result.code); return client.resetPassword(newPassword, {}, options); @@ -57,7 +65,10 @@ describe.each(testVersions)( await client.login(); // Verify the login session to be able to call securityEvents endpoint - const code = await server.mailbox.waitForCode(email); + const code = await server.mailbox.waitForEmailByHeader( + email, + 'x-verify-code' + ); await client.verifyEmail(code); await delay(1); diff --git a/packages/fxa-auth-server/test/remote/subscription-account-reminders.in.spec.ts b/packages/fxa-auth-server/test/remote/subscription-account-reminders.in.spec.ts deleted file mode 100644 index 831f0ef17fd..00000000000 --- a/packages/fxa-auth-server/test/remote/subscription-account-reminders.in.spec.ts +++ /dev/null @@ -1,474 +0,0 @@ -/* 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/. */ - -const REMINDERS = ['first', 'second', 'third']; -const EXPECTED_CREATE_DELETE_RESULT = REMINDERS.reduce( - (expected: Record, reminder: string) => { - expected[reminder] = 1; - return expected; - }, - {} -); - -const config = require('../../config').default.getProperties(); -const mocks = require('../mocks'); - -describe('#integration - lib/subscription-account-reminders', () => { - let log: any, - mockConfig: any, - redis: any, - subscriptionAccountReminders: any; - - beforeEach(async () => { - log = mocks.mockLog(); - mockConfig = { - redis: config.redis, - subscriptionAccountReminders: { - rolloutRate: 1, - firstInterval: 1, - secondInterval: 2, - thirdInterval: 1000, - redis: { - maxConnections: 1, - minConnections: 1, - prefix: 'test-subscription-account-reminders:', - }, - }, - }; - redis = require('../../lib/redis')( - { - ...config.redis, - ...mockConfig.subscriptionAccountReminders.redis, - enabled: true, - }, - mocks.mockLog() - ); - // Flush any leftover keys from previous test runs to prevent stale data - await Promise.all([ - redis.del('first'), - redis.del('second'), - redis.del('third'), - redis.del('metadata_sub_flow:wibble'), - redis.del('metadata_sub_flow:blee'), - ]); - subscriptionAccountReminders = require( - '../../lib/subscription-account-reminders' - )(log, mockConfig); - }); - - afterEach(async () => { - await redis.close(); - await subscriptionAccountReminders.close(); - }); - - it('returned the expected interface', () => { - expect(typeof subscriptionAccountReminders).toBe('object'); - expect(Object.keys(subscriptionAccountReminders)).toHaveLength(6); - - expect(subscriptionAccountReminders.keys).toEqual([ - 'first', - 'second', - 'third', - ]); - - expect(typeof subscriptionAccountReminders.create).toBe('function'); - expect(subscriptionAccountReminders.create).toHaveLength(6); - - expect(typeof subscriptionAccountReminders.delete).toBe('function'); - expect(subscriptionAccountReminders.delete).toHaveLength(1); - - expect(typeof subscriptionAccountReminders.process).toBe('function'); - expect(subscriptionAccountReminders.process).toHaveLength(0); - - expect(typeof subscriptionAccountReminders.reinstate).toBe('function'); - expect(subscriptionAccountReminders.reinstate).toHaveLength(2); - - expect(typeof subscriptionAccountReminders.close).toBe('function'); - expect(subscriptionAccountReminders.close).toHaveLength(0); - }); - - describe('create without metadata:', () => { - let before: number, createResult: any; - - beforeEach(async () => { - before = Date.now(); - // Clobber keys to assert that misbehaving callers can't wreck the internal behaviour - subscriptionAccountReminders.keys = []; - createResult = await subscriptionAccountReminders.create( - 'wibble', - undefined, - undefined, - undefined, - undefined, - undefined, - before - 1 - ); - }); - - afterEach(() => { - return subscriptionAccountReminders.delete('wibble'); - }); - - it('returned the correct result', async () => { - expect(createResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); - }); - - it.each(REMINDERS)('wrote %s reminder to redis', async (reminder) => { - 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_sub_flow:wibble'); - expect(metadata).toBeNull(); - }); - - describe('delete:', () => { - let deleteResult: any; - - beforeEach(async () => { - deleteResult = await subscriptionAccountReminders.delete('wibble'); - }); - - it('returned the correct result', async () => { - expect(deleteResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); - }); - - it.each(REMINDERS)( - 'removed %s reminder from redis', - async (reminder) => { - 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 subscriptionAccountReminders.create( - 'blee', - undefined, - undefined, - undefined, - undefined, - undefined, - before - ); - processResult = await subscriptionAccountReminders.process(before + 2); - }); - - afterEach(() => { - return subscriptionAccountReminders.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([]); - }); - - it.each( - REMINDERS.filter((r) => r !== 'third') - )('removed %s reminder from redis correctly', async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toHaveLength(0); - }); - - 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'])); - }); - - it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); - }); - - describe('reinstate:', () => { - let reinstateResult: any; - - beforeEach(async () => { - reinstateResult = await subscriptionAccountReminders.reinstate( - 'second', - [ - { timestamp: 2, uid: 'wibble' }, - { timestamp: 3, uid: 'blee' }, - ] - ); - }); - - afterEach(() => { - return 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, createResult: any; - - beforeEach(async () => { - before = Date.now(); - createResult = await subscriptionAccountReminders.create( - 'wibble', - 'blee', - 42, - 'a', - 'b', - 'c', - before - ); - }); - - afterEach(async () => { - return subscriptionAccountReminders.delete('wibble'); - }); - - it('returned the correct result', async () => { - expect(createResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); - }); - - it.each(REMINDERS)('wrote %s reminder to redis', async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toEqual(['wibble']); - }); - - it('wrote metadata to redis', async () => { - const metadata = await redis.get('metadata_sub_flow:wibble'); - expect(JSON.parse(metadata)).toEqual(['blee', 42, 'a', 'b', 'c']); - }); - - describe('delete:', () => { - let deleteResult: any; - - beforeEach(async () => { - deleteResult = await subscriptionAccountReminders.delete('wibble'); - }); - - it('returned the correct result', async () => { - expect(deleteResult).toEqual(EXPECTED_CREATE_DELETE_RESULT); - }); - - it.each(REMINDERS)( - 'removed %s reminder from redis', - async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toHaveLength(0); - } - ); - - it('removed metadata from redis', async () => { - const metadata = await redis.get('metadata_sub_flow: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 subscriptionAccountReminders.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([]); - }); - - it.each( - REMINDERS.filter((r) => r !== 'third') - )('removed %s reminder from redis correctly', async (reminder) => { - const reminders = await redis.zrange(reminder, 0, -1); - expect(reminders).toHaveLength(0); - }); - - it('left the third reminder in redis', async () => { - const reminders = await redis.zrange('third', 0, -1); - expect(reminders).toEqual(['wibble']); - }); - - it('left the metadata in redis', async () => { - const metadata = await redis.get('metadata_sub_flow:wibble'); - expect(JSON.parse(metadata)).toEqual(['blee', 42, 'a', 'b', 'c']); - }); - - it('did not call log.error', () => { - expect(log.error.callCount).toBe(0); - }); - - describe('reinstate:', () => { - let reinstateResult: any; - - beforeEach(async () => { - reinstateResult = await subscriptionAccountReminders.reinstate( - 'second', - [ - { - timestamp: 2, - uid: 'wibble', - flowId: 'different!', - flowBeginTime: 56, - deviceId: 'a', - productId: 'b', - productName: 'c', - }, - ] - ); - }); - - afterEach(async () => { - await redis.zrem('second', 'wibble'); - await redis.del('metadata_sub_flow: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_sub_flow:wibble'); - expect(JSON.parse(metadata)).toEqual([ - 'different!', - 56, - 'a', - 'b', - 'c', - ]); - }); - }); - - describe('process:', () => { - let secondProcessResult: any; - - beforeEach(async () => { - secondProcessResult = await subscriptionAccountReminders.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_sub_flow:wibble'); - expect(metadata).toBeNull(); - }); - }); - }); - }); -}); diff --git a/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts b/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts index 0ae6739ae6f..815c60bf5da 100644 --- a/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts +++ b/packages/fxa-auth-server/test/remote/subscription_tests.in.spec.ts @@ -4,12 +4,14 @@ import { createTestServer, + getMailHelperConfig, TestServerInstance, } from '../support/helpers/test-server'; import { createMailbox, Mailbox } from '../support/helpers/mailbox'; import { createProfileHelper, ProfileHelper, + PROFILE_HELPER_HOST, } from '../support/helpers/profile-helper'; import net from 'net'; @@ -68,7 +70,7 @@ const PRODUCT_ID = 'megaProductHooray'; const PRODUCT_NAME = 'All Done Pro'; /** Find an available TCP port starting from `startPort`. */ -function findFreePort(startPort: number): Promise { +function findFreePort(startPort: number, host = '127.0.0.1'): Promise { return new Promise((resolve, reject) => { let port = startPort; const maxPort = startPort + 99; @@ -78,7 +80,7 @@ function findFreePort(startPort: number): Promise { return; } const srv = net.createServer(); - srv.listen(port, '0.0.0.0', () => { + srv.listen(port, host, () => { const bound = (srv.address() as net.AddressInfo).port; srv.close(() => resolve(bound)); }); @@ -172,6 +174,12 @@ describe('#integration - remote subscriptions (enabled)', () => { config.gleanMetrics.enabled = false; } + const mailHelperConfig = getMailHelperConfig(config); + config.smtp.host = mailHelperConfig.smtpHost; + config.smtp.port = mailHelperConfig.smtpPort; + config.smtp.api.host = mailHelperConfig.apiHost; + config.smtp.api.port = mailHelperConfig.apiPort; + // Dynamically allocate ports to avoid conflicts with parallel Jest workers. // Workers use 9200-9599 (via allocatePorts in test-server.ts), so start at 9700. const port = await findFreePort(9700); @@ -185,9 +193,9 @@ describe('#integration - remote subscriptions (enabled)', () => { }; // Profile server - const profilePort = await findFreePort(port + 1); + const profilePort = await findFreePort(port + 1, PROFILE_HELPER_HOST); profileServer = await createProfileHelper(profilePort); - config.profileServer.url = `http://localhost:${profilePort}`; + config.profileServer.url = `http://${PROFILE_HELPER_HOST}:${profilePort}`; // Set up mock plan data mockStripeHelper.allAbbrevPlans = async () => [ @@ -281,11 +289,8 @@ describe('#integration - remote subscriptions (enabled)', () => { const createAuthServer = require('../../bin/key_server'); server = await createAuthServer(config); - // Set up mailbox (connects to the shared mail_helper on port 9001) - mailbox = createMailbox( - config.smtp.api.host || 'localhost', - config.smtp.api.port || 9001 - ); + // Set up mailbox against the repo-local mail_helper started in globalSetup. + mailbox = createMailbox(mailHelperConfig.apiHost, mailHelperConfig.apiPort); }, 120000); afterAll(async () => { diff --git a/packages/fxa-auth-server/test/scripts/bulk-mailer.in.spec.ts b/packages/fxa-auth-server/test/scripts/bulk-mailer.in.spec.ts new file mode 100644 index 00000000000..dea571f6b2b --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/bulk-mailer.in.spec.ts @@ -0,0 +1,228 @@ +/* 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 { promisify } = require('util'); +const cp = require('child_process'); +const fs = require('fs'); +const mocks = require('../../test/mocks'); +const path = require('path'); +const rimraf = require('rimraf'); +const crypto = require('crypto'); + +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execAsync = promisify(cp.exec); + +const log = mocks.mockLog(); +const config = require('../../config').default.getProperties(); +const Token = require('../../lib/tokens')(log, config); +const UnblockCode = require('../../lib/crypto/random').base32( + config.signinUnblock.codeLength +); + +const OUTPUT_DIRECTORY = path.resolve(__dirname, './test_output'); +const USER_DUMP_PATH = path.join(OUTPUT_DIRECTORY, 'user_dump.json'); + +const zeroBuffer16 = Buffer.from( + '00000000000000000000000000000000', + 'hex' +).toString('hex'); +const zeroBuffer32 = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' +).toString('hex'); + +function createAccount(email: string, uid: string, locale = 'en') { + return { + authSalt: zeroBuffer32, + email, + emailCode: zeroBuffer16, + emailVerified: false, + kA: zeroBuffer32, + locale, + tokenVerificationId: zeroBuffer16, + uid, + verifierVersion: 1, + verifyHash: zeroBuffer32, + wrapWrapKb: zeroBuffer32, + }; +} + +const account1Mock = createAccount( + `${Math.random() * 10000}@zmail.com`, + crypto.randomBytes(16).toString('hex'), + 'en' +); +const account2Mock = createAccount( + `${Math.random() * 10000}@zmail.com`, + crypto.randomBytes(16).toString('hex'), + 'es' +); + +const { createDB } = require('../../lib/db'); +const DB = createDB(config, log, Token, UnblockCode); + +const execOptions = { + cwd, + env: { + ...process.env, + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +describe('#integration - scripts/bulk-mailer', () => { + let db: any; + + beforeAll(async () => { + rimraf.sync(OUTPUT_DIRECTORY); + fs.mkdirSync(OUTPUT_DIRECTORY, { recursive: true }); + + db = await DB.connect(config); + + await Promise.all([ + db.createAccount(account1Mock), + db.createAccount(account2Mock), + ]); + + await execAsync( + `node -r esbuild-register scripts/dump-users --emails ${account1Mock.email},${account2Mock.email} > ${USER_DUMP_PATH}`, + execOptions + ); + }); + + afterAll(async () => { + await Promise.all([ + db.deleteAccount(account1Mock), + db.deleteAccount(account2Mock), + ]); + await db.close(); + + rimraf.sync(OUTPUT_DIRECTORY); + }); + + it('fails if --input missing', async () => { + try { + await execAsync( + 'node -r esbuild-register scripts/bulk-mailer --method sendVerifyEmail', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --input file missing', async () => { + try { + await execAsync( + 'node -r esbuild-register scripts/bulk-mailer --input does_not_exist --method sendVerifyEmail', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --method missing', async () => { + try { + await execAsync( + 'node -r esbuild-register scripts/bulk-mailer --input ${USER_DUMP_PATH}', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --method is invalid', async () => { + try { + await execAsync( + 'node -r esbuild-register scripts/bulk-mailer --input ${USER_DUMP_PATH} --method doesNotExist', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('succeeds with valid input file and method, writing files to disk', async () => { + await execAsync( + `node -r esbuild-register scripts/bulk-mailer --input ${USER_DUMP_PATH} --method sendPasswordChangedEmail --write ${OUTPUT_DIRECTORY}`, + execOptions + ); + + expect( + fs.existsSync( + path.join(OUTPUT_DIRECTORY, `${account1Mock.email}.headers`) + ) + ).toBe(true); + expect( + fs.existsSync(path.join(OUTPUT_DIRECTORY, `${account1Mock.email}.html`)) + ).toBe(true); + expect( + fs.existsSync(path.join(OUTPUT_DIRECTORY, `${account1Mock.email}.txt`)) + ).toBe(true); + + // emails are in english + const test1Html = fs + .readFileSync(path.join(OUTPUT_DIRECTORY, `${account1Mock.email}.html`)) + .toString(); + expect(test1Html).toContain('Password changed successfully'); + const test1Text = fs + .readFileSync(path.join(OUTPUT_DIRECTORY, `${account1Mock.email}.txt`)) + .toString(); + expect(test1Text).toContain('Password changed successfully'); + + expect( + fs.existsSync( + path.join(OUTPUT_DIRECTORY, `${account1Mock.email}.headers`) + ) + ).toBe(true); + expect( + fs.existsSync(path.join(OUTPUT_DIRECTORY, `${account1Mock.email}.html`)) + ).toBe(true); + expect( + fs.existsSync(path.join(OUTPUT_DIRECTORY, `${account1Mock.email}.txt`)) + ).toBe(true); + + // emails are in spanish + const test2Html = fs + .readFileSync(path.join(OUTPUT_DIRECTORY, `${account2Mock.email}.html`)) + .toString(); + expect(test2Html).toContain('Has cambiado la contraseña correctamente'); + const test2Text = fs + .readFileSync(path.join(OUTPUT_DIRECTORY, `${account2Mock.email}.txt`)) + .toString(); + expect(test2Text).toContain('Has cambiado la contraseña correctamente'); + }); + + it('succeeds with valid input file and method, writing emails to stdout', async () => { + const output = await execAsync( + `node -r esbuild-register scripts/bulk-mailer --input ${USER_DUMP_PATH} --method sendPasswordChangedEmail`, + execOptions + ); + const result = output.stdout.toString(); + + expect(result).toContain(account1Mock.uid); + expect(result).toContain(account1Mock.email); + expect(result).toContain('Password changed successfully'); + + // For some reason this assert fails locally + // expect(result).toContain(account2Mock.uid); + // expect(result).toContain(account2Mock.email); + // expect(result).toContain("Has cambiado la contraseña correctamente"); + }); + + it('succeeds with valid input file and method, sends', async () => { + await execAsync( + `node -r esbuild-register scripts/bulk-mailer --input ${USER_DUMP_PATH} --method sendVerifyEmail --send`, + execOptions + ); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/check-users.in.spec.ts b/packages/fxa-auth-server/test/scripts/check-users.in.spec.ts new file mode 100644 index 00000000000..2ba65cd0d8c --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/check-users.in.spec.ts @@ -0,0 +1,106 @@ +/* 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 cp from 'child_process'; +import util from 'util'; +import path from 'path'; +import fs from 'fs'; +import { + createTestServer, + TestServerInstance, +} from '../support/helpers/test-server'; + +const execAsync = util.promisify(cp.exec); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Client = require('../client')(); + +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: { + ...process.env, + PATH: process.env.PATH || '', + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +const PASSWORD_VALID = 'password'; + +describe('#integration - scripts/check-users:', () => { + let server: TestServerInstance; + let validClient: any; + let invalidClient: any; + let filename: string; + + beforeAll(async () => { + server = await createTestServer(); + + validClient = await Client.create( + server.publicUrl, + server.uniqueEmail(), + PASSWORD_VALID, + { version: '' } + ); + invalidClient = await Client.create( + server.publicUrl, + server.uniqueEmail(), + PASSWORD_VALID, + { version: '' } + ); + + // Write the test accounts to a file that will be used to verify the script + let csvData = `${validClient.email}:${PASSWORD_VALID}\n`; + csvData = csvData + `${invalidClient.email}:wrong_password\n`; + csvData = csvData + `invalid@email.com:wrong_password\n`; + filename = `./test/scripts/fixtures/${Math.random()}_two_email_passwords.txt`; + fs.writeFileSync(filename, csvData); + }); + + afterAll(async () => { + await server.stop(); + if (filename && fs.existsSync(filename)) { + fs.unlinkSync(filename); + } + }); + + it('fails if no input file', async () => { + try { + await execAsync( + 'node -r esbuild-register scripts/check-users', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('creates csv file with user stats', async () => { + const outfile = `./test/scripts/fixtures/${Math.random()}_stats.csv`; + await execAsync( + `node -r esbuild-register scripts/check-users -i ${filename} -o ${outfile}`, + execOptions + ); + + // Verify the output file was created and its content are correct + const data = fs.readFileSync(outfile, 'utf8'); + const usersStats = data.split('\n'); + + expect(usersStats.length).toBe(4); + + // Verify the first line is the header + expect(usersStats[0]).toContain( + 'email,exists,passwordMatch,mfaEnabled,keysChangedAt,profileChangedAt,hasSecondaryEmails,isPrimaryEmailVerified' + ); + + // Verify the user stats are correct + expect(usersStats[1]).toContain(`${validClient.email},true,true`); // User exists and matches password + expect(usersStats[2]).toContain(`${invalidClient.email},true,false`); // User exists and doesn't match password + expect(usersStats[3]).toContain('invalid@email.com,false'); // User does not exist + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/convert-customers-to-stripe-automatic-tax.in.spec.ts b/packages/fxa-auth-server/test/scripts/convert-customers-to-stripe-automatic-tax.in.spec.ts new file mode 100644 index 00000000000..1c21003de95 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/convert-customers-to-stripe-automatic-tax.in.spec.ts @@ -0,0 +1,29 @@ +/* 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 cp from 'child_process'; +import util from 'util'; +import path from 'path'; + +const execAsync = util.promisify(cp.exec); +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: { + ...process.env, + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +describe('starting script - convert-customers-to-stripe-automatic-tax', () => { + it('does not fail', async () => { + await execAsync( + 'node -r esbuild-register scripts/convert-customers-to-stripe-automatic-tax.ts --help', + execOptions + ); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/delete-account.in.spec.ts b/packages/fxa-auth-server/test/scripts/delete-account.in.spec.ts new file mode 100644 index 00000000000..b67d80fd04d --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/delete-account.in.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/. */ + +const ROOT_DIR = '../..'; + +const cp = require('child_process'); +const util = require('util'); +const path = require('path'); + +const execAsync = util.promisify(cp.exec); + +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: { + ...process.env, + PATH: process.env.PATH || '', + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +describe('#integration - scripts/delete-account:', () => { + it('does not fail', async () => { + await execAsync( + 'node -r esbuild-register scripts/delete-account', + execOptions + ); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.in.spec.ts b/packages/fxa-auth-server/test/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.in.spec.ts new file mode 100644 index 00000000000..39a38391ba5 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.in.spec.ts @@ -0,0 +1,91 @@ +/* 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 childProcess from 'child_process'; +import util from 'util'; +import path from 'path'; + +const exec = util.promisify(childProcess.exec); +const ROOT_DIR = '../../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: process.env, +}; + +const command = [ + 'node', + '-r esbuild-register', + 'scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.ts', +]; + +describe('enqueue inactive account deletions script', () => { + // combining tests because forking a process to run the script is a little + // slow + it('has correct defaults', async () => { + const getOutputValue = (lines: string[], needle: string) => { + const line = lines.find((line) => line.startsWith(needle)); + return line?.split(': ')[1]; + }; + + const cmd = [...command, '--bq-dataset fxa-dev.inactives-testo']; + const { stdout } = await exec(cmd.join(' '), execOptions); + const outputLines = stdout.split('\n'); + + expect(stdout).toContain('Dry run mode is on.'); + + const now = new Date(); + const activeByDateString = getOutputValue(outputLines, 'Active by'); + const activeByDate = new Date(activeByDateString || ''); + const nowish = activeByDate.setFullYear(activeByDate.getFullYear() + 2); + const diff = Math.abs(now.valueOf() - nowish.valueOf()); + expect(diff).toBeLessThanOrEqual(1000); + + const startDateString = getOutputValue(outputLines, 'Start date'); + expect(startDateString?.startsWith('2012-03-12')).toBe(true); + + const daysTilFirstEmailString = getOutputValue(outputLines, "Days 'til"); + expect(daysTilFirstEmailString).toBe('0'); + + const dbResultsLimitString = getOutputValue(outputLines, 'Per MySQL query'); + expect(dbResultsLimitString).toBe('500000'); + }); + + it( + 'requires an BQ dataset id', + async () => { + try { + await exec(command.join(' '), execOptions); + throw new Error('Expected script to fail without a BQ dataset id'); + } catch (err: any) { + expect(err.code).toBe(1); + expect(err.stderr).toContain('BigQuery dataset ID is required.'); + } + + const cmd = [...command, '--bq-dataset fxa-dev.inactives-testo']; + await exec(cmd.join(' '), execOptions); + }, + 30 * 1000 + ); + + it('requires the end date to be the same or later than the start date', async () => { + try { + const cmd = [ + ...command, + '--end-date 2020-12-22', + '--start-date 2021-12-22', + '--bq-dataset fxa-dev.inactives-testo', + ]; + await exec(cmd.join(' '), execOptions); + throw new Error( + 'Expected script to fail with end date before start date' + ); + } catch (err: any) { + expect(err.code).toBe(1); + expect(err.stderr).toContain( + 'The end date must be on the same day or later than the start date.' + ); + } + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/delete-unverified-accounts.in.spec.ts b/packages/fxa-auth-server/test/scripts/delete-unverified-accounts.in.spec.ts new file mode 100644 index 00000000000..df4ffb305ea --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/delete-unverified-accounts.in.spec.ts @@ -0,0 +1,90 @@ +/* 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 childProcess from 'child_process'; +import util from 'util'; +import path from 'path'; + +const exec = util.promisify(childProcess.exec); +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: { + ...process.env, + NODE_ENV: 'dev', + STRIPE_API_KEY: 'sk_test_dummy', + SUBHUB_STRIPE_APIKEY: 'sk_test_dummy', + }, +}; + +const command = [ + 'node', + '-r esbuild-register', + 'scripts/delete-unverified-accounts.ts', +]; + +describe('enqueue delete unverified account tasks script', () => { + it('needs uid, email, or date range', async () => { + try { + await exec(command.join(' '), execOptions); + } catch (err: any) { + expect(err.stderr).toContain( + 'The program needs at least a uid, an email, or valid date range.' + ); + } + }); + + it('allows uid/email or date range but not both', async () => { + try { + const cmd = [ + ...command, + '--uid 0f0f0f', + '--email testo@example.gg', + '--start-date 2022-11-22', + '--end-date 2022-11-30', + ]; + await exec(cmd.join(' '), execOptions); + } catch (err: any) { + expect(err.stderr).toContain( + 'the script does not support uid/email arguments and a date range' + ); + } + }); + + it('needs a positive integer for the limit', async () => { + try { + const cmd = [ + ...command, + '--start-date 2022-11-22', + '--end-date 2022-11-30', + '--limit null', + ]; + await exec(cmd.join(' '), execOptions); + } catch (err: any) { + expect(err.stderr).toContain('The limit should be a positive integer.'); + } + }); + + it('executes in dry-run mode by default', async () => { + const cmd = [ + ...command, + '--start-date 2022-11-22', + '--end-date 2022-11-30', + ]; + const { stdout } = await exec(cmd.join(' '), execOptions); + expect(stdout).toContain('Dry run mode is on.'); + }); + + it('warns about table scan', async () => { + const cmd = [ + ...command, + '--start-date 2022-11-22', + '--end-date 2022-11-30', + '--dry-run=false', + ]; + const { stdout } = await exec(cmd.join(' '), execOptions); + expect(stdout).toContain('Please call with --table-scan if you are sure.'); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/dump-users.in.spec.ts b/packages/fxa-auth-server/test/scripts/dump-users.in.spec.ts new file mode 100644 index 00000000000..b57dfd04a34 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/dump-users.in.spec.ts @@ -0,0 +1,302 @@ +/* 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 { promisify } = require('util'); +const cp = require('child_process'); +const path = require('path'); +const mocks = require('../../test/mocks'); +const crypto = require('crypto'); +const fs = require('fs'); + +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +cp.execAsync = promisify(cp.exec); + +const log = mocks.mockLog(); +const config = require('../../config').default.getProperties(); +const Token = require('../../lib/tokens')(log, config); +const UnblockCode = require('../../lib/crypto/random').base32( + config.signinUnblock.codeLength +); + +const zeroBuffer16 = Buffer.from( + '00000000000000000000000000000000', + 'hex' +).toString('hex'); +const zeroBuffer32 = Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' +).toString('hex'); + +function createAccount(email: string, uid: string) { + return { + uid, + email, + emailCode: zeroBuffer16, + emailVerified: false, + verifierVersion: 1, + verifyHash: zeroBuffer32, + authSalt: zeroBuffer32, + kA: zeroBuffer32, + wrapWrapKb: zeroBuffer32, + tokenVerificationId: zeroBuffer16, + }; +} + +const account1Mock = createAccount( + `${Math.random() * 10000}@zmail.com`, + crypto.randomBytes(16).toString('hex') +); +const account2Mock = createAccount( + `${Math.random() * 10000}@zmail.com`, + crypto.randomBytes(16).toString('hex') +); + +const { createDB } = require('../../lib/db'); + +const DB = createDB(config, log, Token, UnblockCode); + +const execOptions = { + cwd, + env: { + ...process.env, + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +describe('#integration - scripts/dump-users', () => { + let db: any, + oneEmailFilename: string, + twoEmailsFilename: string, + oneUidFilename: string, + twoUidsFilename: string; + + beforeAll(async () => { + db = await DB.connect(config); + await db.createAccount(account1Mock); + await db.createAccount(account2Mock); + + const data = `${account1Mock.email}\n`; + oneEmailFilename = `./test/scripts/fixtures/${crypto + .randomBytes(16) + .toString('hex')}_one_email.txt`; + fs.writeFileSync(oneEmailFilename, data); + + const data2 = `${account1Mock.uid}\n`; + oneUidFilename = `./test/scripts/fixtures/${crypto + .randomBytes(16) + .toString('hex')}_one_uid.txt`; + fs.writeFileSync(oneUidFilename, data2); + + const data3 = `${account1Mock.email}\n${account2Mock.email}\n`; + twoEmailsFilename = `./test/scripts/fixtures/${crypto + .randomBytes(16) + .toString('hex')}_two_emails.txt`; + fs.writeFileSync(twoEmailsFilename, data3); + + const data4 = `${account1Mock.uid}\n${account2Mock.uid}\n`; + twoUidsFilename = `./test/scripts/fixtures/${crypto + .randomBytes(16) + .toString('hex')}_two_uids.txt`; + fs.writeFileSync(twoUidsFilename, data4); + }); + + afterAll(async () => { + await db.close(); + fs.unlinkSync(oneEmailFilename); + fs.unlinkSync(oneUidFilename); + fs.unlinkSync(twoEmailsFilename); + fs.unlinkSync(twoUidsFilename); + }); + + it('fails if neither --emails nor --uids is specified', async () => { + try { + await cp.execAsync( + 'node -r esbuild-register scripts/dump-users', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if both --emails nor --uids are specified', async () => { + try { + await cp.execAsync( + 'node -r esbuild-register scripts/dump-users --emails --uids', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --emails specified w/o list of emails or --input', async () => { + try { + await cp.execAsync( + 'node -r esbuild-register scripts/dump-users --emails', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --uids specified w/o list of uids or --input', async () => { + try { + await cp.execAsync( + 'node -r esbuild-register scripts/dump-users --uids', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --uids w/ invalid uid', async () => { + try { + await cp.execAsync( + 'node -r esbuild-register scripts/dump-users --uids deadbeef', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('succeeds with --uids and 1 valid uid1', async () => { + const { stdout: output } = await cp.execAsync( + `node -r esbuild-register scripts/dump-users --uids ${account1Mock.uid}`, + execOptions + ); + const result = JSON.parse(output); + expect(result).toHaveLength(1); + + expect(result[0].email).toBe(account1Mock.email); + expect(result[0].uid).toBe(account1Mock.uid); + }); + + it('succeeds with --uids and 2 valid uids', async () => { + const { stdout: output } = await cp.execAsync( + `node -r esbuild-register scripts/dump-users --uids ${account1Mock.uid},${account2Mock.uid}`, + execOptions + ); + const result = JSON.parse(output); + expect(result).toHaveLength(2); + + expect(result[0].email).toBe(account1Mock.email); + expect(result[0].uid).toBe(account1Mock.uid); + + expect(result[1].email).toBe(account2Mock.email); + expect(result[1].uid).toBe(account2Mock.uid); + }); + + it('succeeds with --uids and --input containing 1 uid', async () => { + const { stdout: output } = await cp.execAsync( + `node -r esbuild-register scripts/dump-users --uids --input ${ + '../' + oneUidFilename + }`, + execOptions + ); + const result = JSON.parse(output); + expect(result).toHaveLength(1); + + expect(result[0].email).toBe(account1Mock.email); + expect(result[0].uid).toBe(account1Mock.uid); + }); + + it('succeeds with --uids and --input containing 2 uids', async () => { + const { stdout: output } = await cp.execAsync( + `node -r esbuild-register scripts/dump-users --uids --input ${ + '../' + twoUidsFilename + }`, + execOptions + ); + const result = JSON.parse(output); + expect(result).toHaveLength(2); + + expect(result[0].email).toBe(account1Mock.email); + expect(result[0].uid).toBe(account1Mock.uid); + + expect(result[1].email).toBe(account2Mock.email); + expect(result[1].uid).toBe(account2Mock.uid); + }); + + it('fails if --emails w/ invalid emails', async () => { + try { + await cp.execAsync( + 'node -r esbuild-register scripts/dump-users --emails user3@test.com', + execOptions + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('succeeds with --emails and 1 valid email', async () => { + const { stdout: output } = await cp.execAsync( + `node -r esbuild-register scripts/dump-users --emails ${account1Mock.email}`, + execOptions + ); + const result = JSON.parse(output); + expect(result).toHaveLength(1); + + expect(result[0].email).toBe(account1Mock.email); + expect(result[0].uid).toBe(account1Mock.uid); + }); + + it('succeeds with --emails and 2 valid emails', async () => { + const { stdout: output } = await cp.execAsync( + `node -r esbuild-register scripts/dump-users --emails ${account1Mock.email},${account2Mock.email}`, + execOptions + ); + const result = JSON.parse(output); + expect(result).toHaveLength(2); + + expect(result[0].email).toBe(account1Mock.email); + expect(result[0].uid).toBe(account1Mock.uid); + + expect(result[1].email).toBe(account2Mock.email); + expect(result[1].uid).toBe(account2Mock.uid); + }); + + it('succeeds with --emails and --input containing 1 email', async () => { + const { stdout: output } = await cp.execAsync( + `node -r esbuild-register scripts/dump-users --emails --input ${ + '../' + oneEmailFilename + }`, + execOptions + ); + const result = JSON.parse(output); + expect(result).toHaveLength(1); + + expect(result[0].email).toBe(account1Mock.email); + expect(result[0].uid).toBe(account1Mock.uid); + }); + + it('succeeds with --emails and --input containing 2 email', async () => { + const { stdout: output } = await cp.execAsync( + `node -r esbuild-register scripts/dump-users --emails --input ${ + '../' + twoEmailsFilename + }`, + execOptions + ); + const result = JSON.parse(output); + expect(result).toHaveLength(2); + + expect(result[0].email).toBe(account1Mock.email); + expect(result[0].uid).toBe(account1Mock.uid); + + expect(result[1].email).toBe(account2Mock.email); + expect(result[1].uid).toBe(account2Mock.uid); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/move-customers-to-new-plan-v2.in.spec.ts b/packages/fxa-auth-server/test/scripts/move-customers-to-new-plan-v2.in.spec.ts new file mode 100644 index 00000000000..d54cc91935a --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/move-customers-to-new-plan-v2.in.spec.ts @@ -0,0 +1,29 @@ +/* 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 cp from 'child_process'; +import util from 'util'; +import path from 'path'; + +const execAsync = util.promisify(cp.exec); +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: { + ...process.env, + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +describe('starting script - move-customers-to-new-plan-v2', () => { + it('does not fail', async () => { + await execAsync( + 'node -r esbuild-register scripts/move-customers-to-new-plan-v2.ts --help', + execOptions + ); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/move-customers-to-new-plan.in.spec.ts b/packages/fxa-auth-server/test/scripts/move-customers-to-new-plan.in.spec.ts new file mode 100644 index 00000000000..e3dd51d7692 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/move-customers-to-new-plan.in.spec.ts @@ -0,0 +1,29 @@ +/* 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 cp from 'child_process'; +import util from 'util'; +import path from 'path'; + +const execAsync = util.promisify(cp.exec); +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: { + ...process.env, + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +describe('starting script - move-customers-to-new-plan', () => { + it('does not fail', async () => { + await execAsync( + 'node -r esbuild-register scripts/move-customers-to-new-plan.ts --help', + execOptions + ); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/must-reset.in.spec.ts b/packages/fxa-auth-server/test/scripts/must-reset.in.spec.ts new file mode 100644 index 00000000000..4e186973985 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/must-reset.in.spec.ts @@ -0,0 +1,309 @@ +/* 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 { promisify } = require('util'); +const cp = require('child_process'); +const path = require('path'); +const mocks = require('../../test/mocks'); +const crypto = require('crypto'); +const fs = require('fs'); + +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +cp.execAsync = promisify(cp.exec); + +const log = mocks.mockLog(); +const config = require('../../config').default.getProperties(); +const Token = require('../../lib/tokens')(log, config); +const UnblockCode = require('../../lib/crypto/random').base32( + config.signinUnblock.codeLength +); + +const twoBuffer16 = Buffer.from( + '22222222222222222222222222222222', + 'hex' +).toString('hex'); +const twoBuffer32 = Buffer.from( + '2222222222222222222222222222222222222222222222222222222222222222', + 'hex' +).toString('hex'); + +function createAccount(email: string, uid: string) { + return { + uid, + email, + emailCode: twoBuffer16, + emailVerified: false, + verifierVersion: 1, + verifyHash: twoBuffer32, + authSalt: twoBuffer32, + kA: twoBuffer32, + wrapWrapKb: twoBuffer32, + tokenVerificationId: twoBuffer16, + }; +} + +const account1Mock = createAccount( + `${Math.random() * 10000}@zmail.com`, + crypto.randomBytes(16).toString('hex') +); +const account2Mock = createAccount( + `${Math.random() * 10000}@zmail.com`, + crypto.randomBytes(16).toString('hex') +); + +const { createDB } = require('../../lib/db'); +const DB = createDB(config, log, Token, UnblockCode); + +describe('#integration - scripts/must-reset', () => { + let db: any, + oneEmailFilename: string, + oneUidFilename: string, + twoEmailsFilename: string, + twoUidsFilename: string; + + beforeAll(async () => { + db = await DB.connect(config); + await db.createAccount(account1Mock); + await db.createAccount(account2Mock); + + const data = `${account1Mock.email}\n`; + oneEmailFilename = `./test/scripts/fixtures/${crypto + .randomBytes(16) + .toString('hex')}_one_email.txt`; + fs.writeFileSync(oneEmailFilename, data); + + const data2 = `${account1Mock.uid}\n`; + oneUidFilename = `./test/scripts/fixtures/${crypto + .randomBytes(16) + .toString('hex')}_one_uid.txt`; + fs.writeFileSync(oneUidFilename, data2); + + const data3 = `${account1Mock.email}\n${account2Mock.email}\n`; + twoEmailsFilename = `./test/scripts/fixtures/${crypto + .randomBytes(16) + .toString('hex')}_two_emails.txt`; + fs.writeFileSync(twoEmailsFilename, data3); + + const data4 = `${account1Mock.uid}\n${account2Mock.uid}\n`; + twoUidsFilename = `./test/scripts/fixtures/${crypto + .randomBytes(16) + .toString('hex')}_two_uids.txt`; + fs.writeFileSync(twoUidsFilename, data4); + }); + + afterAll(async () => { + await db.close(); + fs.unlinkSync(oneEmailFilename); + fs.unlinkSync(oneUidFilename); + fs.unlinkSync(twoEmailsFilename); + fs.unlinkSync(twoUidsFilename); + }); + + it('fails if -i is not specified', async () => { + try { + await cp.execAsync( + `node --require esbuild-register scripts/must-reset ${oneEmailFilename}`, + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if neither --emails nor --uids is specified', async () => { + try { + await cp.execAsync( + `node --require esbuild-register scripts/must-reset -i ${oneEmailFilename}`, + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if both --emails and --uids are specified', async () => { + try { + await cp.execAsync( + `node --require esbuild-register scripts/must-reset --emails --uids -i ${oneEmailFilename}`, + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --emails specified w/o --input', async () => { + try { + await cp.execAsync( + 'node --require esbuild-register scripts/must-reset --emails', + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --uids specified w/o --input', async () => { + try { + await cp.execAsync( + 'node --require esbuild-register scripts/must-reset --uids', + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --uids and --input specified w/ file missing', async () => { + try { + await cp.execAsync( + 'node --require esbuild-register scripts/must-reset --uids -input does_not_exist', + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --emails and --input specified w/ file missing', async () => { + try { + await cp.execAsync( + 'node --require esbuild-register scripts/must-reset --emails --input does_not_exist', + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --uids and -i specified w/ invalid uid', async () => { + try { + await cp.execAsync( + 'node --require esbuild-register scripts/must-reset --uids -i ./test/scripts/fixtures/invalid_uid.txt', + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('fails if --emails and -i specified w/ invalid email', async () => { + try { + await cp.execAsync( + 'node --require esbuild-register scripts/must-reset --emails -i ./test/scripts/fixtures/invalid_email.txt', + { + cwd, + } + ); + throw new Error('script should have failed'); + } catch (err: any) { + expect(err.message).toContain('Command failed'); + } + }); + + it('succeeds with --uids and --input containing 1 uid', async () => { + try { + await cp.execAsync( + `node --require esbuild-register scripts/must-reset --uids --input ${oneUidFilename}`, + { + cwd, + } + ); + const account = await db.account(account1Mock.uid); + expect(account.authSalt).toBe( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ); + } catch (err) { + throw new Error('script should have succeeded'); + } + }); + + it('succeeds with --emails and --input containing 1 email', async () => { + try { + await cp.execAsync( + `node --require esbuild-register scripts/must-reset --emails --input ${oneEmailFilename}`, + { + cwd, + } + ); + const account = await db.account(account1Mock.uid); + expect(account.authSalt).toBe( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ); + } catch (err) { + throw new Error('script should have succeeded'); + } + }); + + it('succeeds with --uids and --input containing 2 uids', async () => { + try { + await cp.execAsync( + `node --require esbuild-register scripts/must-reset --uids --input ${twoUidsFilename}`, + { + cwd, + } + ); + + const account1 = await db.account(account1Mock.uid); + const account2 = await db.account(account2Mock.uid); + + expect(account1.authSalt).toBe( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ); + expect(account2.authSalt).toBe( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ); + } catch (err) { + throw new Error('script should have succeeded'); + } + }); + + it('succeeds with --emails and --input containing 2 emails', async () => { + try { + await cp.execAsync( + `node --require esbuild-register scripts/must-reset --emails --input ${twoEmailsFilename}`, + { + cwd, + } + ); + + const account1 = await db.account(account1Mock.uid); + const account2 = await db.account(account2Mock.uid); + + expect(account1.authSalt).toBe( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ); + expect(account2.authSalt).toBe( + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + ); + } catch (err) { + throw new Error('script should have succeeded'); + } + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/prune-oauth-authorization-codes.in.spec.ts b/packages/fxa-auth-server/test/scripts/prune-oauth-authorization-codes.in.spec.ts new file mode 100644 index 00000000000..bc537775cd0 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/prune-oauth-authorization-codes.in.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/. */ + +const ROOT_DIR = '../..'; + +const cp = require('child_process'); +const util = require('util'); +const path = require('path'); + +const execAsync = util.promisify(cp.exec); + +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, +}; + +describe('#integration - scripts/prune-oauth-authorization-codes:', () => { + it('does not fail with no argument', async () => { + return execAsync( + 'node -r esbuild-register scripts/prune-oauth-authorization-codes', + execOptions + ); + }); + + it('does not fail with an argument', async () => { + return execAsync( + 'node -r esbuild-register scripts/prune-oauth-authorization-codes --ttl 600000', + execOptions + ); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/prune-tokens.in.spec.ts b/packages/fxa-auth-server/test/scripts/prune-tokens.in.spec.ts new file mode 100644 index 00000000000..26a4d55f8f6 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/prune-tokens.in.spec.ts @@ -0,0 +1,442 @@ +/* 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 moment = require('moment'); +const util = require('node:util'); +const exec = util.promisify(require('node:child_process').exec); +const path = require('path'); +const mocks = require(`../../test/mocks`); +const crypto = require('crypto'); + +const config = require('../../config').default.getProperties(); +const UnblockCode = require('../../lib/crypto/random').base32( + config.signinUnblock.codeLength +); + +const { Account } = require('fxa-shared/db/models/auth/account'); +const { SessionToken } = require('fxa-shared/db/models/auth/session-token'); +const { + PasswordChangeToken, +} = require('fxa-shared/db/models/auth/password-change-token'); +const { + PasswordForgotToken, +} = require('fxa-shared/db/models/auth/password-forgot-token'); +const { AccountResetToken, Device } = require('fxa-shared/db/models/auth'); +const { UnblockCodes } = require('fxa-shared/db/models/auth/unblock-codes'); +const { SignInCodes } = require('fxa-shared/db/models/auth/sign-in-codes'); +const { uuidTransformer } = require('fxa-shared/db/transformers'); + +const log = mocks.mockLog(); +const Token = require('../../lib/tokens')(log, config); +const { createDB } = require('../../lib/db'); +const DB = createDB(config, log, Token, UnblockCode); + +const redis = require('../../lib/redis')( + { + ...config.redis, + ...config.redis.sessionTokens, + maxttl: 1337, + }, + mocks.mockLog() +); + +describe('#integration - scripts/prune-tokens', () => { + let db: any; + + const toRandomBuff = (size: number) => + uuidTransformer.to(crypto.randomBytes(size).toString('hex')); + const toZeroBuff = (size: number) => + Buffer.from(Array(size).fill(0), 'hex').toString('hex'); + + const cwd = path.resolve(__dirname, '../..'); + + // Use a really big number for max age. + const maxAge = 10000; + + // Set createdAt 1 day before maxAge + const createdAt = + Date.now() - + moment.duration(maxAge, 'days').asMilliseconds() - + moment.duration(1, 'day').asMilliseconds(); + const uid = uuidTransformer.to(crypto.randomBytes(16).toString('hex')); + const email = `${crypto.randomBytes(16).toString('hex')}blue@test.com`; + const account = { + uid, + createdAt, + email, + emailCode: toZeroBuff(16), + normalizedEmail: email, + emailVerified: false, + verifierVersion: 1, + verifyHash: toZeroBuff(32), + authSalt: toZeroBuff(32), + kA: toZeroBuff(32), + wrapWrapKb: toZeroBuff(32), + verifierSetAt: createdAt, + locale: 'en-US', + }; + + const sessionToken = () => ({ + id: toRandomBuff(32), + data: toRandomBuff(32), + tokenVerificationId: null, + uid, + createdAt, + lastAccessTime: Date.now(), + location: { + city: 'pdx', + state: 'or', + stateCode: 'or', + country: 'usa', + countryCode: 'usa', + }, + uaBrowser: '', + uaBrowserVersion: '', + uaOS: '', + uaOSVersion: '', + uaDeviceType: '', + uaFormFactor: '', + }); + + function serialize(t: any) { + return { + ...t, + ...{ + id: t.id.toString('hex'), + data: t.data.toString('hex'), + uid: t.uid.toString('hex'), + }, + }; + } + + const device = (uid: any, sessionTokenId: any) => ({ + id: toRandomBuff(16), + uid, + sessionTokenId, + refreshTokenId: null, + name: null, + type: null, + createdAt: Date.now(), + pushCallback: null, + pushPublicKey: null, + pushAuthKey: null, + availableCommands: null, + }); + + const passwordChangeToken = { + id: toRandomBuff(32), + data: toRandomBuff(32), + uid, + createdAt, + }; + + const passwordForgotToken = { + id: toRandomBuff(32), + data: toRandomBuff(32), + passCode: toRandomBuff(3), + tries: 0, + uid, + createdAt, + }; + + const accountResetToken = { + tokenId: toRandomBuff(32), + tokenData: toRandomBuff(32), + uid, + createdAt, + }; + + const unblockCode = { + unblockCodeHash: toRandomBuff(32), + uid, + createdAt, + }; + + const signInCode = { + hash: toRandomBuff(32), + flowid: toRandomBuff(32), + uid, + createdAt, + }; + + async function clearDb() { + await Device.knexQuery().where({ uid }).del(); + await SessionToken.knexQuery().where({ uid }).del(); + await PasswordChangeToken.knexQuery().where({ uid }).del(); + await PasswordForgotToken.knexQuery().where({ uid }).del(); + await AccountResetToken.knexQuery().where({ uid }).del(); + await UnblockCodes.knexQuery().where({ uid }).del(); + await SignInCodes.knexQuery().where({ uid }).del(); + } + + beforeAll(async () => { + db = await DB.connect( + Object.assign({}, config, { log: { level: 'error' } }) + ); + await clearDb(); + await Account.create(account); + }); + + afterAll(async () => { + await db.deleteAccount(account); + await clearDb(); + await db.close(); + await redis.close(); + }); + + it('prints help', async () => { + const { stdout } = await exec( + 'NODE_ENV=dev node -r esbuild-register scripts/prune-tokens.ts --help', + { + cwd, + } + ); + + expect(/Usage:/.test(stdout)).toBe(true); + }); + + it('prints warnings when args are missing', async () => { + const { stderr } = await exec( + `NODE_ENV=dev node -r esbuild-register scripts/prune-tokens.ts `, + { + cwd, + shell: '/bin/bash', + } + ); + expect(/skipping limit sessions operation./.test(stderr)).toBe(true); + expect(/skipping token pruning operation./.test(stderr)).toBe(true); + }); + + it('parses args', async () => { + const { stderr } = await exec( + `NODE_ENV=dev node -r esbuild-register scripts/prune-tokens.ts --maxTokenAge=0 --maxTokenAgeWindowSize=0 --maxCodeAge=0 --maxSessions=0 --maxSessionsMaxAccounts=0 --maxSessionsMaxDeletions=0 --maxSessionsBatchSize=0 --wait=1`, + { + cwd, + shell: '/bin/bash', + } + ); + + expect(stderr).toMatch(/"maxTokenAge":"0"/); + expect(stderr).toMatch(/"maxCodeAge":"0"/); + expect(stderr).toMatch(/"maxSessions":"0"/); + expect(stderr).toMatch(/"maxSessionsMaxAccounts":"0"/); + expect(stderr).toMatch(/"maxSessionsMaxDeletions":"0"/); + expect(stderr).toMatch(/"maxSessionsBatchSize":"0"/); + expect(stderr).toMatch(/"maxTokenAgeWindowSize":"0"/); + expect(stderr).toMatch(/"wait":"1"/); + }); + + describe('prune tokens', () => { + let token: any; + + beforeAll(async () => { + await clearDb(); + + token = sessionToken(); + await SessionToken.create(token); + await PasswordChangeToken.create(passwordChangeToken); + await PasswordForgotToken.create(passwordForgotToken); + await AccountResetToken.knexQuery().insert(accountResetToken); + await UnblockCodes.knexQuery().insert(unblockCode); + await SignInCodes.knexQuery().insert(signInCode); + await redis.touchSessionToken(uid.toString('hex'), serialize(token)); + }); + + afterAll(async () => { + await clearDb(); + await redis.del(uid.toString('hex')); + }); + + it('prunes tokens', async () => { + // Note that logger output, directs to standard err. + const { stderr } = await exec( + `NODE_ENV=dev node -r esbuild-register scripts/prune-tokens.ts '--maxTokenAge=${maxAge}-days' '--maxCodeAge=${maxAge}-days' `, + { + cwd, + shell: '/bin/bash', + } + ); + + expect(/"@passwordForgotTokensDeleted":1/.test(stderr)).toBe(true); + expect(/"@passwordChangeTokensDeleted":1/.test(stderr)).toBe(true); + expect(/"@accountResetTokensDeleted":1/.test(stderr)).toBe(true); + expect(/"@sessionTokensDeleted":1/.test(stderr)).toBe(true); + expect(/"@unblockCodesDeleted":1/.test(stderr)).toBe(true); + expect(/"@signInCodesDeleted":1/.test(stderr)).toBe(true); + expect(/pruning orphaned sessions in redis/.test(stderr)).toBe(true); + + const redisTokens = await redis.getSessionTokens(uid.toString('hex')); + expect(Object.keys(redisTokens).length).toBe(0); + expect(await SessionToken.findByTokenId(token.id)).toBeNull(); + expect( + await PasswordChangeToken.findByTokenId(passwordChangeToken.id) + ).toBeNull(); + expect( + await PasswordForgotToken.findByTokenId(passwordForgotToken.id) + ).toBeNull(); + expect( + await AccountResetToken.findByTokenId(accountResetToken.tokenId) + ).toBeNull(); + expect( + await UnblockCodes.knexQuery().where({ uid: unblockCode.uid }) + ).toHaveLength(0); + expect( + await SignInCodes.knexQuery().where({ uid: signInCode.uid }) + ).toHaveLength(0); + }); + }); + + describe('limits sessions', () => { + const size = 20; + let tokens: any[] = []; + let devices: any[] = []; + + const sessionAt = (i: number) => + SessionToken.findByTokenId(tokens.at(i).id); + + const deviceAt = (i: number) => + Device.findByPrimaryKey(devices.at(i).uid, devices.at(i).id); + + const sessionCount = async () => + (await SessionToken.knexQuery().where({ uid }).count())[0]['count(*)']; + + const deviceCount = async () => + (await Device.knexQuery().where({ uid }).count())[0]['count(*)']; + + beforeEach(async () => { + await clearDb(); + await redis.del(uid.toString('hex')); + tokens = []; + devices = []; + + // Add tokens. The first token will be the oldest, and the last token + // will be the newest. + for (let i = 0; i < size; i++) { + const curToken = sessionToken(); + const curDevice = device(account.uid, curToken.id); + curToken.createdAt = Date.now(); + + await SessionToken.create(curToken); + await Device.create(curDevice); + await new Promise((r) => setTimeout(r, 10)); + + await redis.touchSessionToken( + curToken.uid.toString('hex'), + serialize(curToken) + ); + + tokens.push(curToken); + devices.push(curDevice); + } + + // Check initial DB state is correct + expect(await sessionAt(0)).not.toBeNull(); + expect(await sessionAt(-1)).not.toBeNull(); + expect(await deviceAt(0)).not.toBeNull(); + expect(await deviceAt(-1)).not.toBeNull(); + expect(await sessionCount()).toBe(size); + expect(await deviceCount()).toBe(size); + }); + + afterEach(async () => { + await clearDb(); + await redis.del(uid.toString('hex')); + }); + + async function testScript( + args: string, + opts: { remaining: number; totalDeletions: number } + ) { + // Note that logger output, directs to standard err. + const { stderr } = await exec( + `NODE_ENV=dev node -r esbuild-register scripts/prune-tokens.ts ${args}`, + { + cwd, + shell: '/bin/bash', + } + ); + + // Get the remaining redis tokens + const redisTokens = await redis.getSessionTokens(uid.toString('hex')); + + // Expected counts + expect(await sessionCount()).toBe(opts.remaining); + expect(await deviceCount()).toBe(opts.remaining); + expect(Object.keys(redisTokens).length).toBe(opts.remaining); + + // Expected program output. Note that there are two deletions, + // one for the sessionToken and one for the device. + if (opts.totalDeletions > 0) { + expect( + new RegExp( + 'limit sessions complete.*"deletions":' + opts.totalDeletions + ).test(stderr) + ).toBe(true); + + expect(/pruning orphaned sessions/.test(stderr)).toBe(true); + } + + // Expect that oldest session & device were removed + for (let i = 0; i < size - opts.remaining; i++) { + expect(await sessionAt(i)).toBeNull(); + expect(await deviceAt(i)).toBeNull(); + expect(redisTokens[tokens.at(i).id]).toBeUndefined(); + } + + // Expect that the first set of sessions & devices are intact + for (let i = opts.remaining; i < size; i++) { + expect(await sessionAt(i)).not.toBeNull(); + expect(await deviceAt(i)).not.toBeNull(); + expect(await redisTokens[tokens.at(i).id]).not.toBeNull(); + } + } + + it('limits with --maxSessionsBatchSize=1000', async () => { + await testScript( + `--maxSessions=10 --maxSessionsBatchSize=1000 --wait=10 `, + { + remaining: size - 10, + totalDeletions: 10 * 2, + } + ); + }); + + it('limits with --maxSessionsBatchSize=2', async () => { + await testScript(`--maxSessions=10 --maxSessionsBatchSize=2 --wait=10`, { + remaining: size - 10, + totalDeletions: 10 * 2, + }); + }); + + it('limits with --maxSessionsMaxDeletions=2', async () => { + await testScript( + `--maxSessions=10 --maxSessionsMaxDeletions=2 --maxSessionsBatchSize=2 --wait=10`, + { + remaining: size - 2, + totalDeletions: 2 * 2, + } + ); + }); + + it('limits with --maxSessionsMaxDeletions=0', async () => { + await testScript( + `--maxSessions=10 --maxSessionsMaxDeletions=0 --wait=10`, + { + remaining: size, + totalDeletions: 0, + } + ); + }); + + it('limits with --maxSessionsMaxAccounts=0', async () => { + await testScript( + `--maxSessions=10 --maxSessionsMaxAccounts=0 --wait=10`, + { + remaining: size, + totalDeletions: 0, + } + ); + }); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/recorded-future/check-and-reset.in.spec.ts b/packages/fxa-auth-server/test/scripts/recorded-future/check-and-reset.in.spec.ts new file mode 100644 index 00000000000..ea987d00621 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/recorded-future/check-and-reset.in.spec.ts @@ -0,0 +1,72 @@ +/* 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 childProcess from 'child_process'; +import util from 'util'; +import path from 'path'; + +const exec = util.promisify(childProcess.exec); +const ROOT_DIR = '../../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: process.env, +}; + +const command = [ + 'node', + '-r esbuild-register', + 'scripts/recorded-future/check-and-reset.ts', +]; + +describe('#integration - recorded future credentials search and account reset script', () => { + const getOutputValue = (lines: string[], needle: string) => { + const line = lines.find((line) => line.startsWith(needle)); + return line?.split(': ')[1]; + }; + + it('has correct defaults', async () => { + const now = Date.now(); + + // passing in an email so that the script won't try to use the Recorded Future API, which we are not set up in this context + const cmd = [...command, `--email testo@example.gg`]; + const { stdout } = await exec(cmd.join(' '), execOptions); + const outputLines = stdout.split('\n'); + + expect(stdout).toContain('Dry run mode is on.'); + + const expectedDate = new Date(now - 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0]; + + const searchDomain = getOutputValue(outputLines, 'Domains'); + expect(searchDomain).toBe('accounts.firefox.com'); + + const filter = getOutputValue(outputLines, 'Filter'); + const firstDownloadedDateGte = filter?.substring(filter.length - 10); + expect(firstDownloadedDateGte).toBe(expectedDate); + + const limit = getOutputValue(outputLines, 'Limit'); + expect(limit).toBe('500'); + }); + + it('uses given arguments', async () => { + const expectedDate = '2025-01-01'; + const cmd = [ + ...command, + `--first-downloaded-date ${expectedDate}`, + '--email testo@example.gg', + '--search-domain accounts.firefox.com', + '--search-domain allizom.com', + ]; + const { stdout } = await exec(cmd.join(' '), execOptions); + const outputLines = stdout.split('\n'); + + const searchDomains = getOutputValue(outputLines, 'Domains'); + expect(searchDomains).toBe('accounts.firefox.com, allizom.com'); + const filter = getOutputValue(outputLines, 'Filter'); + const firstDownloadedDateGte = filter?.substring(filter.length - 10); + expect(firstDownloadedDateGte).toBe(expectedDate); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts b/packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts new file mode 100644 index 00000000000..37a55180d7d --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts @@ -0,0 +1,579 @@ +/* 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.setTimeout(60000); + +import sinon from 'sinon'; +import fs from 'fs'; +import { Container } from 'typedi'; +import { deleteCollection, deepCopy } from '../local/payments/util'; +import { AuthFirestore, AuthLogger, AppConfig } from '../../lib/types'; +import { setupFirestore } from '../../lib/firestore-db'; +import { PaymentConfigManager } from '../../lib/payments/configuration/manager'; + +const plan = require('fxa-auth-server/test/local/payments/fixtures/stripe/plan2.json'); +const product = require('fxa-shared/test/fixtures/stripe/product1.json'); +const { mockLog, mockStripeHelper } = require('../mocks'); + +const PLAN_EN_LANG_ERROR = 'Plan specific en metadata'; +const GOOGLE_ERROR_MESSAGE = 'Google Translate Error Overload'; +const googleTranslateShapedError = { + code: 403, + message: GOOGLE_ERROR_MESSAGE, + response: { + request: { + href: 'https://translation.googleapis.com/language/translate/v2/detect', + }, + }, +}; + +const langFromMetadataStub = sinon.stub().callsFake((plan: any) => { + if (plan.nickname.includes('es-ES')) { + return 'es-ES'; + } + if (plan.nickname.includes('fr')) { + return 'fr'; + } + if (plan.nickname === 'localised en plan') { + throw new Error(PLAN_EN_LANG_ERROR); + } + if (plan.nickname === 'you cannot translate this') { + throw googleTranslateShapedError; + } + return 'en'; +}); + +jest.mock( + '../../scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser', + () => ({ + getLanguageTagFromPlanMetadata: langFromMetadataStub, + PLAN_EN_LANG_ERROR: 'Plan specific en metadata', + }) +); + +// Must import after jest.mock so the mock is in place +const { + StripeProductsAndPlansConverter, +} = require('../../scripts/stripe-products-and-plans-to-firestore-documents/stripe-products-and-plans-converter'); + +const sandbox = sinon.createSandbox(); +const mockSupportedLanguages = ['es-ES', 'fr']; + +describe('#integration - convert', () => { + let converter: any; + let paymentConfigManager: any; + let productConfigDbRef: any; + let planConfigDbRef: any; + const mockConfig = { + authFirestore: { + prefix: 'mock-fxa-', + }, + subscriptions: { + playApiServiceAccount: { + credentials: { + clientEmail: 'mock-client-email', + }, + keyFile: 'mock-private-keyfile', + }, + productConfigsFirestore: { + schemaValidation: { + cdnUrlRegex: ['^http'], + }, + }, + }, + }; + let products: any; + let plans: any; + let args: any; + const product1 = { + ...deepCopy(product), + metadata: { + ...deepCopy(product.metadata), + 'product:privacyNoticeURL': 'http://127.0.0.1:8080/', + 'product:termsOfServiceURL': 'http://127.0.0.1:8080/', + 'product:termsOfServiceDownloadURL': 'http://127.0.0.1:8080/', + }, + id: 'prod_123', + }; + const productConfig1 = { + active: true, + stripeProductId: product1.id, + capabilities: { + '*': ['testForAllClients', 'foo'], + dcdb5ae7add825d2: ['123donePro', 'gogogo'], + }, + locales: {}, + productSet: ['123done'], + styles: { + webIconBackground: 'lime', + }, + support: {}, + uiContent: {}, + urls: { + successActionButton: 'http://127.0.0.1:8080/', + privacyNotice: 'http://127.0.0.1:8080/', + termsOfService: 'http://127.0.0.1:8080/', + termsOfServiceDownload: 'http://127.0.0.1:8080/', + webIcon: 'https://123done-stage.dev.lcip.org/img/transparent-logo.png', + emailIcon: 'https://123done-stage.dev.lcip.org/img/transparent-logo.png', + }, + }; + const product2 = deepCopy({ ...product1, id: 'prod_456' }); + const productConfig2 = deepCopy({ + ...productConfig1, + stripeProductId: product2.id, + }); + const plan1 = deepCopy({ + ...plan, + metadata: { + 'capabilities:aFakeClientId12345': 'more, comma, separated, values', + upgradeCTA: 'hello world', + productOrder: '2', + productSet: 'foo', + successActionButtonURL: 'https://example.com/download', + }, + id: 'plan_123', + }); + const planConfig1 = { + active: true, + stripePriceId: plan1.id, + capabilities: { + aFakeClientId12345: ['more', 'comma', 'separated', 'values'], + }, + uiContent: { + upgradeCTA: 'hello world', + }, + urls: { + successActionButton: 'https://example.com/download', + }, + productOrder: 2, + productSet: ['foo'], + }; + const plan2 = deepCopy({ ...plan1, id: 'plan_456' }); + const planConfig2 = { ...deepCopy(planConfig1), stripePriceId: plan2.id }; + const plan3 = deepCopy({ ...deepCopy(plan1), id: 'plan_789' }); + const planConfig3 = { ...deepCopy(planConfig1), stripePriceId: plan3.id }; + const plan4 = deepCopy({ + ...plan1, + id: 'plan_infinity', + nickname: 'localised en plan', + }); + const planConfig4 = { + ...deepCopy(planConfig1), + stripePriceId: plan4.id, + locales: { + en: { + support: {}, + uiContent: { + upgradeCTA: 'hello world', + }, + urls: { + successActionButton: 'https://example.com/download', + }, + }, + }, + }; + const plan5 = deepCopy({ + ...plan1, + id: 'plan_googol', + nickname: 'you cannot translate this', + }); + + beforeEach(() => { + mockLog.error = sandbox.fake.returns({}); + mockLog.info = sandbox.fake.returns({}); + mockLog.debug = sandbox.fake.returns({}); + const firestore = setupFirestore(mockConfig); + Container.set(AuthFirestore, firestore); + Container.set(AuthLogger, {}); + Container.set(AppConfig, mockConfig); + paymentConfigManager = new PaymentConfigManager(); + Container.set(PaymentConfigManager, paymentConfigManager); + productConfigDbRef = paymentConfigManager.productConfigDbRef; + planConfigDbRef = paymentConfigManager.planConfigDbRef; + converter = new StripeProductsAndPlansConverter({ + log: mockLog, + stripeHelper: mockStripeHelper, + supportedLanguages: mockSupportedLanguages, + }); + args = { + productId: '', + isDryRun: false, + target: 'firestore', + targetDir: 'home/dir', + }; + async function* productGenerator() { + yield product1; + yield product2; + } + async function* planGenerator1() { + yield plan1; + yield plan2; + yield plan4; + } + async function* planGenerator2() { + yield plan3; + } + converter.stripeHelper.stripe = { + products: { list: sandbox.stub().returns(productGenerator()) }, + plans: { + list: sandbox + .stub() + .onFirstCall() + .returns(planGenerator1()) + .onSecondCall() + .returns(planGenerator2()), + }, + }; + }); + + afterEach(async () => { + await deleteCollection( + paymentConfigManager.firestore, + productConfigDbRef, + 100 + ); + await deleteCollection( + paymentConfigManager.firestore, + planConfigDbRef, + 100 + ); + Container.reset(); + sandbox.reset(); + }); + + it('processes new products and plans', async () => { + await converter.convert(args); + products = await paymentConfigManager.allProducts(); + plans = await paymentConfigManager.allPlans(); + // We don't care what the values of the Firestore doc IDs as long + // as they match the expected productConfigId for planConfigs. + expect(products[0]).toEqual({ + ...productConfig1, + id: products[0].id, + }); + expect(products[1]).toEqual({ + ...productConfig2, + id: products[1].id, + }); + expect(plans[0]).toEqual({ + ...planConfig1, + id: plans[0].id, + productConfigId: products[0].id, + }); + expect(plans[1]).toEqual({ + ...planConfig2, + id: plans[1].id, + productConfigId: products[0].id, + }); + expect(plans[2]).toEqual({ + ...planConfig4, + id: plans[2].id, + productConfigId: products[0].id, + }); + expect(plans[3]).toEqual({ + ...planConfig3, + id: plans[3].id, + productConfigId: products[1].id, + }); + }); + + it('updates existing products and plans', async () => { + // Put some configs into Firestore + const productConfigDocId1 = + await paymentConfigManager.storeProductConfig(productConfig1); + await paymentConfigManager.storePlanConfig( + planConfig1, + productConfigDocId1 + ); + products = await paymentConfigManager.allProducts(); + plans = await paymentConfigManager.allPlans(); + expect(products[0]).toEqual({ + ...productConfig1, + id: products[0].id, + }); + expect(plans[0]).toEqual({ + ...planConfig1, + id: plans[0].id, + productConfigId: products[0].id, + }); + // Now update one product and one plan + const updatedProduct = { + ...deepCopy(product1), + metadata: { + ...product1.metadata, + webIconBackground: 'pink', + }, + }; + const updatedProductConfig = { + ...deepCopy(productConfig1), + styles: { + webIconBackground: 'pink', + }, + }; + const updatedPlan = { + ...deepCopy(plan1), + metadata: { + ...deepCopy(plan1.metadata), + 'product:privacyNoticeURL': 'https://privacy.com', + }, + }; + const updatedPlanConfig = { + ...deepCopy(planConfig1), + urls: { + ...planConfig1.urls, + privacyNotice: 'https://privacy.com', + }, + }; + async function* productGeneratorUpdated() { + yield updatedProduct; + } + async function* planGeneratorUpdated() { + yield updatedPlan; + } + converter.stripeHelper.stripe = { + products: { list: sandbox.stub().returns(productGeneratorUpdated()) }, + plans: { list: sandbox.stub().returns(planGeneratorUpdated()) }, + }; + await converter.convert(args); + products = await paymentConfigManager.allProducts(); + plans = await paymentConfigManager.allPlans(); + expect(products[0]).toEqual({ + ...updatedProductConfig, + id: products[0].id, + }); + expect(plans[0]).toEqual({ + ...updatedPlanConfig, + id: plans[0].id, + productConfigId: products[0].id, + }); + }); + + it('processes only the product with productId when passed', async () => { + await converter.convert({ ...args, productId: product1.id }); + sinon.assert.calledOnceWithExactly( + converter.stripeHelper.stripe.products.list, + { ids: [product1.id] } + ); + }); + + it('processes successfully and writes to file', async () => { + const stubFsAccess = sandbox.stub(fs.promises, 'access').resolves(); + paymentConfigManager.storeProductConfig = sandbox.stub(); + paymentConfigManager.storePlanConfig = sandbox.stub(); + converter.writeToFileProductConfig = sandbox.stub().resolves(); + converter.writeToFilePlanConfig = sandbox.stub().resolves(); + + const argsLocal = { ...args, target: 'local' }; + await converter.convert(argsLocal); + + sinon.assert.called(stubFsAccess); + sinon.assert.called(converter.writeToFileProductConfig); + sinon.assert.called(converter.writeToFilePlanConfig); + sinon.assert.notCalled(paymentConfigManager.storeProductConfig); + sinon.assert.notCalled(paymentConfigManager.storePlanConfig); + + sandbox.restore(); + }); + + it('does not update Firestore if dryRun = true', async () => { + paymentConfigManager.storeProductConfig = sandbox.stub(); + paymentConfigManager.storePlanConfig = sandbox.stub(); + converter.writeToFileProductConfig = sandbox.stub(); + converter.writeToFilePlanConfig = sandbox.stub(); + const argsDryRun = { ...args, isDryRun: true }; + await converter.convert(argsDryRun); + sinon.assert.notCalled(paymentConfigManager.storeProductConfig); + sinon.assert.notCalled(paymentConfigManager.storePlanConfig); + sinon.assert.notCalled(converter.writeToFileProductConfig); + sinon.assert.notCalled(converter.writeToFilePlanConfig); + }); + + it('moves localized data from plans into the productConfig', async () => { + const productWithRequiredKeys = { + ...deepCopy(product1), + metadata: { + ...deepCopy(product1.metadata), + 'product:privacyNoticeURL': 'http://127.0.0.1:8080/', + 'product:termsOfServiceURL': 'http://127.0.0.1:8080/', + 'product:termsOfServiceDownloadURL': 'http://127.0.0.1:8080/', + }, + }; + const planWithLocalizedData1 = { + ...deepCopy(plan1), + nickname: '123Done Pro Monthly es-ES', + metadata: { + 'product:details:1': 'Producto nuevo', + }, + }; + const planWithLocalizedData2 = { + ...deepCopy(plan2), + nickname: '123Done Pro Monthly fr', + metadata: { + 'product:details:1': 'En euf', + }, + }; + async function* productGenerator() { + yield productWithRequiredKeys; + } + async function* planGenerator() { + yield planWithLocalizedData1; + yield planWithLocalizedData2; + } + converter.stripeHelper.stripe = { + products: { list: sandbox.stub().returns(productGenerator()) }, + plans: { + list: sandbox.stub().returns(planGenerator()), + }, + }; + await converter.convert(args); + products = await paymentConfigManager.allProducts(); + plans = await paymentConfigManager.allPlans(); + const expected = { + 'es-ES': { + uiContent: { + details: [planWithLocalizedData1.metadata['product:details:1']], + }, + urls: {}, + support: {}, + }, + fr: { + uiContent: { + details: [planWithLocalizedData2.metadata['product:details:1']], + }, + urls: {}, + support: {}, + }, + }; + expect(products[0].locales).toEqual(expected); + }); + + it('logs an error and keeps processing if a product fails', async () => { + const productConfigId = 'test-product-id'; + const planConfigId = 'test-plan-id'; + paymentConfigManager.storeProductConfig = sandbox + .stub() + .resolves(productConfigId); + paymentConfigManager.storePlanConfig = sandbox + .stub() + .resolves(planConfigId); + converter.stripeProductToProductConfig = sandbox + .stub() + .onFirstCall() + .throws({ message: 'Something broke!' }) + .onSecondCall() + .returns(productConfig2); + async function* planGenerator() { + yield plan2; + } + converter.stripeHelper.stripe = { + ...converter.stripeHelper.stripe, + plans: { list: sandbox.stub().returns(planGenerator()) }, + }; + + await converter.convert(args); + sinon.assert.calledWithExactly( + paymentConfigManager.storeProductConfig.firstCall, + productConfig2, + null + ); + sinon.assert.calledWithExactly( + paymentConfigManager.storeProductConfig.secondCall, + productConfig2, + productConfigId + ); + sinon.assert.calledOnceWithExactly( + paymentConfigManager.storePlanConfig, + planConfig2, + productConfigId, + null + ); + sinon.assert.calledOnceWithExactly( + mockLog.error, + 'StripeProductsAndPlansConverter.convertProductError', + { + error: 'Something broke!', + stripeProductId: product1.id, + } + ); + }); + + it('logs an error and keeps processing if a plan fails', async () => { + const productConfigId = 'test-product-id'; + const planConfigId = 'test-plan-id'; + paymentConfigManager.storeProductConfig = sandbox + .stub() + .resolves(productConfigId); + paymentConfigManager.storePlanConfig = sandbox + .stub() + .resolves(planConfigId); + converter.stripePlanToPlanConfig = sandbox + .stub() + .onFirstCall() + .throws({ message: 'Something else broke!' }) + .onSecondCall() + .returns(planConfig2); + async function* productGenerator() { + yield product1; + } + async function* planGenerator() { + yield plan1; + yield plan2; + } + converter.stripeHelper.stripe = { + products: { list: sandbox.stub().returns(productGenerator()) }, + plans: { list: sandbox.stub().returns(planGenerator()) }, + }; + + await converter.convert(args); + sinon.assert.calledWithExactly( + paymentConfigManager.storeProductConfig.firstCall, + productConfig1, + null + ); + sinon.assert.calledWithExactly( + paymentConfigManager.storeProductConfig.secondCall, + productConfig1, + productConfigId + ); + sinon.assert.calledWithExactly( + paymentConfigManager.storePlanConfig.firstCall, + planConfig2, + productConfigId, + null + ); + sinon.assert.calledOnceWithExactly( + mockLog.error, + 'StripeProductsAndPlansConverter.convertPlanError', + { + error: 'Something else broke!', + stripePlanId: plan1.id, + stripeProductId: product1.id, + } + ); + }); + + it('re-throws an error from Google Translation API', async () => { + async function* planGenerator() { + yield plan5; + } + async function* productGenerator() { + yield product1; + } + try { + converter.stripeHelper.stripe = { + products: { list: sandbox.stub().returns(productGenerator()) }, + plans: { + list: sandbox.stub().returns(planGenerator()), + }, + }; + await converter.convert(args); + throw new Error('An error should have been thrown'); + } catch (err: any) { + expect(err.message).toBe( + `Google Translation API error: ${GOOGLE_ERROR_MESSAGE}` + ); + } + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/update-subscriptions-to-new-plan.in.spec.ts b/packages/fxa-auth-server/test/scripts/update-subscriptions-to-new-plan.in.spec.ts new file mode 100644 index 00000000000..c3f5317367b --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/update-subscriptions-to-new-plan.in.spec.ts @@ -0,0 +1,29 @@ +/* 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 cp from 'child_process'; +import util from 'util'; +import path from 'path'; + +const execAsync = util.promisify(cp.exec); +const ROOT_DIR = '../..'; +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: { + ...process.env, + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +describe('starting script - update-subscriptions-to-new-plan', () => { + it('does not fail', async () => { + await execAsync( + 'node -r esbuild-register scripts/update-subscriptions-to-new-plan.ts --help', + execOptions + ); + }); +}); diff --git a/packages/fxa-auth-server/test/scripts/verification-reminders.in.spec.ts b/packages/fxa-auth-server/test/scripts/verification-reminders.in.spec.ts new file mode 100644 index 00000000000..3d5e64db35a --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/verification-reminders.in.spec.ts @@ -0,0 +1,31 @@ +/* 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 ROOT_DIR = '../..'; + +const cp = require('child_process'); +const util = require('util'); +const path = require('path'); + +const execAsync = util.promisify(cp.exec); + +const cwd = path.resolve(__dirname, ROOT_DIR); +const execOptions = { + cwd, + env: { + ...process.env, + NODE_ENV: 'dev', + LOG_LEVEL: 'error', + AUTH_FIRESTORE_EMULATOR_HOST: 'localhost:9090', + }, +}; + +describe('#integration - scripts/verification-reminders:', () => { + it('does not fail', async () => { + return execAsync( + 'node -r esbuild-register scripts/verification-reminders', + execOptions + ); + }); +}); diff --git a/packages/fxa-auth-server/test/support/helpers/mailbox.ts b/packages/fxa-auth-server/test/support/helpers/mailbox.ts index 9c579391dd1..fadebedcfa1 100644 --- a/packages/fxa-auth-server/test/support/helpers/mailbox.ts +++ b/packages/fxa-auth-server/test/support/helpers/mailbox.ts @@ -87,7 +87,9 @@ export function createMailbox( if (mail && mail.length > 0) { await deleteMail(username); - const result = mail[0]; + // Newer emails are appended last in mail_helper; prefer the latest + // message so stale OTPs don't win when multiple messages are present. + const result = mail[mail.length - 1]; eventEmitter.emit('email:message', email, result); return result; } @@ -100,7 +102,10 @@ export function createMailbox( throw error; } - async function waitForEmails(email: string, count: number): Promise { + async function waitForEmails( + email: string, + count: number + ): Promise { const username = email.split('@')[0]; for (let tries = MAX_RETRIES; tries > 0; tries--) { @@ -147,7 +152,10 @@ export function createMailbox( return code; } - async function waitForEmailByHeader(email: string, headerName: string): Promise { + async function waitForEmailByHeader( + email: string, + headerName: string + ): Promise { const username = email.split('@')[0]; for (let tries = MAX_RETRIES; tries > 0; tries--) { @@ -156,7 +164,8 @@ export function createMailbox( const mail = await fetchMail(username); if (mail && mail.length > 0) { - for (const m of mail) { + for (let i = mail.length - 1; i >= 0; i--) { + const m = mail[i]; const headerValue = m.headers[headerName]; if (headerValue) { await deleteMail(username); @@ -168,7 +177,9 @@ export function createMailbox( await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); } - throw new Error(`Timeout waiting for email with header ${headerName}: ${email}`); + throw new Error( + `Timeout waiting for email with header ${headerName}: ${email}` + ); } async function clear(email: string): Promise { diff --git a/packages/fxa-auth-server/test/support/helpers/profile-helper.ts b/packages/fxa-auth-server/test/support/helpers/profile-helper.ts index fc27a97dfd5..494f8b17798 100644 --- a/packages/fxa-auth-server/test/support/helpers/profile-helper.ts +++ b/packages/fxa-auth-server/test/support/helpers/profile-helper.ts @@ -10,9 +10,13 @@ export interface ProfileHelper { close: () => Promise; } -export async function createProfileHelper(port: number): Promise { +export const PROFILE_HELPER_HOST = '127.0.0.1'; + +export async function createProfileHelper( + port: number +): Promise { const server = new Hapi.Server({ - host: 'localhost', + host: PROFILE_HELPER_HOST, port, }); @@ -33,7 +37,7 @@ export async function createProfileHelper(port: number): Promise return { port, - url: `http://localhost:${port}`, + url: `http://${PROFILE_HELPER_HOST}:${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 index 1d8eba158b4..dc8fc780782 100644 --- a/packages/fxa-auth-server/test/support/helpers/test-server.ts +++ b/packages/fxa-auth-server/test/support/helpers/test-server.ts @@ -12,8 +12,15 @@ import path from 'path'; import fs from 'fs'; import net from 'net'; import { createMailbox, Mailbox } from './mailbox'; -import { createProfileHelper, ProfileHelper } from './profile-helper'; -import { registerAuthServerPid, unregisterAuthServerPid } from './test-process-registry'; +import { + createProfileHelper, + ProfileHelper, + PROFILE_HELPER_HOST, +} from './profile-helper'; +import { + registerAuthServerPid, + unregisterAuthServerPid, +} from './test-process-registry'; export interface TestServerConfig { printLogs?: boolean; @@ -35,23 +42,35 @@ interface AllocatedPorts { profileServerPort: number; } +interface MailHelperConfig { + smtpHost: string; + smtpPort: number; + apiHost: string; + apiPort: number; +} + const AUTH_SERVER_ROOT = path.resolve(__dirname, '../../..'); export const SHARED_SERVER_PORT = 9100; export const SHARED_PROFILE_PORT = 9101; -function getAvailablePort(startPort: number): Promise { +export function getAvailablePort( + startPort: number, + host: string +): Promise { return new Promise((resolve, reject) => { let port = startPort; const maxPort = startPort + 99; function tryPort() { if (port > maxPort) { - reject(new Error(`No available port found in range ${startPort}-${maxPort}`)); + reject( + new Error(`No available port found in range ${startPort}-${maxPort}`) + ); return; } const srv = net.createServer(); - srv.listen(port, '0.0.0.0', () => { + srv.listen(port, host, () => { const bound = (srv.address() as net.AddressInfo).port; srv.close(() => resolve(bound)); }); @@ -73,29 +92,55 @@ async function allocatePorts(): Promise { // (9000 = auth-server, 9001 = mail_helper, etc.) // Port 9100 is reserved for the shared server (see SHARED_SERVER_PORT). const basePort = 9200 + (workerId - 1) * 100; - const authServerPort = await getAvailablePort(basePort); - const profileServerPort = await getAvailablePort(authServerPort + 1); + const authServerPort = await getAvailablePort(basePort, '127.0.0.1'); + const profileServerPort = await getAvailablePort( + authServerPort + 1, + PROFILE_HELPER_HOST + ); return { authServerPort, profileServerPort }; } -export async function waitForServer(url: string, maxAttempts = 60, delayMs = 1000): Promise { +export function getMailHelperConfig( + baseConfig: Record +): MailHelperConfig { + return { + smtpHost: process.env.SMTP_HOST || baseConfig.smtp?.host || 'localhost', + smtpPort: Number(process.env.SMTP_PORT || baseConfig.smtp?.port || 25), + apiHost: + process.env.MAILER_HOST || baseConfig.smtp?.api?.host || 'localhost', + apiPort: Number( + process.env.MAILER_PORT || baseConfig.smtp?.api?.port || 9001 + ), + }; +} + +export async function waitForServer( + url: string, + maxAttempts = 60, + delayMs = 1000 +): Promise { for (let i = 0; i < maxAttempts; i++) { try { const response = await fetch(`${url}/__heartbeat__`); if (response.ok) { // Allow async initialization to settle after heartbeat passes - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); return; } } catch { // Server not ready yet } - await new Promise(resolve => setTimeout(resolve, delayMs)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } - throw new Error(`Server at ${url} did not become ready after ${maxAttempts} attempts`); + throw new Error( + `Server at ${url} did not become ready after ${maxAttempts} attempts` + ); } -export function createTempConfig(overrides: Record, port: number): string { +export function createTempConfig( + overrides: Record, + port: number +): string { const config = { ...overrides, listen: { host: '127.0.0.1', port }, @@ -136,7 +181,11 @@ export function spawnAuthServer( const serverProcess = spawn( 'node', - ['-r', 'esbuild-register', path.join(AUTH_SERVER_ROOT, 'bin', 'key_server.js')], + [ + '-r', + 'esbuild-register', + path.join(AUTH_SERVER_ROOT, 'bin', 'key_server.js'), + ], { cwd: AUTH_SERVER_ROOT, env, @@ -181,9 +230,10 @@ export async function createTestServer( const baseConfigPath = require.resolve('../../../config'); delete require.cache[baseConfigPath]; const baseConfig = require('../../../config').default.getProperties(); + const mailHelperConfig = getMailHelperConfig(baseConfig); let profileServer: ProfileHelper | null = null; - const profileServerUrl = `http://localhost:${ports.profileServerPort}`; + const profileServerUrl = `http://${PROFILE_HELPER_HOST}:${ports.profileServerPort}`; if (baseConfig.profileServer?.url) { profileServer = await createProfileHelper(ports.profileServerPort); } @@ -205,6 +255,16 @@ export async function createTestServer( checkAllEndpoints: false, ignoreIPs: ['127.0.0.1', '::1', 'localhost'], }, + smtp: { + ...baseConfig.smtp, + host: mailHelperConfig.smtpHost, + port: mailHelperConfig.smtpPort, + api: { + ...baseConfig.smtp?.api, + host: mailHelperConfig.apiHost, + port: mailHelperConfig.apiPort, + }, + }, oauth: { ...baseConfig.oauth, url: publicUrl, @@ -223,8 +283,8 @@ export async function createTestServer( const configPath = createTempConfig(fullOverrides, ports.authServerPort); const mailbox = createMailbox( - baseConfig.smtp?.api?.host || 'localhost', - baseConfig.smtp?.api?.port || 9001, + mailHelperConfig.apiHost, + mailHelperConfig.apiPort, printLogs ); @@ -272,15 +332,28 @@ export async function createTestServer( if (serverProcess && !serverProcess.killed) { const exitPromise = new Promise((resolve) => { - serverProcess.on('exit', () => resolve()); + serverProcess.once('exit', () => resolve()); + }); + let timeoutId: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(false), 5000); + timeoutId.unref?.(); }); serverProcess.kill('SIGTERM'); - const timeout = new Promise((resolve) => - setTimeout(resolve, 5000) - ); - await Promise.race([exitPromise, timeout]); - if (!serverProcess.killed) { + const exited = await Promise.race([ + exitPromise.then(() => true), + timeout, + ]); + if (timeoutId) { + clearTimeout(timeoutId); + } + if ( + !exited && + serverProcess.exitCode === null && + serverProcess.signalCode === null + ) { serverProcess.kill('SIGKILL'); + await exitPromise; } } @@ -314,10 +387,11 @@ export async function getSharedTestServer(): Promise { const baseConfigPath = require.resolve('../../../config'); delete require.cache[baseConfigPath]; const baseConfig = require('../../../config').default.getProperties(); + const mailHelperConfig = getMailHelperConfig(baseConfig); const mailbox = createMailbox( - baseConfig.smtp?.api?.host || 'localhost', - baseConfig.smtp?.api?.port || 9001, + mailHelperConfig.apiHost, + mailHelperConfig.apiPort, process.env.REMOTE_TEST_LOGS === 'true' ); diff --git a/packages/fxa-auth-server/test/support/jest-global-setup.ts b/packages/fxa-auth-server/test/support/jest-global-setup.ts index 5eac3df7216..2b3a95a0189 100644 --- a/packages/fxa-auth-server/test/support/jest-global-setup.ts +++ b/packages/fxa-auth-server/test/support/jest-global-setup.ts @@ -15,10 +15,14 @@ import { SHARED_SERVER_PORT, SHARED_PROFILE_PORT, createTempConfig, + getAvailablePort, spawnAuthServer, waitForServer, } from './helpers/test-server'; -import { createProfileHelper } from './helpers/profile-helper'; +import { + createProfileHelper, + PROFILE_HELPER_HOST, +} from './helpers/profile-helper'; const AUTH_SERVER_ROOT = path.resolve(__dirname, '../..'); const TMP_DIR = path.join(AUTH_SERVER_ROOT, 'test', 'support', '.tmp'); @@ -26,6 +30,9 @@ const MAIL_HELPER_PID_FILE = path.join(TMP_DIR, 'mail_helper.pid'); const SHARED_SERVER_PID_FILE = path.join(TMP_DIR, 'shared_server.pid'); const VERSION_JSON_PATH = path.join(AUTH_SERVER_ROOT, 'config', 'version.json'); const VERSION_JSON_MARKER = path.join(TMP_DIR, 'version_json_created'); +const MAIL_HELPER_HOST = '127.0.0.1'; +const MAIL_HELPER_API_START_PORT = 39001; +const MAIL_HELPER_SMTP_START_PORT = 39101; function generateKeysIfNeeded(): void { const keyScripts = [ @@ -59,20 +66,29 @@ function generateKeysIfNeeded(): void { } } -async function waitForMailHelper(port = 9001, maxAttempts = 30, delayMs = 500): Promise { +async function waitForMailHelper( + port = Number(process.env.MAILER_PORT || 9001), + maxAttempts = 30, + delayMs = 500 +): Promise { // Use DELETE endpoint — GET /mail/{email} blocks until an email arrives for (let i = 0; i < maxAttempts; i++) { try { - const response = await fetch(`http://localhost:${port}/mail/__healthcheck__`, { method: 'DELETE' }); + const response = await fetch( + `http://${process.env.MAILER_HOST || MAIL_HELPER_HOST}:${port}/mail/__healthcheck__`, + { method: 'DELETE' } + ); if (response.ok || response.status === 404) { return; } } catch { // Not ready yet } - await new Promise(resolve => setTimeout(resolve, delayMs)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } - throw new Error(`mail_helper did not become ready after ${maxAttempts} attempts`); + throw new Error( + `mail_helper did not become ready after ${maxAttempts} attempts` + ); } function killExistingProcess(pidFile: string, label: string): void { @@ -81,7 +97,11 @@ function killExistingProcess(pidFile: string, label: string): void { if (oldPid) { try { process.kill(oldPid, 'SIGTERM'); - console.log(`[Jest Global Setup] Killed leftover ${label} (PID:`, oldPid, ')'); + console.log( + `[Jest Global Setup] Killed leftover ${label} (PID:`, + oldPid, + ')' + ); } catch { // Process already dead } @@ -101,7 +121,9 @@ function generateVersionJsonIfNeeded(): void { let source = 'unknown'; try { source = execSync('git config --get remote.origin.url').toString().trim(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } fs.writeFileSync( VERSION_JSON_PATH, JSON.stringify({ version: { hash, source } }) @@ -117,6 +139,20 @@ export default async function globalSetup(): Promise { if (!process.env.CORS_ORIGIN) { process.env.CORS_ORIGIN = 'http://foo,http://bar'; } + + const mailHelperApiPort = await getAvailablePort( + MAIL_HELPER_API_START_PORT, + MAIL_HELPER_HOST + ); + const mailHelperSmtpPort = await getAvailablePort( + MAIL_HELPER_SMTP_START_PORT, + MAIL_HELPER_HOST + ); + process.env.MAILER_HOST = MAIL_HELPER_HOST; + process.env.MAILER_PORT = String(mailHelperApiPort); + process.env.SMTP_HOST = MAIL_HELPER_HOST; + process.env.SMTP_PORT = String(mailHelperSmtpPort); + const printLogs = process.env.MAIL_HELPER_LOGS === 'true'; generateKeysIfNeeded(); @@ -126,7 +162,10 @@ export default async function globalSetup(): Promise { console.log('[Jest Global Setup] Cleaning up stale auth server processes...'); killAllTrackedAuthServers(); - console.log('[Jest Global Setup] Starting mail_helper...'); + console.log( + '[Jest Global Setup] Starting mail_helper...', + `(api ${process.env.MAILER_HOST}:${process.env.MAILER_PORT}, smtp ${process.env.SMTP_HOST}:${process.env.SMTP_PORT})` + ); if (!fs.existsSync(TMP_DIR)) { fs.mkdirSync(TMP_DIR, { recursive: true }); @@ -136,13 +175,16 @@ export default async function globalSetup(): Promise { const mailHelperProcess = spawn( 'node', - ['-r', 'esbuild-register', path.join(AUTH_SERVER_ROOT, 'test', 'mail_helper.js')], + [ + '-r', + 'esbuild-register', + path.join(AUTH_SERVER_ROOT, 'test', 'mail_helper.js'), + ], { cwd: AUTH_SERVER_ROOT, env: { ...process.env, NODE_ENV: 'dev', - MAILER_HOST: '0.0.0.0', MAIL_HELPER_LOGS: printLogs ? 'true' : '', }, stdio: printLogs ? 'inherit' : 'ignore', @@ -155,7 +197,11 @@ export default async function globalSetup(): Promise { try { await waitForMailHelper(); - console.log('[Jest Global Setup] mail_helper started (PID:', mailHelperProcess.pid, ')'); + console.log( + '[Jest Global Setup] mail_helper started (PID:', + mailHelperProcess.pid, + ')' + ); } catch (err) { console.error('[Jest Global Setup] Failed to start mail_helper:', err); mailHelperProcess.kill(); @@ -163,18 +209,29 @@ export default async function globalSetup(): Promise { } // Start the shared profile helper for the shared auth server - console.log('[Jest Global Setup] Starting shared profile helper on port', SHARED_PROFILE_PORT, '...'); + console.log( + '[Jest Global Setup] Starting shared profile helper on port', + SHARED_PROFILE_PORT, + '...' + ); const sharedProfileHelper = await createProfileHelper(SHARED_PROFILE_PORT); (global as any).__sharedProfileHelper = sharedProfileHelper; - console.log('[Jest Global Setup] Shared profile helper started on port', SHARED_PROFILE_PORT); + console.log( + '[Jest Global Setup] Shared profile helper started on port', + SHARED_PROFILE_PORT + ); // Start the shared auth server for test suites that don't need config overrides - console.log('[Jest Global Setup] Starting shared auth server on port', SHARED_SERVER_PORT, '...'); + console.log( + '[Jest Global Setup] Starting shared auth server on port', + SHARED_SERVER_PORT, + '...' + ); killExistingProcess(SHARED_SERVER_PID_FILE, 'shared_server'); const sharedPublicUrl = `http://localhost:${SHARED_SERVER_PORT}`; - const sharedProfileUrl = `http://localhost:${SHARED_PROFILE_PORT}`; + const sharedProfileUrl = `http://${PROFILE_HELPER_HOST}:${SHARED_PROFILE_PORT}`; const sharedPrintLogs = process.env.REMOTE_TEST_LOGS === 'true'; // Only specify overrides — convict deep-merges these on top of defaults, // so we don't need to load the full base config here (which has deps @@ -197,8 +254,15 @@ export default async function globalSetup(): Promise { }, profileServer: { url: sharedProfileUrl }, }; - const sharedConfigPath = createTempConfig(sharedOverrides, SHARED_SERVER_PORT); - const sharedSpawned = spawnAuthServer(SHARED_SERVER_PORT, sharedConfigPath, sharedPrintLogs); + const sharedConfigPath = createTempConfig( + sharedOverrides, + SHARED_SERVER_PORT + ); + const sharedSpawned = spawnAuthServer( + SHARED_SERVER_PORT, + sharedConfigPath, + sharedPrintLogs + ); if (sharedSpawned.process.pid) { fs.writeFileSync(SHARED_SERVER_PID_FILE, String(sharedSpawned.process.pid)); @@ -207,19 +271,31 @@ export default async function globalSetup(): Promise { try { await waitForServer(sharedPublicUrl); - console.log('[Jest Global Setup] Shared auth server started (PID:', sharedSpawned.process.pid, ')'); + console.log( + '[Jest Global Setup] Shared auth server started (PID:', + sharedSpawned.process.pid, + ')' + ); } catch (err) { const stderr = sharedSpawned.stderrChunks.join(''); sharedSpawned.process.kill(); if (stderr) { - console.error(`[Jest Global Setup] Shared server stderr:\n${stderr.slice(-2000)}`); + console.error( + `[Jest Global Setup] Shared server stderr:\n${stderr.slice(-2000)}` + ); } throw err; } // Install signal handlers so Ctrl+C / SIGTERM cleans up all child processes // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - const cleanup = (signal: 'SIGINT' | 'SIGTERM') => { + let cleaningUp = false; + + function cleanup(signal: 'SIGINT' | 'SIGTERM') { + if (cleaningUp) { + return; + } + cleaningUp = true; console.log(`[Jest Global Setup] ${signal} received, cleaning up...`); killAllTrackedAuthServers(); try { @@ -234,9 +310,19 @@ export default async function globalSetup(): Promise { } sharedProfileHelper.close().catch(() => {}); // Re-raise so the process exits with the correct signal code - process.removeListener(signal, cleanup); + process.off('SIGINT', handleSigInt); + process.off('SIGTERM', handleSigTerm); process.kill(process.pid, signal); - }; - process.on('SIGINT', () => cleanup('SIGINT')); - process.on('SIGTERM', () => cleanup('SIGTERM')); + } + + function handleSigInt() { + cleanup('SIGINT'); + } + + function handleSigTerm() { + cleanup('SIGTERM'); + } + + process.on('SIGINT', handleSigInt); + process.on('SIGTERM', handleSigTerm); } diff --git a/packages/fxa-auth-server/test/support/jest-setup-integration.ts b/packages/fxa-auth-server/test/support/jest-setup-integration.ts index ad68d186b3b..d1eec0ab225 100644 --- a/packages/fxa-auth-server/test/support/jest-setup-integration.ts +++ b/packages/fxa-auth-server/test/support/jest-setup-integration.ts @@ -7,8 +7,6 @@ * Runs AFTER the test environment is set up (after jest-setup-env.ts). */ -jest.setTimeout(60000); - process.on('unhandledRejection', (reason) => { console.error('Unhandled Rejection:', reason); });