mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-13 20:36:41 +01:00
410 lines
13 KiB
JavaScript
410 lines
13 KiB
JavaScript
/* */ /* 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 sinon = require('sinon');
|
|
const { assert } = require('chai');
|
|
const proxyquire = require('proxyquire');
|
|
const { default: Container } = require('typedi');
|
|
const { AppConfig, AuthLogger } = require('../../lib/types');
|
|
const mocks = require('../mocks');
|
|
const uuid = require('uuid');
|
|
const { AppError: error } = require('@fxa/accounts/errors');
|
|
const {
|
|
AppleIAP,
|
|
} = require('../../lib/payments/iap/apple-app-store/apple-iap');
|
|
const {
|
|
PlayBilling,
|
|
} = require('../../lib/payments/iap/google-play/play-billing');
|
|
const { ReasonForDeletion } = require('@fxa/shared/cloud-tasks');
|
|
|
|
const email = 'foo@example.com';
|
|
const uid = uuid.v4({}, Buffer.alloc(16)).toString('hex');
|
|
const expectedSubscriptions = [
|
|
{ uid, subscriptionId: '123' },
|
|
{ uid, subscriptionId: '456' },
|
|
{ uid, subscriptionId: '789' },
|
|
];
|
|
const deleteReason = 'fxa_user_requested_account_delete';
|
|
|
|
describe('AccountDeleteManager', function () {
|
|
this.timeout(10000);
|
|
|
|
const sandbox = sinon.createSandbox();
|
|
|
|
let mockFxaDb;
|
|
let mockOAuthDb;
|
|
let mockPush;
|
|
let mockPushbox;
|
|
let mockStatsd;
|
|
let mockGlean;
|
|
let mockMailer;
|
|
let mockStripeHelper;
|
|
let mockPaypalHelper;
|
|
let mockAppleIap;
|
|
let mockPlayBilling;
|
|
let mockLog;
|
|
let mockConfig;
|
|
let accountDeleteManager;
|
|
let mockAuthModels;
|
|
let isActiveStub;
|
|
|
|
beforeEach(() => {
|
|
const { PayPalHelper } = require('../../lib/payments/paypal/helper');
|
|
const { StripeHelper } = require('../../lib/payments/stripe');
|
|
|
|
sandbox.reset();
|
|
mockFxaDb = {
|
|
...mocks.mockDB({ email: email, emailVerified: true, uid: uid }),
|
|
fetchAccountSubscriptions: sinon.spy(
|
|
async (uid) => expectedSubscriptions
|
|
),
|
|
};
|
|
mockOAuthDb = {};
|
|
mockPush = mocks.mockPush();
|
|
mockPushbox = mocks.mockPushbox();
|
|
mockStatsd = { increment: sandbox.stub() };
|
|
mockGlean = mocks.mockGlean();
|
|
mockMailer = mocks.mockMailer();
|
|
mockStripeHelper = {};
|
|
mockLog = mocks.mockLog();
|
|
mockAppleIap = {
|
|
purchaseManager: {
|
|
deletePurchases: sinon.fake.resolves(),
|
|
},
|
|
};
|
|
mockPlayBilling = {
|
|
purchaseManager: {
|
|
deletePurchases: sinon.fake.resolves(),
|
|
},
|
|
};
|
|
|
|
mockConfig = {
|
|
apiVersion: 1,
|
|
cloudTasks: mocks.mockCloudTasksConfig,
|
|
publicUrl: 'https://tasks.example.io',
|
|
subscriptions: {
|
|
enabled: true,
|
|
paypalNvpSigCredentials: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
domain: 'wibble',
|
|
};
|
|
Container.set(AppConfig, mockConfig);
|
|
|
|
mockStripeHelper = mocks.mockStripeHelper([
|
|
'removeCustomer',
|
|
'removeFirestoreCustomer',
|
|
]);
|
|
mockStripeHelper.removeCustomer = sandbox.stub().resolves();
|
|
mockStripeHelper.removeFirestoreCustomer = sandbox.stub().resolves();
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions = sandbox
|
|
.stub()
|
|
.resolves();
|
|
mockStripeHelper.refundInvoices = sandbox.stub().resolves();
|
|
mockPaypalHelper = mocks.mockPayPalHelper(['cancelBillingAgreement']);
|
|
mockPaypalHelper.cancelBillingAgreement = sandbox.stub().resolves();
|
|
mockPaypalHelper.refundInvoices = sandbox.stub().resolves();
|
|
mockAuthModels = {};
|
|
mockAuthModels.getAllPayPalBAByUid = sinon.spy(async () => {
|
|
return [{ status: 'Active', billingAgreementId: 'B-test' }];
|
|
});
|
|
mockAuthModels.deleteAllPayPalBAs = sinon.spy(async () => {});
|
|
mockAuthModels.getAccountCustomerByUid = sinon.spy(async (...args) => {
|
|
return { stripeCustomerId: 'cus_993' };
|
|
});
|
|
|
|
mockOAuthDb = {
|
|
removeTokensAndCodes: sinon.fake.resolves(),
|
|
removePublicAndCanGrantTokens: sinon.fake.resolves(),
|
|
};
|
|
|
|
Container.set(StripeHelper, mockStripeHelper);
|
|
Container.set(PayPalHelper, mockPaypalHelper);
|
|
Container.set(AuthLogger, mockLog);
|
|
Container.set(AppConfig, mockConfig);
|
|
Container.set(AppleIAP, mockAppleIap);
|
|
Container.set(PlayBilling, mockPlayBilling);
|
|
|
|
isActiveStub = sandbox.stub();
|
|
const { AccountDeleteManager } = proxyquire('../../lib/account-delete', {
|
|
'fxa-shared/db/models/auth': mockAuthModels,
|
|
'./inactive-accounts': {
|
|
...require('../../lib/inactive-accounts'),
|
|
InactiveAccountsManager: class {
|
|
isActive = isActiveStub;
|
|
},
|
|
},
|
|
});
|
|
|
|
accountDeleteManager = new AccountDeleteManager({
|
|
fxaDb: mockFxaDb,
|
|
oauthDb: mockOAuthDb,
|
|
config: mockConfig,
|
|
push: mockPush,
|
|
pushbox: mockPushbox,
|
|
statsd: mockStatsd,
|
|
mailer: mockMailer,
|
|
glean: mockGlean,
|
|
log: mockLog,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
Container.reset();
|
|
sandbox.reset();
|
|
});
|
|
|
|
it('can be instantiated', () => {
|
|
assert.ok(accountDeleteManager);
|
|
});
|
|
|
|
describe('delete account', function () {
|
|
it('should delete the account', async () => {
|
|
mockPush.notifyAccountDestroyed = sinon.fake.resolves();
|
|
mockFxaDb.devices = sinon.fake.resolves(['test123', 'test456']);
|
|
await accountDeleteManager.deleteAccount(uid, deleteReason);
|
|
|
|
sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, {
|
|
uid,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(mockStripeHelper.removeCustomer, uid, {
|
|
cancellation_reason: deleteReason,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockStripeHelper.removeFirestoreCustomer,
|
|
uid
|
|
);
|
|
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockAuthModels.getAllPayPalBAByUid,
|
|
uid
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockPaypalHelper.cancelBillingAgreement,
|
|
'B-test'
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockAuthModels.deleteAllPayPalBAs,
|
|
uid
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockAppleIap.purchaseManager.deletePurchases,
|
|
uid
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockPlayBilling.purchaseManager.deletePurchases,
|
|
uid
|
|
);
|
|
sinon.assert.calledOnceWithExactly(mockPush.notifyAccountDestroyed, uid, [
|
|
'test123',
|
|
'test456',
|
|
]);
|
|
sinon.assert.calledOnceWithExactly(mockPushbox.deleteAccount, uid);
|
|
sinon.assert.calledOnceWithExactly(mockOAuthDb.removeTokensAndCodes, uid);
|
|
sinon.assert.calledOnceWithExactly(mockLog.activityEvent, {
|
|
uid,
|
|
email,
|
|
emailVerified: true,
|
|
event: 'account.deleted',
|
|
});
|
|
});
|
|
|
|
it('should delete even if already deleted from fxa db', async () => {
|
|
const unkonwnError = error.unknownAccount('test@email.com');
|
|
mockFxaDb.account = sinon.fake.rejects(unkonwnError);
|
|
mockPush.notifyAccountDestroyed = sinon.fake.resolves();
|
|
await accountDeleteManager.deleteAccount(uid, deleteReason);
|
|
sinon.assert.calledWithMatch(mockStripeHelper.removeCustomer, uid);
|
|
sinon.assert.callCount(mockPush.notifyAccountDestroyed, 0);
|
|
sinon.assert.callCount(mockFxaDb.deleteAccount, 0);
|
|
sinon.assert.callCount(mockLog.activityEvent, 0);
|
|
});
|
|
|
|
it('does not fail if pushbox fails to delete', async () => {
|
|
mockPushbox.deleteAccount = sinon.fake.rejects();
|
|
try {
|
|
await accountDeleteManager.deleteAccount(uid, deleteReason);
|
|
} catch (err) {
|
|
assert.fail('no exception should have been thrown');
|
|
}
|
|
});
|
|
|
|
it('should fail if stripeHelper update customer fails', async () => {
|
|
mockStripeHelper.removeCustomer(async () => {
|
|
throw new Error('wibble');
|
|
});
|
|
try {
|
|
await accountDeleteManager.deleteAccount(uid, deleteReason);
|
|
assert.fail('method should throw an error');
|
|
} catch (err) {
|
|
assert.isObject(err);
|
|
}
|
|
});
|
|
|
|
it('should fail if paypalHelper cancel billing agreement fails', async () => {
|
|
mockPaypalHelper.cancelBillingAgreement(async () => {
|
|
throw new Error('wibble');
|
|
});
|
|
try {
|
|
await accountDeleteManager.deleteAccount(uid, deleteReason);
|
|
assert.fail('method should throw an error');
|
|
} catch (err) {
|
|
assert.isObject(err);
|
|
}
|
|
});
|
|
|
|
describe('scheduled inactive account deletion', () => {
|
|
it('should skip if the account is active', async () => {
|
|
isActiveStub.resolves(true);
|
|
await accountDeleteManager.deleteAccount(
|
|
uid,
|
|
ReasonForDeletion.InactiveAccountScheduled
|
|
);
|
|
sinon.assert.notCalled(mockFxaDb.deleteAccount);
|
|
sinon.assert.calledOnce(
|
|
mockGlean.inactiveAccountDeletion.deletionSkipped
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockStatsd.increment,
|
|
'account.inactive.deletion.skipped.active'
|
|
);
|
|
});
|
|
|
|
it('should delete the inactive account', async () => {
|
|
isActiveStub.resolves(false);
|
|
await accountDeleteManager.deleteAccount(
|
|
uid,
|
|
ReasonForDeletion.InactiveAccountScheduled
|
|
);
|
|
sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, {
|
|
uid,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockLog.info,
|
|
'accountDeleted.byCloudTask',
|
|
{ uid }
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('quickDelete', () => {
|
|
it('should delete the account', async () => {
|
|
await accountDeleteManager.quickDelete(uid, deleteReason);
|
|
|
|
sinon.assert.calledWithMatch(mockFxaDb.deleteAccount, {
|
|
uid,
|
|
});
|
|
sinon.assert.calledOnceWithExactly(mockOAuthDb.removeTokensAndCodes, uid);
|
|
});
|
|
|
|
it('should error if its not user requested', async () => {
|
|
try {
|
|
await accountDeleteManager.quickDelete(uid, 'not_user_requested');
|
|
assert.fail('method should throw an error');
|
|
} catch (err) {
|
|
assert.match(err.message, /^quickDelete only supports user/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('refundSubscriptions', () => {
|
|
it('returns immediately when delete reason is not for unverified account', async () => {
|
|
await accountDeleteManager.refundSubscriptions('invalid_reason');
|
|
sinon.assert.notCalled(
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions
|
|
);
|
|
});
|
|
|
|
it('returns if no invoices are found', async () => {
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves([]);
|
|
await accountDeleteManager.refundSubscriptions(
|
|
'fxa_unverified_account_delete',
|
|
'customerid'
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions,
|
|
'customerid',
|
|
'paid',
|
|
undefined
|
|
);
|
|
sinon.assert.notCalled(mockStripeHelper.refundInvoices);
|
|
});
|
|
|
|
it('attempts refunds on invoices created within refundPeriod', async () => {
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves([]);
|
|
await accountDeleteManager.refundSubscriptions(
|
|
'fxa_unverified_account_delete',
|
|
'customerid',
|
|
34
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions,
|
|
'customerid',
|
|
'paid',
|
|
sinon.match.date
|
|
);
|
|
sinon.assert.calledOnce(
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions
|
|
);
|
|
sinon.assert.notCalled(mockStripeHelper.refundInvoices);
|
|
});
|
|
|
|
it('attempts refunds on invoices', async () => {
|
|
const expectedInvoices = ['invoice1', 'invoice2'];
|
|
const expectedRefundResult = [
|
|
{
|
|
invoiceId: 'id1',
|
|
priceId: 'priceId1',
|
|
total: '123',
|
|
currency: 'usd',
|
|
},
|
|
];
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves(
|
|
expectedInvoices
|
|
);
|
|
mockStripeHelper.refundInvoices.resolves(expectedRefundResult);
|
|
await accountDeleteManager.refundSubscriptions(
|
|
'fxa_unverified_account_delete',
|
|
'customerId'
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockStripeHelper.refundInvoices,
|
|
expectedInvoices
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockPaypalHelper.refundInvoices,
|
|
expectedInvoices
|
|
);
|
|
});
|
|
|
|
it('rejects on refundInvoices handler exception', async () => {
|
|
const expectedInvoices = ['invoice1', 'invoice2'];
|
|
const expectedError = new Error('expected');
|
|
mockStripeHelper.fetchInvoicesForActiveSubscriptions.resolves(
|
|
expectedInvoices
|
|
);
|
|
mockStripeHelper.refundInvoices.rejects(expectedError);
|
|
try {
|
|
await accountDeleteManager.refundSubscriptions(
|
|
'fxa_unverified_account_delete',
|
|
'customerId'
|
|
);
|
|
assert.fail('expecting refundSubscriptions exception');
|
|
} catch (error) {
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockStripeHelper.refundInvoices,
|
|
expectedInvoices
|
|
);
|
|
sinon.assert.calledOnceWithExactly(
|
|
mockPaypalHelper.refundInvoices,
|
|
expectedInvoices
|
|
);
|
|
assert.deepEqual(error, expectedError);
|
|
}
|
|
});
|
|
});
|
|
});
|