UserUpdaterTests.js 31 KB

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