UserMembershipController.test.mjs 19 KB

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