UserUpdaterTests.js 31 KB

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