import { vi } from 'vitest' import sinon from 'sinon' const modulePath = '../../../../app/src/Features/Subscription/SubscriptionGroupController' describe('SubscriptionGroupController', function () { beforeEach(async function (ctx) { ctx.user = { _id: '!@312431', email: 'user@email.com' } ctx.adminUserId = '123jlkj' ctx.subscriptionId = '123434325412' ctx.user_email = 'bob@gmail.com' ctx.req = { session: { user: { _id: ctx.adminUserId, email: ctx.user_email, }, }, params: { subscriptionId: ctx.subscriptionId, }, query: {}, } ctx.subscription = { _id: ctx.subscriptionId, teamName: 'Cool group', groupPlan: true, membersLimit: 5, } ctx.plan = { canUseFlexibleLicensing: true, } ctx.recurlySubscription = { get isCollectionMethodManual() { return true }, } ctx.previewSubscriptionChangeData = { change: {}, currency: 'USD', } ctx.createSubscriptionChangeData = { adding: 1 } ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' } ctx.SubscriptionGroupHandler = { promises: { removeUserFromGroup: sinon.stub().resolves(), getUsersGroupSubscriptionDetails: sinon.stub().resolves({ subscription: ctx.subscription, plan: ctx.plan, recurlySubscription: ctx.recurlySubscription, }), previewAddSeatsSubscriptionChange: sinon .stub() .resolves(ctx.previewSubscriptionChangeData), createAddSeatsSubscriptionChange: sinon .stub() .resolves(ctx.createSubscriptionChangeData), ensureFlexibleLicensingEnabled: sinon.stub().resolves(), ensureSubscriptionIsActive: sinon.stub().resolves(), ensureSubscriptionCollectionMethodIsNotManual: sinon.stub().resolves(), ensureSubscriptionHasNoPendingChanges: sinon.stub().resolves(), ensureSubscriptionHasNoPastDueInvoice: sinon.stub().resolves(), getGroupPlanUpgradePreview: sinon .stub() .resolves(ctx.previewSubscriptionChangeData), checkBillingInfoExistence: sinon.stub().resolves(ctx.paymentMethod), updateSubscriptionPaymentTerms: sinon.stub().resolves(), }, } ctx.SubscriptionLocator = { promises: { getSubscription: sinon.stub().resolves(ctx.subscription), }, } ctx.SessionManager = { getLoggedInUserId(session) { return session.user._id }, getSessionUser(session) { return session.user }, } ctx.UserAuditLogHandler = { promises: { addEntry: sinon.stub().resolves(), }, } ctx.Modules = { promises: { hooks: { fire: sinon.stub().resolves(), }, }, } ctx.SplitTestHandler = { promises: { getAssignment: sinon.stub().resolves({ variant: 'enabled' }), }, } ctx.UserGetter = { promises: { getUserEmail: sinon.stub().resolves(ctx.user.email), }, } ctx.paymentMethod = { cardType: 'Visa', lastFour: '1111' } ctx.RecurlyClient = { promises: { getPaymentMethod: sinon.stub().resolves(ctx.paymentMethod), }, } ctx.SubscriptionController = {} ctx.SubscriptionModel = { Subscription: {} } ctx.PlansHelper = { isProfessionalGroupPlan: sinon.stub().returns(false), } ctx.Errors = { MissingBillingInfoError: class extends Error {}, ManuallyCollectedError: class extends Error {}, PendingChangeError: class extends Error {}, InactiveError: class extends Error {}, SubtotalLimitExceededError: class extends Error {}, HasPastDueInvoiceError: class extends Error {}, } vi.doMock( '../../../../app/src/Features/Subscription/SubscriptionGroupHandler', () => ({ default: ctx.SubscriptionGroupHandler, }) ) vi.doMock( '../../../../app/src/Features/Subscription/SubscriptionLocator', () => ({ default: ctx.SubscriptionLocator, }) ) vi.doMock( '../../../../app/src/Features/Authentication/SessionManager', () => ({ default: ctx.SessionManager, }) ) vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({ default: ctx.UserAuditLogHandler, })) vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ default: ctx.Modules, })) vi.doMock( '../../../../app/src/Features/SplitTests/SplitTestHandler', () => ({ default: ctx.SplitTestHandler, }) ) vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ default: ctx.UserGetter, })) vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({ default: (ctx.ErrorController = { notFound: sinon.stub(), }), })) vi.doMock( '../../../../app/src/Features/Subscription/SubscriptionController', () => ({ default: ctx.SubscriptionController, }) ) vi.doMock( '../../../../app/src/Features/Subscription/RecurlyClient', () => ({ default: ctx.RecurlyClient, }) ) vi.doMock( '../../../../app/src/Features/Subscription/PlansHelper', () => ctx.PlansHelper ) vi.doMock( '../../../../app/src/Features/Subscription/Errors', () => ctx.Errors ) vi.doMock( '../../../../app/src/models/Subscription', () => ctx.SubscriptionModel ) vi.doMock('@overleaf/logger', () => ({ default: { err: sinon.stub(), error: sinon.stub(), warn: sinon.stub(), log: sinon.stub(), debug: sinon.stub(), }, })) ctx.Controller = (await import(modulePath)).default }) describe('removeUserFromGroup', function () { it('should use the subscription id for the logged in user and take the user id from the params', function (ctx) { return new Promise(resolve => { const userIdToRemove = '31231' ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription const res = { sendStatus: () => { ctx.SubscriptionGroupHandler.promises.removeUserFromGroup .calledWith(ctx.subscriptionId, userIdToRemove, { initiatorId: ctx.req.session.user._id, ipAddress: ctx.req.ip, }) .should.equal(true) resolve() }, } ctx.Controller.removeUserFromGroup(ctx.req, res, resolve) }) }) it('should log that the user has been removed', function (ctx) { return new Promise(resolve => { const userIdToRemove = '31231' ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription const res = { sendStatus: () => { sinon.assert.calledWith( ctx.UserAuditLogHandler.promises.addEntry, userIdToRemove, 'remove-from-group-subscription', ctx.adminUserId, ctx.req.ip, { subscriptionId: ctx.subscriptionId } ) resolve() }, } ctx.Controller.removeUserFromGroup(ctx.req, res, resolve) }) }) it('should call the group SSO hooks with group SSO enabled', function (ctx) { return new Promise(resolve => { const userIdToRemove = '31231' ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription ctx.Modules.promises.hooks.fire .withArgs('hasGroupSSOEnabled', ctx.subscription) .resolves([true]) const res = { sendStatus: () => { ctx.Modules.promises.hooks.fire .calledWith('hasGroupSSOEnabled', ctx.subscription) .should.equal(true) ctx.Modules.promises.hooks.fire .calledWith( 'unlinkUserFromGroupSSO', userIdToRemove, ctx.subscriptionId ) .should.equal(true) sinon.assert.calledTwice(ctx.Modules.promises.hooks.fire) resolve() }, } ctx.Controller.removeUserFromGroup(ctx.req, res, resolve) }) }) it('should call the group SSO hooks with group SSO disabled', function (ctx) { return new Promise(resolve => { const userIdToRemove = '31231' ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription ctx.Modules.promises.hooks.fire .withArgs('hasGroupSSOEnabled', ctx.subscription) .resolves([false]) const res = { sendStatus: () => { ctx.Modules.promises.hooks.fire .calledWith('hasGroupSSOEnabled', ctx.subscription) .should.equal(true) sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire) resolve() }, } ctx.Controller.removeUserFromGroup(ctx.req, res, resolve) }) }) }) describe('removeSelfFromGroup', function () { it('gets subscription and remove user', function (ctx) { return new Promise(resolve => { ctx.req.query = { subscriptionId: ctx.subscriptionId } const memberUserIdToremove = 123456789 ctx.req.session.user._id = memberUserIdToremove const res = { sendStatus: () => { sinon.assert.calledWith( ctx.SubscriptionLocator.promises.getSubscription, ctx.subscriptionId ) sinon.assert.calledWith( ctx.SubscriptionGroupHandler.promises.removeUserFromGroup, ctx.subscriptionId, memberUserIdToremove, { initiatorId: ctx.req.session.user._id, ipAddress: ctx.req.ip, } ) resolve() }, } ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve) }) }) it('should log that the user has left the subscription', function (ctx) { return new Promise(resolve => { ctx.req.query = { subscriptionId: ctx.subscriptionId } const memberUserIdToremove = '123456789' ctx.req.session.user._id = memberUserIdToremove const res = { sendStatus: () => { sinon.assert.calledWith( ctx.UserAuditLogHandler.promises.addEntry, memberUserIdToremove, 'remove-from-group-subscription', memberUserIdToremove, ctx.req.ip, { subscriptionId: ctx.subscriptionId } ) resolve() }, } ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve) }) }) it('should call the group SSO hooks with group SSO enabled', function (ctx) { return new Promise(resolve => { ctx.req.query = { subscriptionId: ctx.subscriptionId } const memberUserIdToremove = '123456789' ctx.req.session.user._id = memberUserIdToremove ctx.Modules.promises.hooks.fire .withArgs('hasGroupSSOEnabled', ctx.subscription) .resolves([true]) const res = { sendStatus: () => { ctx.Modules.promises.hooks.fire .calledWith('hasGroupSSOEnabled', ctx.subscription) .should.equal(true) ctx.Modules.promises.hooks.fire .calledWith( 'unlinkUserFromGroupSSO', memberUserIdToremove, ctx.subscriptionId ) .should.equal(true) sinon.assert.calledTwice(ctx.Modules.promises.hooks.fire) resolve() }, } ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve) }) }) it('should call the group SSO hooks with group SSO disabled', function (ctx) { return new Promise(resolve => { const userIdToRemove = '31231' ctx.req.session.user._id = userIdToRemove ctx.req.params = { user_id: userIdToRemove } ctx.req.entity = ctx.subscription ctx.Modules.promises.hooks.fire .withArgs('hasGroupSSOEnabled', ctx.subscription) .resolves([false]) const res = { sendStatus: () => { ctx.Modules.promises.hooks.fire .calledWith('hasGroupSSOEnabled', ctx.subscription) .should.equal(true) sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire) resolve() }, } ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve) }) }) }) describe('addSeatsToGroupSubscription', function () { it('should render the "add seats" page', function (ctx) { return new Promise((resolve, reject) => { const res = { render: (page, props) => { ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails .calledWith(ctx.req.session.user._id) .should.equal(true) ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled .calledWith(ctx.plan) .should.equal(true) ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges .calledWith(ctx.recurlySubscription) .should.equal(true) ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive .calledWith(ctx.subscription) .should.equal(true) ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice .calledWith(ctx.subscription) .should.equal(true) ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence .calledWith(ctx.recurlySubscription, ctx.adminUserId) .should.equal(true) page.should.equal('subscriptions/add-seats') props.subscriptionId.should.equal(ctx.subscriptionId) props.groupName.should.equal(ctx.subscription.teamName) props.totalLicenses.should.equal(ctx.subscription.membersLimit) props.isProfessional.should.equal(false) props.isCollectionMethodManual.should.equal(true) resolve() }, } ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) }) }) it('should redirect to subscription page when getting subscription details fails', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails = sinon.stub().rejects() const res = { redirect: url => { url.should.equal('/user/subscription') resolve() }, } ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) }) }) it('should redirect to subscription page when flexible licensing is not enabled', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled = sinon.stub().rejects() const res = { redirect: url => { url.should.equal('/user/subscription') resolve() }, } ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) }) }) it('should redirect to missing billing information page when billing information is missing', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.checkBillingInfoExistence = sinon .stub() .throws(new ctx.Errors.MissingBillingInfoError()) const res = { redirect: url => { url.should.equal( '/user/subscription/group/missing-billing-information' ) resolve() }, } ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) }) }) it('should redirect to subscription page when there is a pending change', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges = sinon.stub().throws(new ctx.Errors.PendingChangeError()) const res = { redirect: url => { url.should.equal('/user/subscription') resolve() }, } ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) }) }) it('should redirect to subscription page when subscription is not active', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.ensureSubscriptionIsActive = sinon .stub() .rejects() const res = { redirect: url => { url.should.equal('/user/subscription') resolve() }, } ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) }) }) it('should redirect to subscription page when subscription has pending invoice', function (ctx) { ctx.SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPastDueInvoice = sinon.stub().rejects() return new Promise(resolve => { const res = { redirect: url => { url.should.equal('/user/subscription') resolve() }, } ctx.Controller.addSeatsToGroupSubscription(ctx.req, res) }) }) }) describe('previewAddSeatsSubscriptionChange', function () { it('should preview "add seats" change', function (ctx) { return new Promise(resolve => { ctx.req.body = { adding: 2 } const res = { json: data => { ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange .calledWith(ctx.req.session.user._id, ctx.req.body.adding) .should.equal(true) data.should.deep.equal(ctx.previewSubscriptionChangeData) resolve() }, } ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res) }) }) it('should fail previewing "add seats" change', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = sinon.stub().rejects() const res = { status: statusCode => { statusCode.should.equal(500) return { end: () => { resolve() }, } }, } ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res) }) }) it('should fail previewing "add seats" change with SubtotalLimitExceededError', function (ctx) { return new Promise(resolve => { ctx.req.body = { adding: 2 } ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange = sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError()) const res = { status: statusCode => { statusCode.should.equal(422) return { json: data => { data.should.deep.equal({ code: 'subtotal_limit_exceeded', adding: ctx.req.body.adding, }) resolve() }, } }, } ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res) }) }) }) describe('createAddSeatsSubscriptionChange', function () { it('should apply "add seats" change', function (ctx) { return new Promise(resolve => { ctx.req.body = { adding: 2 } const res = { json: data => { ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange .calledWith(ctx.req.session.user._id, ctx.req.body.adding) .should.equal(true) data.should.deep.equal(ctx.createSubscriptionChangeData) resolve() }, } ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) }) }) it('should fail applying "add seats" change', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = sinon.stub().rejects() const res = { status: statusCode => { statusCode.should.equal(500) return { end: () => { resolve() }, } }, } ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) }) }) it('should fail applying "add seats" change with SubtotalLimitExceededError', function (ctx) { return new Promise(resolve => { ctx.req.body = { adding: 2 } ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange = sinon.stub().throws(new ctx.Errors.SubtotalLimitExceededError()) const res = { status: statusCode => { statusCode.should.equal(422) return { json: data => { data.should.deep.equal({ code: 'subtotal_limit_exceeded', adding: ctx.req.body.adding, }) resolve() }, } }, } ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res) }) }) }) describe('submitForm', function () { it('should build and pass the request body to the sales submit handler', function (ctx) { return new Promise(resolve => { const adding = 100 const poNumber = 'PO123456' ctx.req.body = { adding, poNumber } const res = { sendStatus: code => { ctx.SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms( ctx.adminUserId, ctx.recurlySubscription, poNumber ) ctx.Modules.promises.hooks.fire .calledWith('sendSupportRequest', { email: ctx.user.email, subject: 'Sales Contact Form', message: '\n' + '**Overleaf Sales Contact Form:**\n' + '\n' + '**Subject:** Self-Serve Group User Increase Request\n' + '\n' + `**Estimated Number of Users:** ${adding}\n` + '\n' + `**PO Number:** ${poNumber}\n` + '\n' + `**Message:** This email has been generated on behalf of user with email **${ctx.user.email}** to request an increase in the total number of users for their subscription.`, inbox: 'sales', }) .should.equal(true) sinon.assert.calledOnce(ctx.Modules.promises.hooks.fire) code.should.equal(204) resolve() }, } ctx.Controller.submitForm(ctx.req, res, resolve) }) }) }) describe('subscriptionUpgradePage', function () { it('should render "subscription upgrade" page', function (ctx) { return new Promise(resolve => { const olSubscription = { membersLimit: 1, teamName: 'test team' } ctx.SubscriptionModel.Subscription.findOne = () => { return { exec: () => olSubscription, } } const res = { render: (page, data) => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview .calledWith(ctx.req.session.user._id) .should.equal(true) page.should.equal('subscriptions/upgrade-group-subscription-react') data.totalLicenses.should.equal(olSubscription.membersLimit) data.groupName.should.equal(olSubscription.teamName) data.changePreview.should.equal(ctx.previewSubscriptionChangeData) resolve() }, } ctx.Controller.subscriptionUpgradePage(ctx.req, res) }) }) it('should redirect if failed to generate preview', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .rejects() const res = { redirect: url => { url.should.equal('/user/subscription') resolve() }, } ctx.Controller.subscriptionUpgradePage(ctx.req, res) }) }) it('should redirect to missing billing information page when billing information is missing', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .throws(new ctx.Errors.MissingBillingInfoError()) const res = { redirect: url => { url.should.equal( '/user/subscription/group/missing-billing-information' ) resolve() }, } ctx.Controller.subscriptionUpgradePage(ctx.req, res) }) }) it('should redirect to manually collected subscription error page when collection method is manual', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .throws(new ctx.Errors.ManuallyCollectedError()) const res = { redirect: url => { url.should.equal( '/user/subscription/group/manually-collected-subscription' ) resolve() }, } ctx.Controller.subscriptionUpgradePage(ctx.req, res) }) }) it('should redirect to subtotal limit exceeded page', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon .stub() .throws(new ctx.Errors.SubtotalLimitExceededError()) const res = { redirect: url => { url.should.equal('/user/subscription/group/subtotal-limit-exceeded') resolve() }, } ctx.Controller.subscriptionUpgradePage(ctx.req, res) }) }) }) describe('upgradeSubscription', function () { it('should send 200 response', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon .stub() .resolves() const res = { sendStatus: code => { code.should.equal(200) resolve() }, } ctx.Controller.upgradeSubscription(ctx.req, res) }) }) it('should send 500 response', function (ctx) { return new Promise(resolve => { ctx.SubscriptionGroupHandler.promises.upgradeGroupPlan = sinon .stub() .rejects() const res = { sendStatus: code => { code.should.equal(500) resolve() }, } ctx.Controller.upgradeSubscription(ctx.req, res) }) }) }) })