Files
overleaf-cep/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs
Kristina 4c5cdecffa [web] fix bug where pending downgrades are removed when subscriptions change (#30420)
* preserve pending changes when generating change requests
* re-apply pending term_end changes after immediate updates
* block changes when Stripe subscription has multiple phases
* handle MultiplePendingChangesError & rm PendingChangeError

GitOrigin-RevId: 0af11044766ff48e683d684ad6d62b732d17290c
2026-02-03 09:05:55 +00:00

819 lines
26 KiB
JavaScript

import { expect, vi, describe, it, beforeEach } from 'vitest'
import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.mjs'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import UserMembershipErrors from '../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
const modulePath =
'../../../../app/src/Features/UserMembership/UserMembershipController.mjs'
vi.mock(
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs',
() =>
vi.importActual(
'../../../../app/src/Features/UserMembership/UserMembershipErrors.mjs'
)
)
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('UserMembershipController', () => {
let recurlySubscriptionId
beforeEach(async ctx => {
recurlySubscriptionId = 'mock-recurly-subscription-id'
ctx.req = new MockRequest(vi)
ctx.req.params.id = 'mock-entity-id'
ctx.user = { _id: 'mock-user-id' }
ctx.newUser = { _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' }
ctx.subscription = {
_id: 'mock-subscription-id',
admin_id: 'mock-admin-id',
manager_ids: ['mock-admin-id'],
planCode: 'group_professional',
recurlySubscription_id: recurlySubscriptionId,
fetchV1Data: vi.fn(callback => callback(null, ctx.subscription)),
}
ctx.institution = {
_id: 'mock-institution-id',
v1Id: 123,
fetchV1Data: vi.fn(callback => {
const institution = { ...ctx.institution }
institution.name = 'Test Institution Name'
callback(null, institution)
}),
managerIds: ['mock-member-id-1'],
}
ctx.users = [
{
_id: 'mock-member-id-1',
email: 'mock-email-1@foo.com',
last_logged_in_at: '2020-08-09T12:43:11.467Z',
last_active_at: '2021-08-09T12:43:11.467Z',
},
{
_id: 'mock-member-id-2',
email: 'mock-email-2@foo.com',
last_logged_in_at: '2020-05-20T10:41:11.407Z',
last_active_at: '2021-05-20T10:41:11.407Z',
},
{
_id: 'mock-member-id-3',
email: 'mock-email-3@foo.com',
last_logged_in_at: '2021-08-10T10:41:11.407Z',
last_active_at: '2021-08-20T10:41:11.407Z',
enrollment: {
managedBy: 'some-other-subscription-id',
enrolledAt: '2021-05-20T10:41:11.407Z',
sso: undefined,
},
},
{
_id: 'mock-member-id-4',
email: 'mock-email-4@foo.com',
last_logged_in_at: '2021-01-01T10:41:11.407Z',
last_active_at: '2021-01-02T10:41:11.407Z',
enrollment: {
managedBy: 'mock-subscription-id',
enrolledAt: '2021-01-02T10:41:11.407Z',
sso: undefined,
},
},
{
_id: 'mock-member-id-5',
email: 'mock-email-5@foo.com',
last_logged_in_at: '2023-01-01T10:41:11.407Z',
last_active_at: '2023-01-02T10:41:11.407Z',
enrollment: {
sso: [{ groupId: ctx.subscription._id }],
},
},
{
_id: 'mock-member-id-6',
email: 'mock-email-6@foo.com',
last_logged_in_at: '2024-01-01T10:41:11.407Z',
last_active_at: '2024-01-02T10:41:11.407Z',
enrollment: {
managedBy: 'mock-subscription-id',
enrolledAt: '2024-01-02T10:41:11.407Z',
sso: [{ groupId: ctx.subscription._id }],
},
},
]
ctx.Settings = {
managedUsers: {
enabled: false,
},
plans: [
{
planCode: 'personal',
name: 'Personal',
price_in_cents: 0,
features: {
collaborators: -1,
dropbox: true,
github: true,
gitBridge: true,
versioning: true,
compileTimeout: 180,
compileGroup: 'standard',
references: true,
trackChanges: true,
},
},
],
}
ctx.SessionManager = {
getSessionUser: vi.fn().mockReturnValue(ctx.user),
getLoggedInUserId: vi.fn().mockReturnValue(ctx.user._id),
}
ctx.SSOConfig = {
findById: vi.fn().mockReturnValue({
exec: vi.fn().mockResolvedValue({ enabled: true }),
}),
}
ctx.UserMembershipHandler = {
getEntity: vi.fn((_entity, _options, callback) =>
callback(null, ctx.subscription)
),
createEntity: vi.fn((_entity, _options, callback) =>
callback(null, ctx.institution)
),
getUsers: vi.fn((_entity, _options, callback) =>
callback(null, ctx.users)
),
addUser: vi.fn((_entity, _options, _email, callback) =>
callback(null, ctx.newUser)
),
removeUser: vi.fn((_entity, _options, _userId, callback) =>
callback(null)
),
promises: {
getUsers: vi.fn().mockResolvedValue(ctx.users),
addUser: vi.fn().mockResolvedValue(ctx.newUser),
removeUser: vi.fn().mockResolvedValue(),
createEntity: vi.fn().mockResolvedValue(ctx.institution),
},
}
ctx.SplitTestHandler = {
promises: {
getAssignment: vi.fn().mockResolvedValue({ variant: 'default' }),
},
getAssignment: vi.fn((_testName, _userId, callback) =>
callback(null, { variant: 'default' })
),
}
ctx.RecurlyClient = {
promises: {
getSubscription: vi.fn().mockResolvedValue({
id: recurlySubscriptionId,
}),
},
}
ctx.PlansLocator = {
findLocalPlanInSettings: vi.fn().mockReturnValue({
planCode: 'group_professional',
canUseFlexibleLicensing: true,
}),
}
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
default: ctx.SessionManager,
})
)
vi.doMock(
'../../../../app/src/Features/SplitTests/SplitTestHandler',
() => ({
default: ctx.SplitTestHandler,
})
)
vi.doMock(
'../../../../app/src/Features/UserMembership/UserMembershipHandler',
() => ({
default: ctx.UserMembershipHandler,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/RecurlyClient',
() => ({
default: ctx.RecurlyClient,
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/PlansLocator.mjs',
() => ({
default: ctx.PlansLocator,
})
)
vi.doMock('@overleaf/settings', () => ({
default: ctx.Settings,
}))
vi.doMock('../../../../app/src/models/SSOConfig', () => ({
SSOConfig: ctx.SSOConfig,
}))
ctx.Modules = {
promises: {
hooks: {
fire: vi.fn(),
},
},
}
vi.doMock('../../../../app/src/infrastructure/Modules.mjs', () => ({
default: ctx.Modules,
}))
ctx.UserMembershipController = (await import(modulePath)).default
})
describe('index', () => {
beforeEach(ctx => {
ctx.req.user = ctx.user
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.group
ctx.Modules.promises.hooks.fire.mockResolvedValue([])
})
it('get users', async ({
UserMembershipController,
req,
UserMembershipHandler,
subscription,
}) => {
expect.assertions(1)
await UserMembershipController.manageGroupMembers(req, {
render: () => {
expect(UserMembershipHandler.promises.getUsers).toHaveBeenCalledWith(
subscription,
{
modelName: 'Subscription',
baseQuery: { groupPlan: true },
fields: {
access: 'manager_ids',
membership: 'member_ids',
name: 'teamName',
primaryKey: '_id',
read: ['invited_emails', 'teamInvites', 'member_ids'],
write: null,
},
hasMembersLimit: true,
readOnly: true,
}
)
},
})
})
it('render group view', async ({
UserMembershipController,
req,
subscription,
users,
}) => {
expect.assertions(4)
subscription.managedUsersEnabled = false
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-members-react')
expect(viewParams.users).to.deep.equal(users)
expect(viewParams.groupSize).to.equal(subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(false)
},
})
})
it('render group view with managed users', async ({
UserMembershipController,
req,
subscription,
users,
}) => {
expect.assertions(5)
subscription.managedUsersEnabled = true
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-members-react')
expect(viewParams.users).to.deep.equal(users)
expect(viewParams.groupSize).to.equal(subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(true)
expect(viewParams.isUserGroupManager).to.equal(false)
},
})
})
describe('canUseAddSeatsFeature', () => {
beforeEach(ctx => {
ctx.subscription.admin_id = 'mock-admin-id'
ctx.SessionManager.getLoggedInUserId.mockReturnValue('mock-admin-id')
})
it('should be true when all conditions are met', async ({
UserMembershipController,
req,
}) => {
expect.assertions(1)
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewParams.canUseAddSeatsFeature).to.equal(true)
},
})
})
it('should be false when plan does not support flexible licensing', async ({
UserMembershipController,
req,
PlansLocator,
}) => {
expect.assertions(1)
PlansLocator.findLocalPlanInSettings.mockReturnValue({
planCode: 'group_professional',
canUseFlexibleLicensing: false,
})
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewParams.canUseAddSeatsFeature).to.equal(false)
},
})
})
it('should be false when user is not admin', async ({
UserMembershipController,
req,
SessionManager,
}) => {
expect.assertions(1)
SessionManager.getLoggedInUserId.mockReturnValue('mock-user-id')
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewParams.canUseAddSeatsFeature).to.equal(false)
},
})
})
it('should be false when recurly subscription does not exist', async ({
UserMembershipController,
req,
subscription,
}) => {
expect.assertions(1)
subscription.recurlySubscription_id = null
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewParams.canUseAddSeatsFeature).to.equal(false)
},
})
})
it('should be true when recurly subscription has pending changes', async ({
UserMembershipController,
req,
RecurlyClient,
}) => {
expect.assertions(1)
RecurlyClient.promises.getSubscription.mockResolvedValue({
id: recurlySubscriptionId,
pendingChange: {},
})
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewParams.canUseAddSeatsFeature).to.equal(true)
},
})
})
it('should be false when fetching recurly subscription fails', async ({
UserMembershipController,
req,
RecurlyClient,
}) => {
expect.assertions(1)
RecurlyClient.promises.getSubscription.mockRejectedValue(
new Error('Recurly error')
)
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewParams.canUseAddSeatsFeature).to.equal(false)
},
})
})
it('should be false when plan is not found', async ({
UserMembershipController,
req,
PlansLocator,
}) => {
expect.assertions(1)
PlansLocator.findLocalPlanInSettings.mockReturnValue(null)
await UserMembershipController.manageGroupMembers(req, {
render: (viewPath, viewParams) => {
expect(viewParams.canUseAddSeatsFeature).to.equal(false)
},
})
})
})
it('render group managers view', async ({
UserMembershipController,
req,
user,
}) => {
expect.assertions(2)
req.user = user
req.entityConfig = EntityConfigs.groupManagers
await UserMembershipController.manageGroupManagers(req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-managers-react')
expect(viewParams.groupSize).to.equal(undefined)
},
})
})
it('render institution view', async ({
UserMembershipController,
req,
user,
institution,
}) => {
expect.assertions(3)
req.user = user
req.entity = institution
req.entityConfig = EntityConfigs.institution
await UserMembershipController.manageInstitutionManagers(req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal(
'user_membership/institution-managers-react'
)
expect(viewParams.name).to.equal('Test Institution Name')
expect(viewParams.groupSize).to.equal(undefined)
},
})
})
})
describe('add', () => {
beforeEach(ctx => {
ctx.req.body.email = ctx.newUser.email
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
})
it('add user', async ({
UserMembershipController,
req,
UserMembershipHandler,
subscription,
newUser,
}) => {
expect.assertions(1)
await UserMembershipController.add(req, {
json: () => {
expect(UserMembershipHandler.promises.addUser).toHaveBeenCalledWith(
subscription,
{
modelName: 'Subscription',
baseQuery: { groupPlan: true },
fields: {
access: 'manager_ids',
membership: 'member_ids',
name: 'teamName',
primaryKey: '_id',
read: ['manager_ids'],
write: 'manager_ids',
},
},
newUser.email
)
},
})
})
it('return user object', async ({
UserMembershipController,
req,
newUser,
}) => {
expect.assertions(1)
await UserMembershipController.add(req, {
json: payload => {
expect(payload.user).to.equal(newUser)
},
})
})
it('handle readOnly entity', async ({ UserMembershipController, req }) => {
expect.assertions(2)
req.entityConfig = EntityConfigs.group
await UserMembershipController.add(req, null, error => {
expect(error).to.exist
expect(error).to.be.an.instanceof(Errors.NotFoundError)
})
})
it('handle user already added', async ({
UserMembershipController,
req,
UserMembershipHandler,
}) => {
expect.assertions(1)
UserMembershipHandler.promises.addUser.mockRejectedValue(
new UserMembershipErrors.UserAlreadyAddedError()
)
await UserMembershipController.add(
req,
{
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('user_already_added')
},
}),
},
() => {}
)
})
it('handle user not found', async ({
UserMembershipController,
req,
UserMembershipHandler,
}) => {
expect.assertions(1)
UserMembershipHandler.promises.addUser.mockRejectedValue(
new UserMembershipErrors.UserNotFoundError()
)
await UserMembershipController.add(req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('user_not_found')
},
}),
})
})
it('handle invalid email', async ({ UserMembershipController, req }) => {
expect.assertions(1)
req.body.email = 'not_valid_email'
await UserMembershipController.add(req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('invalid_email')
},
}),
})
})
})
describe('remove', () => {
beforeEach(ctx => {
ctx.req.params.userId = ctx.newUser._id
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
})
it('remove user', async ({
UserMembershipController,
req,
UserMembershipHandler,
subscription,
newUser,
}) => {
expect.assertions(1)
await UserMembershipController.remove(req, {
sendStatus: () => {
expect(
UserMembershipHandler.promises.removeUser
).toHaveBeenCalledWith(
subscription,
{
modelName: 'Subscription',
baseQuery: {
groupPlan: true,
},
fields: {
access: 'manager_ids',
membership: 'member_ids',
name: 'teamName',
primaryKey: '_id',
read: ['manager_ids'],
write: 'manager_ids',
},
},
newUser._id
)
},
})
})
it('handle readOnly entity', async ({ UserMembershipController, req }) => {
expect.assertions(2)
req.entityConfig = EntityConfigs.group
await UserMembershipController.remove(req, null, error => {
expect(error).to.exist
expect(error).to.be.an.instanceof(Errors.NotFoundError)
})
})
it('prevent self removal', async ({
UserMembershipController,
req,
user,
}) => {
expect.assertions(1)
req.params.userId = user._id
await UserMembershipController.remove(req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('managers_cannot_remove_self')
},
}),
})
})
it('prevent admin removal', async ({
UserMembershipController,
req,
UserMembershipHandler,
}) => {
expect.assertions(1)
UserMembershipHandler.promises.removeUser.mockRejectedValue(
new UserMembershipErrors.UserIsManagerError()
)
await UserMembershipController.remove(req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('managers_cannot_remove_admin')
},
}),
})
})
})
describe('exportCsv', () => {
beforeEach(ctx => {
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.res = new MockResponse(vi)
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('get users', ({ UserMembershipHandler, subscription }) => {
expect(UserMembershipHandler.promises.getUsers).toHaveBeenCalledWith(
subscription,
{
modelName: 'Subscription',
baseQuery: { groupPlan: true },
fields: {
access: 'manager_ids',
membership: 'member_ids',
name: 'teamName',
primaryKey: '_id',
read: ['manager_ids'],
write: 'manager_ids',
},
}
)
})
it('should set the correct content type on the request', ({ res }) => {
expect(res.contentType).toHaveBeenCalledWith('text/csv; charset=utf-8')
})
it('should name the exported csv file', ({ res }) => {
expect(res.header).toHaveBeenCalledWith(
'Content-Disposition',
'attachment; filename="Group.csv"'
)
})
it('should export the correct csv', ({ res }) => {
expect(res.send).toHaveBeenCalledWith(
'"email","last_logged_in_at","last_active_at"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z"\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z"\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z"\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z"\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z"\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z"'
)
})
})
describe('exportCsv when group is managed', () => {
beforeEach(ctx => {
ctx.req.entity = Object.assign(
{ managedUsersEnabled: true },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.res = new MockResponse(vi)
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', ({ res }) => {
expect(res.send).toHaveBeenCalledWith(
'"email","last_logged_in_at","last_active_at","managed"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true'
)
})
})
describe('exportCsv when group has SSO', () => {
beforeEach(ctx => {
ctx.req.entity = Object.assign(
{ ssoConfig: 'sso-config-id' },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.Modules.promises.hooks.fire.mockResolvedValue([true])
ctx.res = new MockResponse(vi)
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', ({ res }) => {
expect(res.send).toHaveBeenCalledWith(
'"email","last_logged_in_at","last_active_at","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true'
)
})
})
describe('exportCsv when group has SSO and managed users enabled', () => {
beforeEach(ctx => {
ctx.req.entity = Object.assign(
{ managedUsersEnabled: true },
{ ssoConfig: 'sso-config-id' },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.Modules.promises.hooks.fire.mockResolvedValue([true])
ctx.res = new MockResponse(vi)
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', ({ res }) => {
expect(res.send).toHaveBeenCalledWith(
'"email","last_logged_in_at","last_active_at","managed","sso"\n"mock-email-1@foo.com","2020-08-09T12:43:11.467Z","2021-08-09T12:43:11.467Z",false,false\n"mock-email-2@foo.com","2020-05-20T10:41:11.407Z","2021-05-20T10:41:11.407Z",false,false\n"mock-email-3@foo.com","2021-08-10T10:41:11.407Z","2021-08-20T10:41:11.407Z",false,false\n"mock-email-4@foo.com","2021-01-01T10:41:11.407Z","2021-01-02T10:41:11.407Z",true,false\n"mock-email-5@foo.com","2023-01-01T10:41:11.407Z","2023-01-02T10:41:11.407Z",false,true\n"mock-email-6@foo.com","2024-01-01T10:41:11.407Z","2024-01-02T10:41:11.407Z",true,true'
)
})
})
describe('new', () => {
beforeEach(ctx => {
ctx.req.params.name = 'publisher'
ctx.req.params.id = 'abc'
})
it('renders view', async ({ UserMembershipController, req }) => {
expect.assertions(2)
await UserMembershipController.new(req, {
render: (viewPath, data) => {
expect(data.entityName).to.eq('publisher')
expect(data.entityId).to.eq('abc')
},
})
})
})
describe('create', () => {
beforeEach(ctx => {
ctx.req.params.name = 'institution'
ctx.req.entityConfig = EntityConfigs.institution
ctx.req.params.id = 123
})
it('creates institution', async ({
UserMembershipController,
req,
UserMembershipHandler,
}) => {
expect.assertions(2)
await UserMembershipController.create(req, {
redirect: path => {
expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
expect(
UserMembershipHandler.promises.createEntity
).toHaveBeenCalledWith(123, {
fields: {
access: 'managerIds',
membership: 'member_ids',
name: 'name',
primaryKey: 'v1Id',
read: ['managerIds'],
write: 'managerIds',
},
modelName: 'Institution',
pathsFor: EntityConfigs.institution.pathsFor,
})
},
})
})
})
})