UserUpdater.test.mjs 36 KB

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