From 0c2af3263cd4d11c07612e5f1638a4269e48b892 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 31 Mar 2026 16:47:21 -0400 Subject: [PATCH] test(auth-server): migrate script tests from Mocha to co-located Jest specs --- packages/fxa-auth-server/jest.config.js | 4 +- packages/fxa-auth-server/package.json | 2 +- .../cancel-subscriptions-to-plan.spec.ts | 690 +++++++++++++ .../check-firestore-stripe-sync.spec.ts | 510 ++++++++++ .../cleanup-old-carts.spec.ts | 88 ++ ...-customers-to-stripe-automatic-tax.spec.ts | 655 ++++++++++++ .../helpers.spec.ts | 366 +++++++ .../delete-inactive-accounts/lib.spec.ts | 298 ++++++ .../move-customers-to-new-plan-v2.spec.ts | 943 ++++++++++++++++++ .../move-customers-to-new-plan.spec.ts | 393 ++++++++ .../scripts/recorded-future/lib.spec.ts | 280 ++++++ .../converter.spec.ts | 540 ++++++++++ .../plan-language-tags-guesser.spec.ts | 140 +++ .../update-subscriptions-to-new-plan.spec.ts | 340 +++++++ .../test/remote/recovery_phone.in.spec.ts | 36 +- .../test/scripts/bulk-mailer.in.spec.ts | 245 +++++ .../test/scripts/check-users.in.spec.ts | 102 ++ ...stomers-to-stripe-automatic-tax.in.spec.ts | 31 + .../test/scripts/delete-account.in.spec.ts | 34 + ...ueue-inactive-account-deletions.in.spec.ts | 90 ++ .../delete-unverified-accounts.in.spec.ts | 92 ++ .../test/scripts/dump-users.in.spec.ts | 300 ++++++ .../move-customers-to-new-plan-v2.in.spec.ts | 31 + .../move-customers-to-new-plan.in.spec.ts | 31 + .../test/scripts/must-reset.in.spec.ts | 307 ++++++ ...prune-oauth-authorization-codes.in.spec.ts | 34 + .../test/scripts/prune-tokens.in.spec.ts | 438 ++++++++ .../check-and-reset.in.spec.ts | 74 ++ ...pe-products-and-plans-converter.in.spec.ts | 580 +++++++++++ ...pdate-subscriptions-to-new-plan.in.spec.ts | 31 + .../scripts/verification-reminders.in.spec.ts | 33 + 31 files changed, 7726 insertions(+), 12 deletions(-) create mode 100644 packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts create mode 100644 packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts create mode 100644 packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts create mode 100644 packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/convert-customers-to-stripe-automatic-tax.spec.ts create mode 100644 packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts create mode 100644 packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts create mode 100644 packages/fxa-auth-server/scripts/move-customers-to-new-plan-v2/move-customers-to-new-plan-v2.spec.ts create mode 100644 packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts create mode 100644 packages/fxa-auth-server/scripts/recorded-future/lib.spec.ts create mode 100644 packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts create mode 100644 packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/plan-language-tags-guesser.spec.ts create mode 100644 packages/fxa-auth-server/scripts/update-subscriptions-to-new-plan/update-subscriptions-to-new-plan.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/bulk-mailer.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/check-users.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/convert-customers-to-stripe-automatic-tax.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/delete-account.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/delete-unverified-accounts.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/dump-users.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/move-customers-to-new-plan-v2.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/move-customers-to-new-plan.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/must-reset.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/prune-oauth-authorization-codes.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/prune-tokens.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/recorded-future/check-and-reset.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/update-subscriptions-to-new-plan.in.spec.ts create mode 100644 packages/fxa-auth-server/test/scripts/verification-reminders.in.spec.ts diff --git a/packages/fxa-auth-server/jest.config.js b/packages/fxa-auth-server/jest.config.js index ada3b502870..eed88c322ba 100644 --- a/packages/fxa-auth-server/jest.config.js +++ b/packages/fxa-auth-server/jest.config.js @@ -6,12 +6,12 @@ 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/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..a386bfad361 --- /dev/null +++ b/packages/fxa-auth-server/scripts/cancel-subscriptions-to-plan/cancel-subscriptions-to-plan.spec.ts @@ -0,0 +1,690 @@ +/* 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..08c89fe2eba --- /dev/null +++ b/packages/fxa-auth-server/scripts/check-firestore-stripe-sync/check-firestore-stripe-sync.spec.ts @@ -0,0 +1,510 @@ +/* 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..cfd94f89e92 --- /dev/null +++ b/packages/fxa-auth-server/scripts/cleanup-old-carts/cleanup-old-carts.spec.ts @@ -0,0 +1,88 @@ +/* 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..40c27d09ff1 --- /dev/null +++ b/packages/fxa-auth-server/scripts/convert-customers-to-stripe-automatic-tax/helpers.spec.ts @@ -0,0 +1,366 @@ +/* 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..78c361642a0 --- /dev/null +++ b/packages/fxa-auth-server/scripts/delete-inactive-accounts/lib.spec.ts @@ -0,0 +1,298 @@ +/* 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..3e083251e35 --- /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,943 @@ +/* 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..d0fe6a607ee --- /dev/null +++ b/packages/fxa-auth-server/scripts/move-customers-to-new-plan/move-customers-to-new-plan.spec.ts @@ -0,0 +1,393 @@ +/* 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/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..a11300e57c0 --- /dev/null +++ b/packages/fxa-auth-server/scripts/stripe-products-and-plans-to-firestore-documents/converter.spec.ts @@ -0,0 +1,540 @@ +/* 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..10eb997dc07 --- /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 indentical 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..fdbd119d65f --- /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('CustomerPlanMover', () => { + 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/remote/recovery_phone.in.spec.ts b/packages/fxa-auth-server/test/remote/recovery_phone.in.spec.ts index 3026f8faf73..b4cd200c5eb 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 @@ -4,6 +4,16 @@ 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 +42,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: { @@ -72,12 +82,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 +347,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/scripts/bulk-mailer.in.spec.ts b/packages/fxa-auth-server/test/scripts/bulk-mailer.in.spec.ts new file mode 100644 index 00000000000..22276b05f6b --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/bulk-mailer.in.spec.ts @@ -0,0 +1,245 @@ +/* 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', + }, +}; + +jest.setTimeout(30000); + +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), + ]); + + 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..3e68f36095b --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/check-users.in.spec.ts @@ -0,0 +1,102 @@ +/* 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'; + +jest.setTimeout(120000); + +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(); + }); + + 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..243e2dc668c --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/convert-customers-to-stripe-automatic-tax.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/. */ + +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', + }, +}; + +jest.setTimeout(30000); + +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..5642182ffb0 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/delete-account.in.spec.ts @@ -0,0 +1,34 @@ +/* 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(30000); + +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..5e357a7ed01 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/delete-inactive-accounts/enqueue-inactive-account-deletions.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'; + +jest.setTimeout(30000); + +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..241077a7e7e --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/delete-unverified-accounts.in.spec.ts @@ -0,0 +1,92 @@ +/* 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'; + +jest.setTimeout(30000); + +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..0ea4663f5b4 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/dump-users.in.spec.ts @@ -0,0 +1,300 @@ +/* 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', + }, +}; + +jest.setTimeout(20000); + +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..74d586392b4 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/move-customers-to-new-plan-v2.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/. */ + +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', + }, +}; + +jest.setTimeout(30000); + +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..6d76faccfef --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/move-customers-to-new-plan.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/. */ + +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', + }, +}; + +jest.setTimeout(30000); + +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..f6ae7fcb13f --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/must-reset.in.spec.ts @@ -0,0 +1,307 @@ +/* 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); + +jest.setTimeout(10000); + +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..10b20b02067 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/prune-oauth-authorization-codes.in.spec.ts @@ -0,0 +1,34 @@ +/* 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(15000); + +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..12a16bee151 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/prune-tokens.in.spec.ts @@ -0,0 +1,438 @@ +/* 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() +); + +jest.setTimeout(10000); + +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(); + }); + + 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..603c0826bfc --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/recorded-future/check-and-reset.in.spec.ts @@ -0,0 +1,74 @@ +/* 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'; + +jest.setTimeout(30000); + +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..8f3553121f8 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/stripe-products-and-plans-converter.in.spec.ts @@ -0,0 +1,580 @@ +/* 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..58964aecce9 --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/update-subscriptions-to-new-plan.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/. */ + +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', + }, +}; + +jest.setTimeout(30000); + +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..4f79627587e --- /dev/null +++ b/packages/fxa-auth-server/test/scripts/verification-reminders.in.spec.ts @@ -0,0 +1,33 @@ +/* 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(30000); + +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 + ); + }); +});