UserUpdaterTests.js 27 KB

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