InstitutionsManager.test.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import { vi, expect } from 'vitest'
  2. import path from 'path'
  3. import sinon from 'sinon'
  4. import mongodb from 'mongodb-legacy'
  5. import Features from '../../../../app/src/infrastructure/Features.js'
  6. const modulePath = path.join(
  7. import.meta.dirname,
  8. '../../../../app/src/Features/Institutions/InstitutionsManager'
  9. )
  10. const { ObjectId } = mongodb
  11. describe('InstitutionsManager', function () {
  12. beforeEach(async function (ctx) {
  13. ctx.institutionId = 123
  14. ctx.user = {}
  15. const lapsedUser = {
  16. _id: '657300a08a14461b3d1aac3e',
  17. features: {},
  18. }
  19. ctx.users = [
  20. lapsedUser,
  21. { _id: '657300a08a14461b3d1aac3f', features: {} },
  22. { _id: '657300a08a14461b3d1aac40', features: {} },
  23. { _id: '657300a08a14461b3d1aac41', features: {} },
  24. ]
  25. ctx.ssoUsers = [
  26. {
  27. _id: '657300a08a14461b3d1aac3f',
  28. samlIdentifiers: [{ providerId: ctx.institutionId.toString() }],
  29. },
  30. {
  31. _id: '657300a08a14461b3d1aac40',
  32. samlIdentifiers: [
  33. {
  34. providerId: ctx.institutionId.toString(),
  35. hasEntitlement: true,
  36. },
  37. ],
  38. },
  39. {
  40. _id: '657300a08a14461b3d1aac3e',
  41. samlIdentifiers: [{ providerId: ctx.institutionId.toString() }],
  42. hasEntitlement: true,
  43. },
  44. ]
  45. ctx.UserGetter = {
  46. promises: {
  47. getUser: sinon.stub().resolves(ctx.user),
  48. getUsers: sinon.stub().resolves(ctx.users),
  49. getUsersByAnyConfirmedEmail: sinon.stub().resolves(),
  50. getSsoUsersAtInstitution: (ctx.getSsoUsersAtInstitution = sinon
  51. .stub()
  52. .resolves(ctx.ssoUsers)),
  53. },
  54. }
  55. ctx.creator = { create: sinon.stub().resolves() }
  56. ctx.NotificationsBuilder = {
  57. promises: {
  58. featuresUpgradedByAffiliation: sinon.stub().returns(ctx.creator),
  59. redundantPersonalSubscription: sinon.stub().returns(ctx.creator),
  60. },
  61. }
  62. ctx.SubscriptionLocator = {
  63. promises: {
  64. getUsersSubscription: sinon.stub().resolves(),
  65. },
  66. }
  67. ctx.institutionWithV1Data = { name: 'Wombat University' }
  68. ctx.institution = {
  69. fetchV1DataPromise: sinon.stub().resolves(ctx.institutionWithV1Data),
  70. }
  71. ctx.InstitutionModel = {
  72. Institution: {
  73. findOne: sinon.stub().returns({
  74. exec: sinon.stub().resolves(ctx.institution),
  75. }),
  76. },
  77. }
  78. ctx.subscriptionExec = sinon.stub().resolves()
  79. const SubscriptionModel = {
  80. Subscription: {
  81. find: () => ({
  82. populate: () => ({
  83. exec: ctx.subscriptionExec,
  84. }),
  85. }),
  86. },
  87. }
  88. ctx.Mongo = {
  89. ObjectId,
  90. }
  91. ctx.v1Counts = {
  92. user_ids: ctx.users.map(user => user._id),
  93. current_users_count: 3,
  94. lapsed_user_ids: [lapsedUser._id],
  95. entitled_via_sso: 1, // 2 entitled, but 1 lapsed
  96. with_confirmed_email: 2, // 1 non entitled SSO + 1 email user
  97. }
  98. vi.doMock(
  99. '../../../../app/src/Features/Institutions/InstitutionsAPI',
  100. () => ({
  101. default: {
  102. promises: {
  103. addAffiliation: (ctx.addAffiliationPromise = sinon
  104. .stub()
  105. .resolves()),
  106. getInstitutionAffiliations: (ctx.getInstitutionAffiliationsPromise =
  107. sinon.stub().resolves(ctx.affiliations)),
  108. getConfirmedInstitutionAffiliations:
  109. (ctx.getConfirmedInstitutionAffiliationsPromise = sinon
  110. .stub()
  111. .resolves(ctx.affiliations)),
  112. getInstitutionAffiliationsCounts:
  113. (ctx.getInstitutionAffiliationsCounts = sinon
  114. .stub()
  115. .resolves(ctx.v1Counts)),
  116. },
  117. },
  118. })
  119. )
  120. vi.doMock(
  121. '../../../../app/src/Features/Subscription/FeaturesUpdater',
  122. () => ({
  123. default: {
  124. promises: {
  125. refreshFeatures: (ctx.refreshFeaturesPromise = sinon
  126. .stub()
  127. .resolves()),
  128. },
  129. },
  130. })
  131. )
  132. vi.doMock(
  133. '../../../../app/src/Features/Subscription/FeaturesHelper',
  134. () => ({
  135. default: {
  136. isFeatureSetBetter: (ctx.isFeatureSetBetter = sinon.stub()),
  137. },
  138. })
  139. )
  140. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  141. default: ctx.UserGetter,
  142. }))
  143. vi.doMock(
  144. '../../../../app/src/Features/Notifications/NotificationsBuilder',
  145. () => ({
  146. default: ctx.NotificationsBuilder,
  147. })
  148. )
  149. vi.doMock(
  150. '../../../../app/src/Features/Subscription/SubscriptionLocator',
  151. () => ({
  152. default: ctx.SubscriptionLocator,
  153. })
  154. )
  155. vi.doMock(
  156. '../../../../app/src/models/Institution',
  157. () => ctx.InstitutionModel
  158. )
  159. vi.doMock(
  160. '../../../../app/src/models/Subscription',
  161. () => SubscriptionModel
  162. )
  163. vi.doMock('mongodb-legacy', () => ({
  164. default: ctx.Mongo,
  165. }))
  166. vi.doMock('@overleaf/settings', () => ({
  167. default: {
  168. features: { professional: { 'test-feature': true } },
  169. },
  170. }))
  171. ctx.InstitutionsManager = (await import(modulePath)).default
  172. })
  173. describe('refreshInstitutionUsers', function () {
  174. beforeEach(function (ctx) {
  175. ctx.user1Id = '123abc123abc123abc123abc'
  176. ctx.user2Id = '456def456def456def456def'
  177. ctx.user3Id = '789abd789abd789abd789abd'
  178. ctx.user4Id = '321cba321cba321cba321cba'
  179. ctx.affiliations = [
  180. { user_id: ctx.user1Id },
  181. { user_id: ctx.user2Id },
  182. { user_id: ctx.user3Id },
  183. { user_id: ctx.user4Id },
  184. ]
  185. ctx.user1 = { _id: ctx.user1Id }
  186. ctx.user2 = { _id: ctx.user2Id }
  187. ctx.user3 = { _id: ctx.user3Id }
  188. ctx.user4 = { _id: ctx.user4Id }
  189. ctx.UserGetter.promises.getUser
  190. .withArgs(new ObjectId(ctx.user1Id))
  191. .resolves(ctx.user1)
  192. ctx.UserGetter.promises.getUser
  193. .withArgs(new ObjectId(ctx.user2Id))
  194. .resolves(ctx.user2)
  195. ctx.UserGetter.promises.getUser
  196. .withArgs(new ObjectId(ctx.user3Id))
  197. .resolves(ctx.user3)
  198. ctx.UserGetter.promises.getUser
  199. .withArgs(new ObjectId(ctx.user4Id))
  200. .resolves(ctx.user4)
  201. ctx.SubscriptionLocator.promises.getUsersSubscription
  202. .withArgs(ctx.user2)
  203. .resolves({
  204. planCode: 'pro',
  205. groupPlan: false,
  206. })
  207. ctx.SubscriptionLocator.promises.getUsersSubscription
  208. .withArgs(ctx.user3)
  209. .resolves({
  210. planCode: 'collaborator_free_trial_7_days',
  211. groupPlan: false,
  212. })
  213. ctx.SubscriptionLocator.promises.getUsersSubscription
  214. .withArgs(ctx.user4)
  215. .resolves({
  216. planCode: 'collaborator-annual',
  217. groupPlan: true,
  218. })
  219. ctx.refreshFeaturesPromise.resolves({
  220. newFeatures: {},
  221. featuresChanged: false,
  222. })
  223. ctx.refreshFeaturesPromise
  224. .withArgs(new ObjectId(ctx.user1Id))
  225. .resolves({ newFeatures: {}, featuresChanged: true })
  226. ctx.getInstitutionAffiliationsPromise.resolves(ctx.affiliations)
  227. ctx.getConfirmedInstitutionAffiliationsPromise.resolves(ctx.affiliations)
  228. })
  229. it('refresh all users Features', async function (ctx) {
  230. await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
  231. ctx.institutionId,
  232. false
  233. )
  234. sinon.assert.callCount(ctx.refreshFeaturesPromise, 4)
  235. // expect no notifications
  236. sinon.assert.notCalled(
  237. ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation
  238. )
  239. sinon.assert.notCalled(
  240. ctx.NotificationsBuilder.promises.redundantPersonalSubscription
  241. )
  242. })
  243. it('notifies users if their features have been upgraded', async function (ctx) {
  244. await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
  245. ctx.institutionId,
  246. true
  247. )
  248. sinon.assert.calledOnce(
  249. ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation
  250. )
  251. sinon.assert.calledWith(
  252. ctx.NotificationsBuilder.promises.featuresUpgradedByAffiliation,
  253. ctx.affiliations[0],
  254. ctx.user1
  255. )
  256. })
  257. it('notifies users if they have a subscription, or a trial subscription, that should be cancelled', async function (ctx) {
  258. await ctx.InstitutionsManager.promises.refreshInstitutionUsers(
  259. ctx.institutionId,
  260. true
  261. )
  262. sinon.assert.calledTwice(
  263. ctx.NotificationsBuilder.promises.redundantPersonalSubscription
  264. )
  265. sinon.assert.calledWith(
  266. ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
  267. ctx.affiliations[1],
  268. ctx.user2
  269. )
  270. sinon.assert.calledWith(
  271. ctx.NotificationsBuilder.promises.redundantPersonalSubscription,
  272. ctx.affiliations[2],
  273. ctx.user3
  274. )
  275. })
  276. })
  277. describe('checkInstitutionUsers', function () {
  278. it('returns entitled/not, sso/not, lapsed/current, and pro counts', async function (ctx) {
  279. if (Features.hasFeature('saas')) {
  280. ctx.isFeatureSetBetter.returns(true)
  281. const usersSummary =
  282. await ctx.InstitutionsManager.promises.checkInstitutionUsers(
  283. ctx.institutionId
  284. )
  285. expect(usersSummary).to.deep.equal({
  286. emailUsers: {
  287. total: 1,
  288. current: 1,
  289. lapsed: 0,
  290. pro: {
  291. current: 1, // isFeatureSetBetter stubbed to return true for all
  292. lapsed: 0,
  293. },
  294. nonPro: {
  295. current: 0,
  296. lapsed: 0,
  297. },
  298. },
  299. ssoUsers: {
  300. total: 3,
  301. lapsed: 1,
  302. current: {
  303. entitled: 1,
  304. notEntitled: 1,
  305. },
  306. pro: {
  307. current: 2,
  308. lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
  309. },
  310. nonPro: {
  311. current: 0,
  312. lapsed: 0,
  313. },
  314. },
  315. })
  316. }
  317. })
  318. it('includes withConfirmedEmailMismatch when v1 and v2 counts do not add up', async function (ctx) {
  319. if (Features.hasFeature('saas')) {
  320. ctx.isFeatureSetBetter.returns(true)
  321. ctx.v1Counts.with_confirmed_email = 100
  322. const usersSummary =
  323. await ctx.InstitutionsManager.promises.checkInstitutionUsers(
  324. ctx.institutionId
  325. )
  326. expect(usersSummary).to.deep.equal({
  327. emailUsers: {
  328. total: 1,
  329. current: 1,
  330. lapsed: 0,
  331. pro: {
  332. current: 1, // isFeatureSetBetter stubbed to return true for all
  333. lapsed: 0,
  334. },
  335. nonPro: {
  336. current: 0,
  337. lapsed: 0,
  338. },
  339. },
  340. ssoUsers: {
  341. total: 3,
  342. lapsed: 1,
  343. current: {
  344. entitled: 1,
  345. notEntitled: 1,
  346. },
  347. pro: {
  348. current: 2,
  349. lapsed: 1, // isFeatureSetBetter stubbed to return true for all users
  350. },
  351. nonPro: {
  352. current: 0,
  353. lapsed: 0,
  354. },
  355. },
  356. databaseMismatch: {
  357. withConfirmedEmail: {
  358. v1: 100,
  359. v2: 2,
  360. },
  361. },
  362. })
  363. }
  364. })
  365. })
  366. describe('getInstitutionUsersSubscriptions', function () {
  367. it('returns all institution users subscriptions', async function (ctx) {
  368. const stubbedUsers = [
  369. { user_id: '123abc123abc123abc123abc' },
  370. { user_id: '456def456def456def456def' },
  371. { user_id: '789def789def789def789def' },
  372. ]
  373. ctx.getInstitutionAffiliationsPromise.resolves(stubbedUsers)
  374. await ctx.InstitutionsManager.promises.getInstitutionUsersSubscriptions(
  375. ctx.institutionId
  376. )
  377. sinon.assert.calledOnce(ctx.subscriptionExec)
  378. })
  379. })
  380. describe('addAffiliations', function () {
  381. beforeEach(function (ctx) {
  382. ctx.host = 'mit.edu'.split('').reverse().join('')
  383. ctx.stubbedUser1 = {
  384. _id: '6573014d8a14461b3d1aac3f',
  385. name: 'bob',
  386. email: 'hello@world.com',
  387. emails: [
  388. { email: 'stubb1@mit.edu', reversedHostname: ctx.host },
  389. { email: 'test@test.com', reversedHostname: 'test.com' },
  390. { email: 'another@mit.edu', reversedHostname: ctx.host },
  391. ],
  392. }
  393. ctx.stubbedUser1DecoratedEmails = [
  394. {
  395. email: 'stubb1@mit.edu',
  396. reversedHostname: ctx.host,
  397. samlIdentifier: { hasEntitlement: false },
  398. },
  399. { email: 'test@test.com', reversedHostname: 'test.com' },
  400. {
  401. email: 'another@mit.edu',
  402. reversedHostname: ctx.host,
  403. samlIdentifier: { hasEntitlement: true },
  404. },
  405. ]
  406. ctx.stubbedUser2 = {
  407. _id: '6573014d8a14461b3d1aac40',
  408. name: 'test',
  409. email: 'hello2@world.com',
  410. emails: [{ email: 'subb2@mit.edu', reversedHostname: ctx.host }],
  411. }
  412. ctx.stubbedUser2DecoratedEmails = [
  413. {
  414. email: 'subb2@mit.edu',
  415. reversedHostname: ctx.host,
  416. },
  417. ]
  418. ctx.getInstitutionUsersByHostname = sinon.stub().resolves([
  419. {
  420. _id: ctx.stubbedUser1._id,
  421. emails: ctx.stubbedUser1DecoratedEmails,
  422. },
  423. {
  424. _id: ctx.stubbedUser2._id,
  425. emails: ctx.stubbedUser2DecoratedEmails,
  426. },
  427. ])
  428. ctx.UserGetter.promises.getInstitutionUsersByHostname =
  429. ctx.getInstitutionUsersByHostname
  430. })
  431. describe('affiliateUsers', function () {
  432. it('should add affiliations for matching users', async function (ctx) {
  433. await ctx.InstitutionsManager.promises.affiliateUsers('mit.edu')
  434. ctx.getInstitutionUsersByHostname.calledOnce.should.equal(true)
  435. ctx.addAffiliationPromise.calledThrice.should.equal(true)
  436. ctx.addAffiliationPromise
  437. .calledWithMatch(
  438. ctx.stubbedUser1._id,
  439. ctx.stubbedUser1.emails[0].email,
  440. { entitlement: false }
  441. )
  442. .should.equal(true)
  443. ctx.addAffiliationPromise
  444. .calledWithMatch(
  445. ctx.stubbedUser1._id,
  446. ctx.stubbedUser1.emails[2].email,
  447. { entitlement: true }
  448. )
  449. .should.equal(true)
  450. ctx.addAffiliationPromise
  451. .calledWithMatch(
  452. ctx.stubbedUser2._id,
  453. ctx.stubbedUser2.emails[0].email,
  454. { entitlement: undefined }
  455. )
  456. .should.equal(true)
  457. ctx.refreshFeaturesPromise
  458. .calledWith(ctx.stubbedUser1._id)
  459. .should.equal(true)
  460. ctx.refreshFeaturesPromise
  461. .calledWith(ctx.stubbedUser2._id)
  462. .should.equal(true)
  463. ctx.refreshFeaturesPromise.should.have.been.calledTwice
  464. })
  465. it('should return errors if last affiliation cannot be added', async function (ctx) {
  466. ctx.addAffiliationPromise.onCall(2).rejects()
  467. await expect(ctx.InstitutionsManager.promises.affiliateUsers('mit.edu'))
  468. .to.be.rejected
  469. ctx.getInstitutionUsersByHostname.calledOnce.should.equal(true)
  470. })
  471. })
  472. })
  473. })