Files
overleaf-cep/services/web/test/unit/src/UserMembership/UserMembershipController.test.mjs
T
Jessica Lawshe 1768bef22a Merge pull request #26366 from overleaf/jel-group-csv
[web] Include in group members CSV if user is managed and/or linked to the group's SSO

GitOrigin-RevId: 449974917d98cf121ea46eaa58be4b3666d88268
2025-07-15 08:06:14 +00:00

562 lines
18 KiB
JavaScript

import { expect, vi } from 'vitest'
import sinon from 'sinon'
import MockRequest from '../helpers/MockRequest.js'
import MockResponse from '../helpers/MockResponse.js'
import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import {
UserIsManagerError,
UserNotFoundError,
UserAlreadyAddedError,
} from '../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
const assertCalledWith = sinon.assert.calledWith
const modulePath =
'../../../../app/src/Features/UserMembership/UserMembershipController.mjs'
vi.mock(
'../../../../app/src/Features/UserMembership/UserMembershipErrors.js',
() =>
vi.importActual(
'../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
)
)
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('UserMembershipController', function () {
beforeEach(async function (ctx) {
ctx.req = new MockRequest()
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',
fetchV1Data: callback => callback(null, ctx.subscription),
}
ctx.institution = {
_id: 'mock-institution-id',
v1Id: 123,
fetchV1Data: callback => {
const institution = Object.assign({}, ctx.institution)
institution.name = 'Test Institution Name'
callback(null, institution)
},
}
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,
},
}
ctx.SessionManager = {
getSessionUser: sinon.stub().returns(ctx.user),
getLoggedInUserId: sinon.stub().returns(ctx.user._id),
}
ctx.SSOConfig = {
findById: sinon
.stub()
.returns({ exec: sinon.stub().resolves({ enabled: true }) }),
}
ctx.UserMembershipHandler = {
getEntity: sinon.stub().yields(null, ctx.subscription),
createEntity: sinon.stub().yields(null, ctx.institution),
getUsers: sinon.stub().yields(null, ctx.users),
addUser: sinon.stub().yields(null, ctx.newUser),
removeUser: sinon.stub().yields(null),
promises: {
getUsers: sinon.stub().resolves(ctx.users),
},
}
ctx.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
},
getAssignment: sinon.stub().yields(null, { variant: 'default' }),
}
ctx.RecurlyClient = {
promises: {
getSubscription: sinon.stub().resolves({}),
},
}
vi.doMock(
'../../../../app/src/Features/UserMembership/UserMembershipErrors',
() => ({
UserIsManagerError,
UserNotFoundError,
UserAlreadyAddedError,
})
)
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('@overleaf/settings', () => ({
default: ctx.Settings,
}))
vi.doMock('../../../../app/src/models/SSOConfig', () => ({
SSOConfig: ctx.SSOConfig,
}))
ctx.Modules = {
promises: {
hooks: {
fire: sinon.stub(),
},
},
}
vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({
default: ctx.Modules,
}))
ctx.UserMembershipController = (await import(modulePath)).default
})
describe('index', function () {
beforeEach(function (ctx) {
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.group
})
it('get users', async function (ctx) {
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
render: () => {
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.promises.getUsers,
ctx.subscription,
{ modelName: 'Subscription' }
)
},
})
})
it('render group view', async function (ctx) {
ctx.subscription.managedUsersEnabled = false
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-members-react')
expect(viewParams.users).to.deep.equal(ctx.users)
expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(false)
},
})
})
it('render group view with managed users', async function (ctx) {
ctx.subscription.managedUsersEnabled = true
await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-members-react')
expect(viewParams.users).to.deep.equal(ctx.users)
expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
expect(viewParams.managedUsersActive).to.equal(true)
expect(viewParams.isUserGroupManager).to.equal(false)
},
})
})
it('render group managers view', async function (ctx) {
ctx.req.entityConfig = EntityConfigs.groupManagers
await ctx.UserMembershipController.manageGroupManagers(ctx.req, {
render: (viewPath, viewParams) => {
expect(viewPath).to.equal('user_membership/group-managers-react')
expect(viewParams.groupSize).to.equal(undefined)
},
})
})
it('render institution view', async function (ctx) {
ctx.req.entity = ctx.institution
ctx.req.entityConfig = EntityConfigs.institution
await ctx.UserMembershipController.manageInstitutionManagers(ctx.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', function () {
beforeEach(function (ctx) {
ctx.req.body.email = ctx.newUser.email
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
})
it('add user', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.add(ctx.req, {
json: () => {
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.addUser,
ctx.subscription,
{ modelName: 'Subscription' },
ctx.newUser.email
)
resolve()
},
})
})
})
it('return user object', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.add(ctx.req, {
json: payload => {
payload.user.should.equal(ctx.newUser)
resolve()
},
})
})
})
it('handle readOnly entity', async function (ctx) {
await new Promise(resolve => {
ctx.req.entityConfig = EntityConfigs.group
ctx.UserMembershipController.add(ctx.req, null, error => {
expect(error).to.exist
expect(error).to.be.an.instanceof(Errors.NotFoundError)
resolve()
})
})
})
it('handle user already added', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError())
ctx.UserMembershipController.add(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('user_already_added')
resolve()
},
}),
})
})
})
it('handle user not found', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipHandler.addUser.yields(new UserNotFoundError())
ctx.UserMembershipController.add(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('user_not_found')
resolve()
},
}),
})
})
})
it('handle invalid email', async function (ctx) {
await new Promise(resolve => {
ctx.req.body.email = 'not_valid_email'
ctx.UserMembershipController.add(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('invalid_email')
resolve()
},
}),
})
})
})
})
describe('remove', function () {
beforeEach(function (ctx) {
ctx.req.params.userId = ctx.newUser._id
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
})
it('remove user', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.remove(ctx.req, {
sendStatus: () => {
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.removeUser,
ctx.subscription,
{ modelName: 'Subscription' },
ctx.newUser._id
)
resolve()
},
})
})
})
it('handle readOnly entity', async function (ctx) {
await new Promise(resolve => {
ctx.req.entityConfig = EntityConfigs.group
ctx.UserMembershipController.remove(ctx.req, null, error => {
expect(error).to.exist
expect(error).to.be.an.instanceof(Errors.NotFoundError)
resolve()
})
})
})
it('prevent self removal', async function (ctx) {
await new Promise(resolve => {
ctx.req.params.userId = ctx.user._id
ctx.UserMembershipController.remove(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal('managers_cannot_remove_self')
resolve()
},
}),
})
})
})
it('prevent admin removal', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipHandler.removeUser.yields(new UserIsManagerError())
ctx.UserMembershipController.remove(ctx.req, {
status: () => ({
json: payload => {
expect(payload.error.code).to.equal(
'managers_cannot_remove_admin'
)
resolve()
},
}),
})
})
})
})
describe('exportCsv', function () {
beforeEach(function (ctx) {
ctx.req.entity = ctx.subscription
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.res = new MockResponse()
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('get users', function (ctx) {
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.promises.getUsers,
ctx.subscription,
{ modelName: 'Subscription' }
)
})
it('should set the correct content type on the request', function (ctx) {
assertCalledWith(ctx.res.contentType, 'text/csv; charset=utf-8')
})
it('should name the exported csv file', function (ctx) {
assertCalledWith(
ctx.res.header,
'Content-Disposition',
'attachment; filename="Group.csv"'
)
})
it('should export the correct csv', function (ctx) {
assertCalledWith(
ctx.res.send,
'"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', function () {
beforeEach(function (ctx) {
ctx.req.entity = Object.assign(
{ managedUsersEnabled: true },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.res = new MockResponse()
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', function (ctx) {
assertCalledWith(
ctx.res.send,
'"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', function () {
beforeEach(function (ctx) {
ctx.req.entity = Object.assign(
{ ssoConfig: 'sso-config-id' },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.Modules.promises.hooks.fire.resolves([true])
ctx.res = new MockResponse()
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', function (ctx) {
assertCalledWith(
ctx.res.send,
'"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', function () {
beforeEach(function (ctx) {
ctx.req.entity = Object.assign(
{ managedUsersEnabled: true },
{ ssoConfig: 'sso-config-id' },
ctx.subscription
)
ctx.req.entityConfig = EntityConfigs.groupManagers
ctx.Modules.promises.hooks.fire.resolves([true])
ctx.res = new MockResponse()
ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
})
it('should export the correct csv', function (ctx) {
assertCalledWith(
ctx.res.send,
'"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', function () {
beforeEach(function (ctx) {
ctx.req.params.name = 'publisher'
ctx.req.params.id = 'abc'
})
it('renders view', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.new(ctx.req, {
render: (viewPath, data) => {
expect(data.entityName).to.eq('publisher')
expect(data.entityId).to.eq('abc')
resolve()
},
})
})
})
})
describe('create', function () {
beforeEach(function (ctx) {
ctx.req.params.name = 'institution'
ctx.req.entityConfig = EntityConfigs.institution
ctx.req.params.id = 123
})
it('creates institution', async function (ctx) {
await new Promise(resolve => {
ctx.UserMembershipController.create(ctx.req, {
redirect: path => {
expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
sinon.assert.calledWithMatch(
ctx.UserMembershipHandler.createEntity,
123,
{ modelName: 'Institution' }
)
resolve()
},
})
})
})
})
})