UserMembershipController.test.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import { expect, vi } from 'vitest'
  2. import sinon from 'sinon'
  3. import MockRequest from '../helpers/MockRequest.js'
  4. import MockResponse from '../helpers/MockResponse.js'
  5. import EntityConfigs from '../../../../app/src/Features/UserMembership/UserMembershipEntityConfigs.js'
  6. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  7. import {
  8. UserIsManagerError,
  9. UserNotFoundError,
  10. UserAlreadyAddedError,
  11. } from '../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
  12. const assertCalledWith = sinon.assert.calledWith
  13. const modulePath =
  14. '../../../../app/src/Features/UserMembership/UserMembershipController.mjs'
  15. vi.mock(
  16. '../../../../app/src/Features/UserMembership/UserMembershipErrors.js',
  17. () =>
  18. vi.importActual(
  19. '../../../../app/src/Features/UserMembership/UserMembershipErrors.js'
  20. )
  21. )
  22. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  23. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  24. )
  25. describe('UserMembershipController', function () {
  26. beforeEach(async function (ctx) {
  27. ctx.req = new MockRequest()
  28. ctx.req.params.id = 'mock-entity-id'
  29. ctx.user = { _id: 'mock-user-id' }
  30. ctx.newUser = { _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' }
  31. ctx.subscription = {
  32. _id: 'mock-subscription-id',
  33. admin_id: 'mock-admin-id',
  34. manager_ids: ['mock-admin-id'],
  35. fetchV1Data: callback => callback(null, ctx.subscription),
  36. }
  37. ctx.institution = {
  38. _id: 'mock-institution-id',
  39. v1Id: 123,
  40. fetchV1Data: callback => {
  41. const institution = Object.assign({}, ctx.institution)
  42. institution.name = 'Test Institution Name'
  43. callback(null, institution)
  44. },
  45. }
  46. ctx.users = [
  47. {
  48. _id: 'mock-member-id-1',
  49. email: 'mock-email-1@foo.com',
  50. last_logged_in_at: '2020-08-09T12:43:11.467Z',
  51. last_active_at: '2021-08-09T12:43:11.467Z',
  52. },
  53. {
  54. _id: 'mock-member-id-2',
  55. email: 'mock-email-2@foo.com',
  56. last_logged_in_at: '2020-05-20T10:41:11.407Z',
  57. last_active_at: '2021-05-20T10:41:11.407Z',
  58. },
  59. {
  60. _id: 'mock-member-id-3',
  61. email: 'mock-email-3@foo.com',
  62. last_logged_in_at: '2021-08-10T10:41:11.407Z',
  63. last_active_at: '2021-08-20T10:41:11.407Z',
  64. enrollment: {
  65. managedBy: 'some-other-subscription-id',
  66. enrolledAt: '2021-05-20T10:41:11.407Z',
  67. sso: undefined,
  68. },
  69. },
  70. {
  71. _id: 'mock-member-id-4',
  72. email: 'mock-email-4@foo.com',
  73. last_logged_in_at: '2021-01-01T10:41:11.407Z',
  74. last_active_at: '2021-01-02T10:41:11.407Z',
  75. enrollment: {
  76. managedBy: 'mock-subscription-id',
  77. enrolledAt: '2021-01-02T10:41:11.407Z',
  78. sso: undefined,
  79. },
  80. },
  81. {
  82. _id: 'mock-member-id-5',
  83. email: 'mock-email-5@foo.com',
  84. last_logged_in_at: '2023-01-01T10:41:11.407Z',
  85. last_active_at: '2023-01-02T10:41:11.407Z',
  86. enrollment: {
  87. sso: [{ groupId: ctx.subscription._id }],
  88. },
  89. },
  90. {
  91. _id: 'mock-member-id-6',
  92. email: 'mock-email-6@foo.com',
  93. last_logged_in_at: '2024-01-01T10:41:11.407Z',
  94. last_active_at: '2024-01-02T10:41:11.407Z',
  95. enrollment: {
  96. managedBy: 'mock-subscription-id',
  97. enrolledAt: '2024-01-02T10:41:11.407Z',
  98. sso: [{ groupId: ctx.subscription._id }],
  99. },
  100. },
  101. ]
  102. ctx.Settings = {
  103. managedUsers: {
  104. enabled: false,
  105. },
  106. }
  107. ctx.SessionManager = {
  108. getSessionUser: sinon.stub().returns(ctx.user),
  109. getLoggedInUserId: sinon.stub().returns(ctx.user._id),
  110. }
  111. ctx.SSOConfig = {
  112. findById: sinon
  113. .stub()
  114. .returns({ exec: sinon.stub().resolves({ enabled: true }) }),
  115. }
  116. ctx.UserMembershipHandler = {
  117. getEntity: sinon.stub().yields(null, ctx.subscription),
  118. createEntity: sinon.stub().yields(null, ctx.institution),
  119. getUsers: sinon.stub().yields(null, ctx.users),
  120. addUser: sinon.stub().yields(null, ctx.newUser),
  121. removeUser: sinon.stub().yields(null),
  122. promises: {
  123. getUsers: sinon.stub().resolves(ctx.users),
  124. },
  125. }
  126. ctx.SplitTestHandler = {
  127. promises: {
  128. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  129. },
  130. getAssignment: sinon.stub().yields(null, { variant: 'default' }),
  131. }
  132. ctx.RecurlyClient = {
  133. promises: {
  134. getSubscription: sinon.stub().resolves({}),
  135. },
  136. }
  137. vi.doMock(
  138. '../../../../app/src/Features/UserMembership/UserMembershipErrors',
  139. () => ({
  140. UserIsManagerError,
  141. UserNotFoundError,
  142. UserAlreadyAddedError,
  143. })
  144. )
  145. vi.doMock(
  146. '../../../../app/src/Features/Authentication/SessionManager',
  147. () => ({
  148. default: ctx.SessionManager,
  149. })
  150. )
  151. vi.doMock(
  152. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  153. () => ({
  154. default: ctx.SplitTestHandler,
  155. })
  156. )
  157. vi.doMock(
  158. '../../../../app/src/Features/UserMembership/UserMembershipHandler',
  159. () => ({
  160. default: ctx.UserMembershipHandler,
  161. })
  162. )
  163. vi.doMock(
  164. '../../../../app/src/Features/Subscription/RecurlyClient',
  165. () => ({
  166. default: ctx.RecurlyClient,
  167. })
  168. )
  169. vi.doMock('@overleaf/settings', () => ({
  170. default: ctx.Settings,
  171. }))
  172. vi.doMock('../../../../app/src/models/SSOConfig', () => ({
  173. SSOConfig: ctx.SSOConfig,
  174. }))
  175. ctx.Modules = {
  176. promises: {
  177. hooks: {
  178. fire: sinon.stub(),
  179. },
  180. },
  181. }
  182. vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({
  183. default: ctx.Modules,
  184. }))
  185. ctx.UserMembershipController = (await import(modulePath)).default
  186. })
  187. describe('index', function () {
  188. beforeEach(function (ctx) {
  189. ctx.req.user = ctx.user
  190. ctx.req.entity = ctx.subscription
  191. ctx.req.entityConfig = EntityConfigs.group
  192. })
  193. it('get users', async function (ctx) {
  194. await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
  195. render: () => {
  196. sinon.assert.calledWithMatch(
  197. ctx.UserMembershipHandler.promises.getUsers,
  198. ctx.subscription,
  199. { modelName: 'Subscription' }
  200. )
  201. },
  202. })
  203. })
  204. it('render group view', async function (ctx) {
  205. ctx.subscription.managedUsersEnabled = false
  206. await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
  207. render: (viewPath, viewParams) => {
  208. expect(viewPath).to.equal('user_membership/group-members-react')
  209. expect(viewParams.users).to.deep.equal(ctx.users)
  210. expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
  211. expect(viewParams.managedUsersActive).to.equal(false)
  212. },
  213. })
  214. })
  215. it('render group view with managed users', async function (ctx) {
  216. ctx.subscription.managedUsersEnabled = true
  217. await ctx.UserMembershipController.manageGroupMembers(ctx.req, {
  218. render: (viewPath, viewParams) => {
  219. expect(viewPath).to.equal('user_membership/group-members-react')
  220. expect(viewParams.users).to.deep.equal(ctx.users)
  221. expect(viewParams.groupSize).to.equal(ctx.subscription.membersLimit)
  222. expect(viewParams.managedUsersActive).to.equal(true)
  223. expect(viewParams.isUserGroupManager).to.equal(false)
  224. },
  225. })
  226. })
  227. it('render group managers view', async function (ctx) {
  228. ctx.req.entityConfig = EntityConfigs.groupManagers
  229. await ctx.UserMembershipController.manageGroupManagers(ctx.req, {
  230. render: (viewPath, viewParams) => {
  231. expect(viewPath).to.equal('user_membership/group-managers-react')
  232. expect(viewParams.groupSize).to.equal(undefined)
  233. },
  234. })
  235. })
  236. it('render institution view', async function (ctx) {
  237. ctx.req.entity = ctx.institution
  238. ctx.req.entityConfig = EntityConfigs.institution
  239. await ctx.UserMembershipController.manageInstitutionManagers(ctx.req, {
  240. render: (viewPath, viewParams) => {
  241. expect(viewPath).to.equal(
  242. 'user_membership/institution-managers-react'
  243. )
  244. expect(viewParams.name).to.equal('Test Institution Name')
  245. expect(viewParams.groupSize).to.equal(undefined)
  246. },
  247. })
  248. })
  249. })
  250. describe('add', function () {
  251. beforeEach(function (ctx) {
  252. ctx.req.body.email = ctx.newUser.email
  253. ctx.req.entity = ctx.subscription
  254. ctx.req.entityConfig = EntityConfigs.groupManagers
  255. })
  256. it('add user', async function (ctx) {
  257. await new Promise(resolve => {
  258. ctx.UserMembershipController.add(ctx.req, {
  259. json: () => {
  260. sinon.assert.calledWithMatch(
  261. ctx.UserMembershipHandler.addUser,
  262. ctx.subscription,
  263. { modelName: 'Subscription' },
  264. ctx.newUser.email
  265. )
  266. resolve()
  267. },
  268. })
  269. })
  270. })
  271. it('return user object', async function (ctx) {
  272. await new Promise(resolve => {
  273. ctx.UserMembershipController.add(ctx.req, {
  274. json: payload => {
  275. payload.user.should.equal(ctx.newUser)
  276. resolve()
  277. },
  278. })
  279. })
  280. })
  281. it('handle readOnly entity', async function (ctx) {
  282. await new Promise(resolve => {
  283. ctx.req.entityConfig = EntityConfigs.group
  284. ctx.UserMembershipController.add(ctx.req, null, error => {
  285. expect(error).to.exist
  286. expect(error).to.be.an.instanceof(Errors.NotFoundError)
  287. resolve()
  288. })
  289. })
  290. })
  291. it('handle user already added', async function (ctx) {
  292. await new Promise(resolve => {
  293. ctx.UserMembershipHandler.addUser.yields(new UserAlreadyAddedError())
  294. ctx.UserMembershipController.add(ctx.req, {
  295. status: () => ({
  296. json: payload => {
  297. expect(payload.error.code).to.equal('user_already_added')
  298. resolve()
  299. },
  300. }),
  301. })
  302. })
  303. })
  304. it('handle user not found', async function (ctx) {
  305. await new Promise(resolve => {
  306. ctx.UserMembershipHandler.addUser.yields(new UserNotFoundError())
  307. ctx.UserMembershipController.add(ctx.req, {
  308. status: () => ({
  309. json: payload => {
  310. expect(payload.error.code).to.equal('user_not_found')
  311. resolve()
  312. },
  313. }),
  314. })
  315. })
  316. })
  317. it('handle invalid email', async function (ctx) {
  318. await new Promise(resolve => {
  319. ctx.req.body.email = 'not_valid_email'
  320. ctx.UserMembershipController.add(ctx.req, {
  321. status: () => ({
  322. json: payload => {
  323. expect(payload.error.code).to.equal('invalid_email')
  324. resolve()
  325. },
  326. }),
  327. })
  328. })
  329. })
  330. })
  331. describe('remove', function () {
  332. beforeEach(function (ctx) {
  333. ctx.req.params.userId = ctx.newUser._id
  334. ctx.req.entity = ctx.subscription
  335. ctx.req.entityConfig = EntityConfigs.groupManagers
  336. })
  337. it('remove user', async function (ctx) {
  338. await new Promise(resolve => {
  339. ctx.UserMembershipController.remove(ctx.req, {
  340. sendStatus: () => {
  341. sinon.assert.calledWithMatch(
  342. ctx.UserMembershipHandler.removeUser,
  343. ctx.subscription,
  344. { modelName: 'Subscription' },
  345. ctx.newUser._id
  346. )
  347. resolve()
  348. },
  349. })
  350. })
  351. })
  352. it('handle readOnly entity', async function (ctx) {
  353. await new Promise(resolve => {
  354. ctx.req.entityConfig = EntityConfigs.group
  355. ctx.UserMembershipController.remove(ctx.req, null, error => {
  356. expect(error).to.exist
  357. expect(error).to.be.an.instanceof(Errors.NotFoundError)
  358. resolve()
  359. })
  360. })
  361. })
  362. it('prevent self removal', async function (ctx) {
  363. await new Promise(resolve => {
  364. ctx.req.params.userId = ctx.user._id
  365. ctx.UserMembershipController.remove(ctx.req, {
  366. status: () => ({
  367. json: payload => {
  368. expect(payload.error.code).to.equal('managers_cannot_remove_self')
  369. resolve()
  370. },
  371. }),
  372. })
  373. })
  374. })
  375. it('prevent admin removal', async function (ctx) {
  376. await new Promise(resolve => {
  377. ctx.UserMembershipHandler.removeUser.yields(new UserIsManagerError())
  378. ctx.UserMembershipController.remove(ctx.req, {
  379. status: () => ({
  380. json: payload => {
  381. expect(payload.error.code).to.equal(
  382. 'managers_cannot_remove_admin'
  383. )
  384. resolve()
  385. },
  386. }),
  387. })
  388. })
  389. })
  390. })
  391. describe('exportCsv', function () {
  392. beforeEach(function (ctx) {
  393. ctx.req.entity = ctx.subscription
  394. ctx.req.entityConfig = EntityConfigs.groupManagers
  395. ctx.res = new MockResponse()
  396. ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
  397. })
  398. it('get users', function (ctx) {
  399. sinon.assert.calledWithMatch(
  400. ctx.UserMembershipHandler.promises.getUsers,
  401. ctx.subscription,
  402. { modelName: 'Subscription' }
  403. )
  404. })
  405. it('should set the correct content type on the request', function (ctx) {
  406. assertCalledWith(ctx.res.contentType, 'text/csv; charset=utf-8')
  407. })
  408. it('should name the exported csv file', function (ctx) {
  409. assertCalledWith(
  410. ctx.res.header,
  411. 'Content-Disposition',
  412. 'attachment; filename="Group.csv"'
  413. )
  414. })
  415. it('should export the correct csv', function (ctx) {
  416. assertCalledWith(
  417. ctx.res.send,
  418. '"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"'
  419. )
  420. })
  421. })
  422. describe('exportCsv when group is managed', function () {
  423. beforeEach(function (ctx) {
  424. ctx.req.entity = Object.assign(
  425. { managedUsersEnabled: true },
  426. ctx.subscription
  427. )
  428. ctx.req.entityConfig = EntityConfigs.groupManagers
  429. ctx.res = new MockResponse()
  430. ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
  431. })
  432. it('should export the correct csv', function (ctx) {
  433. assertCalledWith(
  434. ctx.res.send,
  435. '"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'
  436. )
  437. })
  438. })
  439. describe('exportCsv when group has SSO', function () {
  440. beforeEach(function (ctx) {
  441. ctx.req.entity = Object.assign(
  442. { ssoConfig: 'sso-config-id' },
  443. ctx.subscription
  444. )
  445. ctx.req.entityConfig = EntityConfigs.groupManagers
  446. ctx.Modules.promises.hooks.fire.resolves([true])
  447. ctx.res = new MockResponse()
  448. ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
  449. })
  450. it('should export the correct csv', function (ctx) {
  451. assertCalledWith(
  452. ctx.res.send,
  453. '"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'
  454. )
  455. })
  456. })
  457. describe('exportCsv when group has SSO and managed users enabled', function () {
  458. beforeEach(function (ctx) {
  459. ctx.req.entity = Object.assign(
  460. { managedUsersEnabled: true },
  461. { ssoConfig: 'sso-config-id' },
  462. ctx.subscription
  463. )
  464. ctx.req.entityConfig = EntityConfigs.groupManagers
  465. ctx.Modules.promises.hooks.fire.resolves([true])
  466. ctx.res = new MockResponse()
  467. ctx.UserMembershipController.exportCsv(ctx.req, ctx.res)
  468. })
  469. it('should export the correct csv', function (ctx) {
  470. assertCalledWith(
  471. ctx.res.send,
  472. '"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'
  473. )
  474. })
  475. })
  476. describe('new', function () {
  477. beforeEach(function (ctx) {
  478. ctx.req.params.name = 'publisher'
  479. ctx.req.params.id = 'abc'
  480. })
  481. it('renders view', async function (ctx) {
  482. await new Promise(resolve => {
  483. ctx.UserMembershipController.new(ctx.req, {
  484. render: (viewPath, data) => {
  485. expect(data.entityName).to.eq('publisher')
  486. expect(data.entityId).to.eq('abc')
  487. resolve()
  488. },
  489. })
  490. })
  491. })
  492. })
  493. describe('create', function () {
  494. beforeEach(function (ctx) {
  495. ctx.req.params.name = 'institution'
  496. ctx.req.entityConfig = EntityConfigs.institution
  497. ctx.req.params.id = 123
  498. })
  499. it('creates institution', async function (ctx) {
  500. await new Promise(resolve => {
  501. ctx.UserMembershipController.create(ctx.req, {
  502. redirect: path => {
  503. expect(path).to.eq(EntityConfigs.institution.pathsFor(123).index)
  504. sinon.assert.calledWithMatch(
  505. ctx.UserMembershipHandler.createEntity,
  506. 123,
  507. { modelName: 'Institution' }
  508. )
  509. resolve()
  510. },
  511. })
  512. })
  513. })
  514. })
  515. })