UserUpdaterTests.js 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126
  1. const { setTimeout } = require('timers/promises')
  2. const SandboxedModule = require('sandboxed-module')
  3. const path = require('path')
  4. const sinon = require('sinon')
  5. const { ObjectId } = require('mongodb-legacy')
  6. const tk = require('timekeeper')
  7. const { expect } = require('chai')
  8. const { normalizeQuery } = require('../../../../app/src/Features/Helpers/Mongo')
  9. const Errors = require('../../../../app/src/Features/Errors/Errors')
  10. const MODULE_PATH = path.join(
  11. __dirname,
  12. '../../../../app/src/Features/User/UserUpdater'
  13. )
  14. describe('UserUpdater', function () {
  15. beforeEach(function () {
  16. tk.freeze(Date.now())
  17. this.user = {
  18. _id: new ObjectId(),
  19. name: 'bob',
  20. email: 'hello@world.com',
  21. emails: [{ email: 'hello@world.com' }],
  22. }
  23. this.db = {
  24. users: {
  25. updateOne: sinon.stub().resolves({ matchedCount: 1, modifiedCount: 1 }),
  26. },
  27. }
  28. this.mongodb = {
  29. db: this.db,
  30. ObjectId,
  31. }
  32. this.UserGetter = {
  33. promises: {
  34. ensureUniqueEmailAddress: sinon.stub().resolves(),
  35. getUser: sinon.stub(),
  36. getUserByMainEmail: sinon.stub(),
  37. getUserFullEmails: sinon.stub(),
  38. getUserEmail: sinon.stub(),
  39. },
  40. }
  41. this.UserGetter.promises.getUser.withArgs(this.user._id).resolves(this.user)
  42. this.UserGetter.promises.getUserByMainEmail
  43. .withArgs(this.user.email)
  44. .resolves(this.user)
  45. this.UserGetter.promises.getUserFullEmails
  46. .withArgs(this.user._id)
  47. .resolves(this.user.emails)
  48. this.UserGetter.promises.getUserEmail
  49. .withArgs(this.user._id)
  50. .resolves(this.user.email)
  51. this.NewsletterManager = {
  52. promises: {
  53. changeEmail: sinon.stub().resolves(),
  54. },
  55. }
  56. this.AnalyticsManager = {
  57. recordEventForUserInBackground: sinon.stub(),
  58. }
  59. this.InstitutionsAPI = {
  60. promises: {
  61. addAffiliation: sinon.stub().resolves(),
  62. removeAffiliation: sinon.stub().resolves(),
  63. getUserAffiliations: sinon.stub().resolves(),
  64. },
  65. }
  66. this.EmailHandler = {
  67. promises: {
  68. sendEmail: sinon.stub().resolves(),
  69. },
  70. }
  71. this.Features = {
  72. hasFeature: sinon.stub().returns(false),
  73. }
  74. this.FeaturesUpdater = {
  75. promises: {
  76. refreshFeatures: sinon.stub().resolves(),
  77. },
  78. }
  79. this.UserAuditLogHandler = {
  80. promises: {
  81. addEntry: sinon.stub().resolves(),
  82. },
  83. }
  84. this.SubscriptionLocator = {
  85. promises: {
  86. getUserIndividualSubscription: sinon.stub().resolves(),
  87. },
  88. }
  89. this.NotificationsBuilder = {
  90. promises: {
  91. redundantPersonalSubscription: sinon
  92. .stub()
  93. .returns({ create: () => {} }),
  94. },
  95. }
  96. this.Modules = {
  97. promises: {
  98. hooks: {
  99. fire: sinon.stub().resolves([]),
  100. },
  101. },
  102. }
  103. this.UserSessionsManager = {
  104. promises: {
  105. removeSessionsFromRedis: sinon.stub().resolves(),
  106. },
  107. }
  108. this.UserUpdater = SandboxedModule.require(MODULE_PATH, {
  109. requires: {
  110. '../Helpers/Mongo': { normalizeQuery },
  111. '../../infrastructure/mongodb': this.mongodb,
  112. './UserGetter': this.UserGetter,
  113. '../Institutions/InstitutionsAPI': this.InstitutionsAPI,
  114. '../Email/EmailHandler': this.EmailHandler,
  115. '../../infrastructure/Features': this.Features,
  116. '../Subscription/FeaturesUpdater': this.FeaturesUpdater,
  117. '@overleaf/settings': (this.settings = {}),
  118. '../Newsletter/NewsletterManager': this.NewsletterManager,
  119. '../Subscription/RecurlyWrapper': this.RecurlyWrapper,
  120. './UserAuditLogHandler': this.UserAuditLogHandler,
  121. '../Analytics/AnalyticsManager': this.AnalyticsManager,
  122. '../../Errors/Errors': Errors,
  123. '../Subscription/SubscriptionLocator': this.SubscriptionLocator,
  124. '../Notifications/NotificationsBuilder': this.NotificationsBuilder,
  125. '../../infrastructure/Modules': this.Modules,
  126. './UserSessionsManager': this.UserSessionsManager,
  127. './ThirdPartyIdentityManager': this.ThirdPartyIdentityManager,
  128. },
  129. })
  130. this.newEmail = 'bob@bob.com'
  131. })
  132. afterEach(function () {
  133. return tk.reset()
  134. })
  135. describe('addAffiliationForNewUser', function () {
  136. it('should not remove affiliationUnchecked flag if v1 returns an error', async function () {
  137. this.InstitutionsAPI.promises.addAffiliation.rejects()
  138. await expect(
  139. this.UserUpdater.promises.addAffiliationForNewUser(
  140. this.user._id,
  141. this.newEmail
  142. )
  143. ).to.be.rejected
  144. sinon.assert.notCalled(this.db.users.updateOne)
  145. })
  146. it('should remove affiliationUnchecked flag if v1 does not return an error', async function () {
  147. await this.UserUpdater.promises.addAffiliationForNewUser(
  148. this.user._id,
  149. this.newEmail
  150. )
  151. sinon.assert.calledOnce(this.db.users.updateOne)
  152. sinon.assert.calledWithMatch(
  153. this.db.users.updateOne,
  154. { _id: this.user._id, 'emails.email': this.newEmail },
  155. { $unset: { 'emails.$.affiliationUnchecked': 1 } }
  156. )
  157. })
  158. it('should not throw if removing affiliationUnchecked flag errors', async function () {
  159. this.db.users.updateOne.rejects(new Error('nope'))
  160. await this.UserUpdater.promises.addAffiliationForNewUser(
  161. this.user._id,
  162. this.newEmail
  163. )
  164. })
  165. })
  166. describe('changeEmailAddress', function () {
  167. beforeEach(async function () {
  168. this.auditLog = {
  169. initiatorId: 'abc123',
  170. ipAddress: '0:0:0:0',
  171. }
  172. // After the email changed, make sure that UserGetter.getUser() returns a
  173. // user with the new email.
  174. this.UserGetter.promises.getUser
  175. .withArgs(this.user._id)
  176. .onCall(1)
  177. .resolves({
  178. ...this.user,
  179. emails: [...this.user.emails, { email: this.newEmail }],
  180. })
  181. // The main email changes as a result of the email change
  182. this.UserGetter.promises.getUserByMainEmail
  183. .withArgs(this.user.email)
  184. .resolves(null)
  185. this.user.emails.push({ email: this.newEmail })
  186. await this.UserUpdater.promises.changeEmailAddress(
  187. this.user._id,
  188. this.newEmail,
  189. this.auditLog
  190. )
  191. })
  192. it('adds the new email', function () {
  193. expect(this.db.users.updateOne).to.have.been.calledWith(
  194. { _id: this.user._id, 'emails.email': { $ne: this.newEmail } },
  195. {
  196. $push: {
  197. emails: sinon.match({ email: this.newEmail }),
  198. },
  199. }
  200. )
  201. })
  202. it('adds the new affiliation', function () {
  203. this.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith(
  204. this.user._id,
  205. this.newEmail
  206. )
  207. })
  208. it('removes the old email', function () {
  209. expect(this.db.users.updateOne).to.have.been.calledWith(
  210. { _id: this.user._id, email: { $ne: this.user.email } },
  211. { $pull: { emails: { email: this.user.email } } }
  212. )
  213. })
  214. it('removes the affiliation', function () {
  215. expect(
  216. this.InstitutionsAPI.promises.removeAffiliation
  217. ).to.have.been.calledWith(this.user._id, this.user.email)
  218. })
  219. it('refreshes features', function () {
  220. sinon.assert.calledWith(
  221. this.FeaturesUpdater.promises.refreshFeatures,
  222. this.user._id
  223. )
  224. })
  225. it('sets the default email', function () {
  226. expect(this.db.users.updateOne).to.have.been.calledWith(
  227. { _id: this.user._id, 'emails.email': this.newEmail },
  228. {
  229. $set: sinon.match({
  230. email: this.newEmail,
  231. }),
  232. }
  233. )
  234. })
  235. it('sets the new email in the newsletter', function () {
  236. expect(
  237. this.NewsletterManager.promises.changeEmail
  238. ).to.have.been.calledWith(this.user, this.newEmail)
  239. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  240. 'updateAccountEmailAddress',
  241. this.user._id,
  242. this.newEmail
  243. )
  244. })
  245. it('validates email', async function () {
  246. await expect(
  247. this.UserUpdater.promises.changeEmailAddress(
  248. this.user._id,
  249. 'foo',
  250. this.auditLog
  251. )
  252. ).to.be.rejected
  253. })
  254. })
  255. describe('addEmailAddress', function () {
  256. it('adds the email', async function () {
  257. await this.UserUpdater.promises.addEmailAddress(
  258. this.user._id,
  259. this.newEmail,
  260. {},
  261. { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
  262. )
  263. this.UserGetter.promises.ensureUniqueEmailAddress.should.have.been.called
  264. const reversedHostname = this.newEmail
  265. .split('@')[1]
  266. .split('')
  267. .reverse()
  268. .join('')
  269. this.db.users.updateOne.should.have.been.calledWith(
  270. { _id: this.user._id, 'emails.email': { $ne: this.newEmail } },
  271. {
  272. $push: {
  273. emails: {
  274. email: this.newEmail,
  275. createdAt: sinon.match.date,
  276. reversedHostname,
  277. },
  278. },
  279. }
  280. )
  281. })
  282. it('adds the affiliation', async function () {
  283. const affiliationOptions = {
  284. university: { id: 1 },
  285. role: 'Prof',
  286. department: 'Math',
  287. }
  288. await this.UserUpdater.promises.addEmailAddress(
  289. this.user._id,
  290. this.newEmail,
  291. affiliationOptions,
  292. { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
  293. )
  294. this.InstitutionsAPI.promises.addAffiliation.should.have.been.calledWith(
  295. this.user._id,
  296. this.newEmail,
  297. affiliationOptions
  298. )
  299. })
  300. it('handles affiliation errors', async function () {
  301. this.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
  302. await expect(
  303. this.UserUpdater.promises.addEmailAddress(
  304. this.user._id,
  305. this.newEmail,
  306. {},
  307. { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
  308. )
  309. ).to.be.rejected
  310. this.db.users.updateOne.should.not.have.been.called
  311. })
  312. it('validates the email', async function () {
  313. expect(
  314. this.UserUpdater.promises.addEmailAddress(
  315. this.user._id,
  316. 'bar',
  317. {},
  318. { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
  319. )
  320. ).to.be.rejected
  321. })
  322. it('updates the audit log', async function () {
  323. this.ip = '127:0:0:0'
  324. await this.UserUpdater.promises.addEmailAddress(
  325. this.user._id,
  326. this.newEmail,
  327. {},
  328. { initiatorId: this.user._id, ipAddress: this.ip }
  329. )
  330. this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true)
  331. const { args } = this.UserAuditLogHandler.promises.addEntry.lastCall
  332. expect(args[0]).to.equal(this.user._id)
  333. expect(args[1]).to.equal('add-email')
  334. expect(args[2]).to.equal(this.user._id)
  335. expect(args[3]).to.equal(this.ip)
  336. expect(args[4]).to.deep.equal({ newSecondaryEmail: this.newEmail })
  337. })
  338. describe('errors', function () {
  339. describe('via UserAuditLogHandler', function () {
  340. const anError = new Error('oops')
  341. beforeEach(function () {
  342. this.UserAuditLogHandler.promises.addEntry.rejects(anError)
  343. })
  344. it('should not add email and should return error', async function () {
  345. await expect(
  346. this.UserUpdater.promises.addEmailAddress(
  347. this.user._id,
  348. this.newEmail,
  349. {},
  350. { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
  351. )
  352. ).to.be.rejectedWith(anError)
  353. expect(this.db.users.updateOne).to.not.have.been.called
  354. })
  355. })
  356. })
  357. })
  358. describe('removeEmailAddress', function () {
  359. this.beforeEach(function () {
  360. this.auditLog = { initiatorId: this.user._id, ipAddress: '127:0:0:0' }
  361. })
  362. it('removes the email', async function () {
  363. await this.UserUpdater.promises.removeEmailAddress(
  364. this.user._id,
  365. this.newEmail,
  366. this.auditLog
  367. )
  368. expect(this.db.users.updateOne).to.have.been.calledWith(
  369. { _id: this.user._id, email: { $ne: this.newEmail } },
  370. { $pull: { emails: { email: this.newEmail } } }
  371. )
  372. })
  373. it('removes the affiliation', async function () {
  374. await this.UserUpdater.promises.removeEmailAddress(
  375. this.user._id,
  376. this.newEmail,
  377. this.auditLog
  378. )
  379. expect(this.InstitutionsAPI.promises.removeAffiliation).to.have.been
  380. .calledOnce
  381. const { args } = this.InstitutionsAPI.promises.removeAffiliation.lastCall
  382. args[0].should.equal(this.user._id)
  383. args[1].should.equal(this.newEmail)
  384. })
  385. it('refreshes features', async function () {
  386. await this.UserUpdater.promises.removeEmailAddress(
  387. this.user._id,
  388. this.newEmail,
  389. this.auditLog
  390. )
  391. sinon.assert.calledWith(
  392. this.FeaturesUpdater.promises.refreshFeatures,
  393. this.user._id
  394. )
  395. })
  396. it('handles Mongo errors', async function () {
  397. const anError = new Error('nope')
  398. this.db.users.updateOne.rejects(anError)
  399. await expect(
  400. this.UserUpdater.promises.removeEmailAddress(
  401. this.user._id,
  402. this.newEmail,
  403. this.auditLog
  404. )
  405. ).to.be.rejected
  406. expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
  407. .called
  408. })
  409. it('handles missed update', async function () {
  410. this.db.users.updateOne.resolves({ matchedCount: 0 })
  411. await expect(
  412. this.UserUpdater.promises.removeEmailAddress(
  413. this.user._id,
  414. this.newEmail,
  415. this.auditLog
  416. )
  417. ).to.be.rejectedWith('Cannot remove email')
  418. expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
  419. .called
  420. })
  421. it('handles an affiliation error', async function () {
  422. const anError = new Error('nope')
  423. this.InstitutionsAPI.promises.removeAffiliation.rejects(anError)
  424. await expect(
  425. this.UserUpdater.promises.removeEmailAddress(
  426. this.user._id,
  427. this.newEmail,
  428. this.auditLog
  429. )
  430. ).to.be.rejected
  431. expect(this.db.users.updateOne).not.to.have.been.called
  432. expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
  433. .called
  434. })
  435. it('throws an error when removing the primary email', async function () {
  436. await expect(
  437. this.UserUpdater.promises.removeEmailAddress(
  438. this.user._id,
  439. this.user.email,
  440. this.auditLog
  441. )
  442. ).to.be.rejectedWith('cannot remove primary email')
  443. expect(this.db.users.updateOne).not.to.have.been.called
  444. expect(this.FeaturesUpdater.promises.refreshFeatures).not.to.have.been
  445. .called
  446. })
  447. it('validates the email', function () {
  448. expect(
  449. this.UserUpdater.promises.removeEmailAddress(
  450. this.user._id,
  451. 'baz',
  452. this.auditLog
  453. )
  454. ).to.be.rejectedWith('invalid email')
  455. })
  456. it('skips email validation when skipParseEmail included', async function () {
  457. const skipParseEmail = true
  458. await this.UserUpdater.promises.removeEmailAddress(
  459. this.user._id,
  460. 'baz',
  461. this.auditLog,
  462. skipParseEmail
  463. )
  464. })
  465. it('throws an error when skipParseEmail included but email is not a string', async function () {
  466. const skipParseEmail = true
  467. await expect(
  468. this.UserUpdater.promises.removeEmailAddress(
  469. this.user._id,
  470. 1,
  471. this.auditLog,
  472. skipParseEmail
  473. )
  474. ).to.be.rejectedWith('email must be a string')
  475. })
  476. it('logs the removal to the audit log', async function () {
  477. await this.UserUpdater.promises.removeEmailAddress(
  478. this.user._id,
  479. this.newEmail,
  480. this.auditLog
  481. )
  482. expect(
  483. this.UserAuditLogHandler.promises.addEntry
  484. ).to.have.been.calledWith(
  485. this.user._id,
  486. 'remove-email',
  487. this.auditLog.initiatorId,
  488. this.auditLog.ipAddress,
  489. {
  490. removedEmail: this.newEmail,
  491. }
  492. )
  493. })
  494. it('logs the removal from script to the audit log', async function () {
  495. this.auditLog = {
  496. initiatorId: undefined,
  497. ipAddress: '0.0.0.0',
  498. extraInfo: {
  499. script: true,
  500. },
  501. }
  502. await this.UserUpdater.promises.removeEmailAddress(
  503. this.user._id,
  504. this.newEmail,
  505. this.auditLog
  506. )
  507. expect(
  508. this.UserAuditLogHandler.promises.addEntry
  509. ).to.have.been.calledWith(
  510. this.user._id,
  511. 'remove-email',
  512. this.auditLog.initiatorId,
  513. this.auditLog.ipAddress,
  514. {
  515. removedEmail: this.newEmail,
  516. script: true,
  517. }
  518. )
  519. })
  520. })
  521. describe('setDefaultEmailAddress', function () {
  522. function setUserEmails(test, emails) {
  523. test.user.emails = emails
  524. test.UserGetter.promises.getUserFullEmails
  525. .withArgs(test.user._id)
  526. .resolves(emails)
  527. }
  528. beforeEach(function () {
  529. this.auditLog = {
  530. initiatorId: this.user,
  531. ipAddress: '0:0:0:0',
  532. }
  533. setUserEmails(this, [
  534. {
  535. email: this.newEmail,
  536. confirmedAt: new Date(),
  537. },
  538. ])
  539. })
  540. it('set default', async function () {
  541. await this.UserUpdater.promises.setDefaultEmailAddress(
  542. this.user._id,
  543. this.newEmail,
  544. false,
  545. this.auditLog
  546. )
  547. expect(this.db.users.updateOne).to.have.been.calledWith(
  548. { _id: this.user._id, 'emails.email': this.newEmail },
  549. {
  550. $set: {
  551. email: this.newEmail,
  552. lastPrimaryEmailCheck: sinon.match.date,
  553. },
  554. }
  555. )
  556. })
  557. it('sets the changed email in the newsletter', async function () {
  558. await this.UserUpdater.promises.setDefaultEmailAddress(
  559. this.user._id,
  560. this.newEmail,
  561. false,
  562. this.auditLog
  563. )
  564. expect(
  565. this.NewsletterManager.promises.changeEmail
  566. ).to.have.been.calledWith(this.user, this.newEmail)
  567. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  568. 'updateAccountEmailAddress',
  569. this.user._id,
  570. this.newEmail
  571. )
  572. })
  573. it('handles Mongo errors', async function () {
  574. this.db.users.updateOne = sinon.stub().rejects(Error('nope'))
  575. await expect(
  576. this.UserUpdater.promises.setDefaultEmailAddress(
  577. this.user._id,
  578. this.newEmail,
  579. false,
  580. this.auditLog
  581. )
  582. ).to.be.rejected
  583. })
  584. it('handles missed updates', async function () {
  585. this.db.users.updateOne.resolves({ matchedCount: 0 })
  586. await expect(
  587. this.UserUpdater.promises.setDefaultEmailAddress(
  588. this.user._id,
  589. this.newEmail,
  590. false,
  591. this.auditLog
  592. )
  593. ).to.be.rejected
  594. })
  595. it('validates the email', async function () {
  596. await expect(
  597. this.UserUpdater.promises.setDefaultEmailAddress(
  598. this.user._id,
  599. '.edu',
  600. false,
  601. this.auditLog
  602. )
  603. ).to.be.rejected
  604. })
  605. it('updates the audit log', async function () {
  606. await this.UserUpdater.promises.setDefaultEmailAddress(
  607. this.user._id,
  608. this.newEmail,
  609. false,
  610. this.auditLog
  611. )
  612. expect(
  613. this.UserAuditLogHandler.promises.addEntry
  614. ).to.have.been.calledWith(
  615. this.user._id,
  616. 'change-primary-email',
  617. this.auditLog.initiatorId,
  618. this.auditLog.ipAddress,
  619. {
  620. newPrimaryEmail: this.newEmail,
  621. oldPrimaryEmail: this.user.email,
  622. }
  623. )
  624. })
  625. it('blocks email update if audit log returns an error', async function () {
  626. this.UserAuditLogHandler.promises.addEntry.rejects(new Error('oops'))
  627. await expect(
  628. this.UserUpdater.promises.setDefaultEmailAddress(
  629. this.user._id,
  630. this.newEmail,
  631. false,
  632. this.auditLog
  633. )
  634. ).to.be.rejected
  635. expect(this.db.users.updateOne).to.not.have.been.called
  636. })
  637. describe('when email not confirmed', function () {
  638. beforeEach(function () {
  639. setUserEmails(this, [
  640. {
  641. email: this.newEmail,
  642. confirmedAt: null,
  643. },
  644. ])
  645. })
  646. it('should throw an error', async function () {
  647. await expect(
  648. this.UserUpdater.promises.setDefaultEmailAddress(
  649. this.user._id,
  650. this.newEmail,
  651. false,
  652. this.auditLog
  653. )
  654. ).to.be.rejectedWith(Errors.UnconfirmedEmailError)
  655. expect(this.db.users.updateOne).to.not.have.been.called
  656. expect(this.NewsletterManager.promises.changeEmail).to.not.have.been
  657. .called
  658. })
  659. })
  660. describe('when email does not belong to user', function () {
  661. beforeEach(function () {
  662. setUserEmails(this, [])
  663. this.UserUpdater.promises.updateUser = sinon.stub()
  664. })
  665. it('should callback with error', function () {
  666. this.UserUpdater.setDefaultEmailAddress(
  667. this.user._id,
  668. this.newEmail,
  669. false,
  670. this.auditLog,
  671. error => {
  672. expect(error).to.exist
  673. expect(error.name).to.equal('Error')
  674. this.UserUpdater.promises.updateUser.callCount.should.equal(0)
  675. this.NewsletterManager.promises.changeEmail.callCount.should.equal(
  676. 0
  677. )
  678. }
  679. )
  680. })
  681. })
  682. describe('security alert', function () {
  683. it('should be sent to old and new email when sendSecurityAlert=true', async function () {
  684. await this.UserUpdater.promises.setDefaultEmailAddress(
  685. this.user._id,
  686. this.newEmail,
  687. false,
  688. this.auditLog,
  689. true
  690. )
  691. // Emails are sent asynchronously. Wait a bit.
  692. await setTimeout(100)
  693. this.EmailHandler.promises.sendEmail.callCount.should.equal(2)
  694. for (const recipient of [this.user.email, this.newEmail]) {
  695. expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
  696. 'securityAlert',
  697. sinon.match({ to: recipient })
  698. )
  699. }
  700. })
  701. it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails', async function () {
  702. setUserEmails(this, [
  703. {
  704. email: '1@a1.uni',
  705. confirmedAt: new Date(2020, 0, 1),
  706. reConfirmedAt: new Date(2021, 2, 11),
  707. lastConfirmedAt: new Date(2021, 2, 11),
  708. default: false,
  709. affiliation: {
  710. institution: {
  711. id: 123,
  712. name: 'A1 University',
  713. },
  714. cachedConfirmedAt: '2020-01-01T18:25:01.639Z',
  715. cachedReconfirmedAt: '2021-03-11T18:25:01.639Z',
  716. },
  717. },
  718. {
  719. email: '2@a1.uni',
  720. confirmedAt: new Date(2019, 0, 1),
  721. reConfirmedAt: new Date(2022, 2, 11),
  722. lastConfirmedAt: new Date(2022, 2, 11),
  723. default: false,
  724. affiliation: {
  725. institution: {
  726. id: 123,
  727. name: 'A1 University',
  728. },
  729. cachedConfirmedAt: '2019-01-01T18:25:01.639Z',
  730. cachedReconfirmedAt: '2022-03-11T18:25:01.639Z',
  731. },
  732. },
  733. {
  734. email: '2020@foo.bar',
  735. confirmedAt: new Date(2020, 6, 1),
  736. lastConfirmedAt: new Date(2020, 6, 1),
  737. },
  738. {
  739. email: '2021@foo.bar',
  740. confirmedAt: new Date(2021, 6, 1),
  741. lastConfirmedAt: new Date(2021, 6, 1),
  742. },
  743. {
  744. email: this.user.email,
  745. confirmedAt: new Date(2021, 6, 1),
  746. lastConfirmedAt: new Date(2021, 6, 1),
  747. },
  748. {
  749. email: this.newEmail,
  750. confirmedAt: new Date(2021, 6, 1),
  751. lastConfirmedAt: new Date(2021, 6, 1),
  752. },
  753. ])
  754. await this.UserUpdater.promises.setDefaultEmailAddress(
  755. this.user._id,
  756. this.newEmail,
  757. false,
  758. this.auditLog,
  759. true
  760. )
  761. // Emails are sent asynchronously. Wait a bit.
  762. await setTimeout(100)
  763. this.EmailHandler.promises.sendEmail.callCount.should.equal(4)
  764. for (const recipient of [
  765. this.user.email,
  766. this.newEmail,
  767. '2@a1.uni',
  768. '2021@foo.bar',
  769. ]) {
  770. expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
  771. 'securityAlert',
  772. sinon.match({ to: recipient })
  773. )
  774. }
  775. })
  776. it('should send to the most recently (re-)confirmed emails grouped by institution and by domain for unaffiliated emails (multiple institutions and unaffiliated email domains)', async function () {
  777. setUserEmails(this, [
  778. {
  779. email: '1@a1.uni',
  780. confirmedAt: new Date(2020, 0, 1),
  781. reConfirmedAt: new Date(2021, 2, 11),
  782. lastConfirmedAt: new Date(2021, 2, 11),
  783. default: false,
  784. affiliation: {
  785. institution: {
  786. id: 123,
  787. name: 'A1 University',
  788. },
  789. cachedConfirmedAt: '2020-01-01T18:25:01.639Z',
  790. cachedReconfirmedAt: '2021-03-11T18:25:01.639Z',
  791. },
  792. },
  793. {
  794. email: '1@b2.uni',
  795. confirmedAt: new Date(2019, 0, 1),
  796. reConfirmedAt: new Date(2022, 2, 11),
  797. lastConfirmedAt: new Date(2022, 2, 11),
  798. default: false,
  799. affiliation: {
  800. institution: {
  801. id: 234,
  802. name: 'B2 University',
  803. },
  804. cachedConfirmedAt: '2019-01-01T18:25:01.639Z',
  805. cachedReconfirmedAt: '2022-03-11T18:25:01.639Z',
  806. },
  807. },
  808. {
  809. email: '2020@foo.bar',
  810. confirmedAt: new Date(2020, 6, 1),
  811. lastConfirmedAt: new Date(2020, 6, 1),
  812. },
  813. {
  814. email: '2021@bar.foo',
  815. confirmedAt: new Date(2021, 6, 1),
  816. lastConfirmedAt: new Date(2021, 6, 1),
  817. },
  818. {
  819. email: this.user.email,
  820. confirmedAt: new Date(2021, 6, 1),
  821. lastConfirmedAt: new Date(2021, 6, 1),
  822. },
  823. {
  824. email: this.newEmail,
  825. confirmedAt: new Date(2021, 6, 1),
  826. lastConfirmedAt: new Date(2021, 6, 1),
  827. },
  828. ])
  829. await this.UserUpdater.promises.setDefaultEmailAddress(
  830. this.user._id,
  831. this.newEmail,
  832. false,
  833. this.auditLog,
  834. true
  835. )
  836. // Emails are sent asynchronously. Wait a bit.
  837. await setTimeout(100)
  838. this.EmailHandler.promises.sendEmail.callCount.should.equal(6)
  839. for (const recipient of [
  840. this.user.email,
  841. this.newEmail,
  842. '1@a1.uni',
  843. '1@b2.uni',
  844. '2020@foo.bar',
  845. '2021@bar.foo',
  846. ]) {
  847. expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
  848. 'securityAlert',
  849. sinon.match({ to: recipient })
  850. )
  851. }
  852. })
  853. describe('errors', function () {
  854. const anError = new Error('oops')
  855. describe('EmailHandler', function () {
  856. beforeEach(function () {
  857. this.EmailHandler.promises.sendEmail.rejects(anError)
  858. })
  859. it('should log but not pass back the error', async function () {
  860. await this.UserUpdater.promises.setDefaultEmailAddress(
  861. this.user._id,
  862. this.newEmail,
  863. false,
  864. this.auditLog,
  865. true
  866. )
  867. const loggerCall = this.logger.error.firstCall
  868. expect(loggerCall.args[0]).to.deep.equal({
  869. error: anError,
  870. userId: this.user._id,
  871. })
  872. expect(loggerCall.args[1]).to.contain(
  873. 'could not send security alert email when primary email changed'
  874. )
  875. })
  876. })
  877. })
  878. })
  879. })
  880. describe('confirmEmail', function () {
  881. it('should update the email record', async function () {
  882. await this.UserUpdater.promises.confirmEmail(
  883. this.user._id,
  884. this.user.email
  885. )
  886. expect(this.db.users.updateOne).to.have.been.calledWith(
  887. {
  888. _id: this.user._id,
  889. 'emails.email': this.user.email,
  890. },
  891. {
  892. $set: {
  893. 'emails.$.reconfirmedAt': new Date(),
  894. },
  895. $min: {
  896. 'emails.$.confirmedAt': new Date(),
  897. },
  898. }
  899. )
  900. })
  901. it('adds affiliation', async function () {
  902. await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
  903. this.InstitutionsAPI.promises.addAffiliation.calledOnce.should.equal(true)
  904. sinon.assert.calledWith(
  905. this.InstitutionsAPI.promises.addAffiliation,
  906. this.user._id,
  907. this.newEmail,
  908. { confirmedAt: new Date() }
  909. )
  910. })
  911. it('handles errors', async function () {
  912. this.db.users.updateOne.rejects(new Error('nope'))
  913. await expect(
  914. this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
  915. ).to.be.rejected
  916. })
  917. it('handle missed update', async function () {
  918. this.db.users.updateOne.resolves({ matchedCount: 0 })
  919. await expect(
  920. this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
  921. ).to.be.rejected
  922. })
  923. it('validates email', async function () {
  924. expect(this.UserUpdater.promises.confirmEmail(this.user._id, '@')).to.be
  925. .rejected
  926. })
  927. it('handles affiliation errors', async function () {
  928. this.InstitutionsAPI.promises.addAffiliation.rejects(new Error('nope'))
  929. await expect(
  930. this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
  931. ).to.be.rejected
  932. expect(this.db.users.updateOne).to.not.have.been.called
  933. })
  934. it('refreshes features', async function () {
  935. await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
  936. sinon.assert.calledWith(
  937. this.FeaturesUpdater.promises.refreshFeatures,
  938. this.user._id
  939. )
  940. })
  941. it('should not call redundantPersonalSubscription when user is not on a commons license', async function () {
  942. this.InstitutionsAPI.promises.getUserAffiliations.resolves([])
  943. this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves({
  944. planCode: 'personal',
  945. groupPlan: false,
  946. })
  947. await this.UserUpdater.promises.confirmEmail(this.user._id, this.newEmail)
  948. sinon.assert.notCalled(
  949. this.NotificationsBuilder.promises.redundantPersonalSubscription
  950. )
  951. })
  952. describe('with institution licence and subscription', function () {
  953. beforeEach(async function () {
  954. this.affiliation = {
  955. email: this.newEmail,
  956. licence: 'pro_plus',
  957. institution: {
  958. id: 123,
  959. name: 'Institution',
  960. },
  961. }
  962. this.InstitutionsAPI.promises.getUserAffiliations.resolves([
  963. this.affiliation,
  964. { email: 'other@email.edu' },
  965. ])
  966. this.SubscriptionLocator.promises.getUserIndividualSubscription.resolves(
  967. {
  968. planCode: 'personal',
  969. groupPlan: false,
  970. }
  971. )
  972. })
  973. it('creates redundant subscription notification', async function () {
  974. await this.UserUpdater.promises.confirmEmail(
  975. this.user._id,
  976. this.newEmail
  977. )
  978. sinon.assert.calledWith(
  979. this.InstitutionsAPI.promises.getUserAffiliations,
  980. this.user._id
  981. )
  982. sinon.assert.calledWith(
  983. this.SubscriptionLocator.promises.getUserIndividualSubscription,
  984. this.user._id
  985. )
  986. sinon.assert.calledWith(
  987. this.NotificationsBuilder.promises.redundantPersonalSubscription,
  988. {
  989. institutionId: 123,
  990. institutionName: 'Institution',
  991. },
  992. { _id: this.user._id }
  993. )
  994. })
  995. })
  996. })
  997. describe('suspendUser', function () {
  998. beforeEach(function () {
  999. this.auditLog = {
  1000. initiatorId: 'abc123',
  1001. ip: '0.0.0.0',
  1002. }
  1003. })
  1004. it('should suspend the user', async function () {
  1005. await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog)
  1006. expect(this.db.users.updateOne).to.have.been.calledWith(
  1007. { _id: this.user._id, suspended: { $ne: true } },
  1008. { $set: { suspended: true } }
  1009. )
  1010. })
  1011. it('should remove sessions from redis', async function () {
  1012. await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog)
  1013. expect(
  1014. this.UserSessionsManager.promises.removeSessionsFromRedis
  1015. ).to.have.been.calledWith({ _id: this.user._id })
  1016. })
  1017. it('should log the suspension to the audit log', async function () {
  1018. await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog)
  1019. expect(
  1020. this.UserAuditLogHandler.promises.addEntry
  1021. ).to.have.been.calledWith(
  1022. this.user._id,
  1023. 'account-suspension',
  1024. this.auditLog.initiatorId,
  1025. this.auditLog.ip,
  1026. {}
  1027. )
  1028. })
  1029. it('should fire the removeDropbox hook', async function () {
  1030. await this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog)
  1031. expect(this.Modules.promises.hooks.fire).to.have.been.calledWith(
  1032. 'removeDropbox',
  1033. this.user._id,
  1034. 'account-suspension'
  1035. )
  1036. })
  1037. it('should handle not finding a record to update', async function () {
  1038. this.db.users.updateOne.resolves({ matchedCount: 0 })
  1039. await expect(
  1040. this.UserUpdater.promises.suspendUser(this.user._id, this.auditLog)
  1041. ).to.be.rejectedWith(Errors.NotFoundError)
  1042. })
  1043. })
  1044. })