UserEmailsController.test.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970
  1. import { vi, assert, expect } from 'vitest'
  2. import { setTimeout } from 'node:timers/promises'
  3. import MockRequest from '../helpers/MockRequest.mjs'
  4. import MockResponse from '../helpers/MockResponse.mjs'
  5. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  6. const modulePath = '../../../../app/src/Features/User/UserEmailsController.mjs'
  7. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  8. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  9. )
  10. describe('UserEmailsController', function () {
  11. beforeEach(async function (ctx) {
  12. ctx.req = new MockRequest(vi)
  13. ctx.req.sessionID = Math.random().toString()
  14. ctx.res = new MockResponse(vi)
  15. ctx.next = vi.fn()
  16. ctx.user = {
  17. _id: 'mock-user-id',
  18. email: 'example@overleaf.com',
  19. emails: [],
  20. }
  21. ctx.UserGetter = {
  22. getUser: vi.fn().mockImplementation((userId, projection, callback) => {
  23. callback?.(null, ctx.user)
  24. }),
  25. getUserFullEmails: vi.fn(),
  26. promises: {
  27. ensureUniqueEmailAddress: vi.fn().mockResolvedValue(undefined),
  28. getUser: vi.fn().mockResolvedValue(ctx.user),
  29. getUserByAnyEmail: vi.fn(),
  30. },
  31. }
  32. ctx.SessionManager = {
  33. getSessionUser: vi.fn().mockReturnValue(ctx.user),
  34. getLoggedInUserId: vi.fn().mockReturnValue(ctx.user._id),
  35. setInSessionUser: vi.fn(),
  36. }
  37. ctx.Features = {
  38. hasFeature: vi.fn(),
  39. }
  40. ctx.UserSessionsManager = {
  41. promises: {
  42. removeSessionsFromRedis: vi.fn().mockResolvedValue(undefined),
  43. },
  44. }
  45. ctx.UserUpdater = {
  46. addEmailAddress: vi.fn(),
  47. updateV1AndSetDefaultEmailAddress: vi.fn(),
  48. promises: {
  49. addEmailAddress: vi.fn().mockResolvedValue(undefined),
  50. confirmEmail: vi.fn().mockResolvedValue(undefined),
  51. removeEmailAddress: vi.fn(),
  52. setDefaultEmailAddress: vi.fn().mockResolvedValue(undefined),
  53. },
  54. }
  55. ctx.EmailHelper = { parseEmail: vi.fn() }
  56. ctx.endorseAffiliation = vi.fn((userId, email, role, dept, callback) =>
  57. callback()
  58. )
  59. ctx.InstitutionsAPI = {
  60. endorseAffiliation: ctx.endorseAffiliation,
  61. }
  62. ctx.HttpErrorHandler = { conflict: vi.fn() }
  63. ctx.AnalyticsManager = {
  64. recordEventForSession: vi.fn(),
  65. recordEventForMongoUserInBackground: vi.fn(),
  66. }
  67. ctx.UserAuditLogHandler = {
  68. addEntry: vi.fn((userId, op, initiatorId, ip, info, callback) =>
  69. callback()
  70. ),
  71. promises: {
  72. addEntry: vi.fn().mockResolvedValue(undefined),
  73. },
  74. }
  75. ctx.rateLimiter = {
  76. consume: vi.fn().mockResolvedValue(undefined),
  77. }
  78. ctx.RateLimiter = {
  79. RateLimiter: vi.fn().mockImplementation(function RateLimiter() {
  80. this.consume = ctx.rateLimiter.consume
  81. }),
  82. }
  83. ctx.AuthenticationController = {
  84. getRedirectFromSession: vi.fn().mockReturnValue(null),
  85. }
  86. vi.doMock(
  87. '../../../../app/src/Features/Authentication/AuthenticationController',
  88. () => ({
  89. default: ctx.AuthenticationController,
  90. })
  91. )
  92. vi.doMock(
  93. '../../../../app/src/Features/Authentication/SessionManager',
  94. () => ({
  95. default: ctx.SessionManager,
  96. })
  97. )
  98. vi.doMock('../../../../app/src/infrastructure/Features', () => ({
  99. default: ctx.Features,
  100. }))
  101. vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({
  102. default: ctx.UserSessionsManager,
  103. }))
  104. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  105. default: ctx.UserGetter,
  106. }))
  107. vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
  108. default: ctx.UserUpdater,
  109. }))
  110. vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
  111. default: (ctx.EmailHandler = {
  112. promises: {
  113. sendEmail: vi.fn().mockResolvedValue(undefined),
  114. },
  115. }),
  116. }))
  117. vi.doMock('../../../../app/src/Features/Helpers/EmailHelper', () => ({
  118. default: ctx.EmailHelper,
  119. }))
  120. vi.doMock(
  121. '../../../../app/src/Features/User/UserEmailsConfirmationHandler',
  122. () => ({
  123. default: (ctx.UserEmailsConfirmationHandler = {
  124. promises: {},
  125. }),
  126. })
  127. )
  128. vi.doMock(
  129. '../../../../app/src/Features/Institutions/InstitutionsAPI',
  130. () => ({
  131. default: ctx.InstitutionsAPI,
  132. })
  133. )
  134. vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
  135. default: ctx.HttpErrorHandler,
  136. }))
  137. vi.doMock(
  138. '../../../../app/src/Features/Analytics/AnalyticsManager',
  139. () => ({
  140. default: ctx.AnalyticsManager,
  141. })
  142. )
  143. vi.doMock('../../../../app/src/Features/User/UserAuditLogHandler', () => ({
  144. default: ctx.UserAuditLogHandler,
  145. }))
  146. vi.doMock(
  147. '../../../../app/src/infrastructure/RateLimiter',
  148. () => ctx.RateLimiter
  149. )
  150. ctx.UserEmailsController = (await import(modulePath)).default
  151. })
  152. describe('List', function () {
  153. beforeEach(function () {})
  154. it('lists emails', async function (ctx) {
  155. expect.assertions(1)
  156. const fullEmails = [{ some: 'data' }]
  157. ctx.UserGetter.getUserFullEmails.mockImplementation(
  158. (userId, callback) => {
  159. callback(null, fullEmails)
  160. }
  161. )
  162. await ctx.UserEmailsController.list(ctx.req, {
  163. json: response => {
  164. assert.deepEqual(response, fullEmails)
  165. expect(ctx.UserGetter.getUserFullEmails).toHaveBeenCalledWith(
  166. ctx.user._id,
  167. expect.any(Function)
  168. )
  169. },
  170. })
  171. })
  172. })
  173. describe('addWithConfirmationCode', function () {
  174. beforeEach(function (ctx) {
  175. ctx.newEmail = 'new_email@baz.com'
  176. ctx.req.body = {
  177. email: ctx.newEmail,
  178. }
  179. ctx.EmailHelper.parseEmail.mockReturnValue(ctx.newEmail)
  180. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode = vi
  181. .fn()
  182. .mockResolvedValue({
  183. confirmCode: '123456',
  184. confirmCodeExpiresTimestamp: new Date(),
  185. })
  186. })
  187. it('sends an email confirmation', async function (ctx) {
  188. expect.assertions(2)
  189. await ctx.UserEmailsController.addWithConfirmationCode(ctx.req, {
  190. sendStatus: code => {
  191. expect(code).to.equal(200)
  192. expect(
  193. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
  194. ).toHaveBeenCalledWith(ctx.newEmail, false)
  195. },
  196. })
  197. })
  198. it('handles email parse error', async function (ctx) {
  199. expect.assertions(1)
  200. ctx.EmailHelper.parseEmail.mockReturnValue(null)
  201. await ctx.UserEmailsController.addWithConfirmationCode(ctx.req, {
  202. sendStatus: code => {
  203. expect(code).to.equal(422)
  204. },
  205. })
  206. })
  207. it('handles when the email already exists', async function (ctx) {
  208. expect.assertions(1)
  209. ctx.UserGetter.promises.ensureUniqueEmailAddress.mockRejectedValue(
  210. new Errors.EmailExistsError()
  211. )
  212. await ctx.UserEmailsController.addWithConfirmationCode(ctx.req, {
  213. status: code => {
  214. expect(code).to.equal(409)
  215. return { json: () => {} }
  216. },
  217. })
  218. })
  219. it('should fail to add new emails when the limit has been reached', async function (ctx) {
  220. expect.assertions(2)
  221. ctx.user.emails = []
  222. for (let i = 0; i < 10; i++) {
  223. ctx.user.emails.push({ email: `example${i}@overleaf.com` })
  224. }
  225. await ctx.UserEmailsController.addWithConfirmationCode(ctx.req, {
  226. status: code => {
  227. expect(code).to.equal(422)
  228. return {
  229. json: error => {
  230. expect(error.message).to.equal('secondary email limit exceeded')
  231. },
  232. }
  233. },
  234. })
  235. })
  236. })
  237. describe('checkNewSecondaryEmailConfirmationCode', function () {
  238. beforeEach(function (ctx) {
  239. ctx.newEmail = 'new_email@baz.com'
  240. ctx.req.session.pendingSecondaryEmail = {
  241. confirmCode: '123456',
  242. email: ctx.newEmail,
  243. confirmCodeExpiresTimestamp: new Date(Math.max),
  244. }
  245. })
  246. describe('with a valid confirmation code', function () {
  247. beforeEach(function (ctx) {
  248. ctx.req.body = {
  249. code: '123456',
  250. }
  251. })
  252. it('adds the email', async function (ctx) {
  253. expect.assertions(2)
  254. await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
  255. ctx.req,
  256. {
  257. json: () => {
  258. expect(
  259. ctx.UserUpdater.promises.addEmailAddress
  260. ).toHaveBeenCalledWith(ctx.user._id, ctx.newEmail, undefined, {
  261. initiatorId: 'mock-user-id',
  262. ipAddress: '42.42.42.42',
  263. })
  264. expect(
  265. ctx.UserUpdater.promises.confirmEmail
  266. ).toHaveBeenCalledWith(ctx.user._id, ctx.newEmail, undefined)
  267. },
  268. }
  269. )
  270. })
  271. it('redirects to /project', async function (ctx) {
  272. expect.assertions(1)
  273. await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
  274. ctx.req,
  275. {
  276. json: ({ redir }) => {
  277. expect(redir).to.equal('/project')
  278. },
  279. }
  280. )
  281. })
  282. it('sends a security alert email', async function (ctx) {
  283. expect.assertions(4)
  284. ctx.req.session.pendingSecondaryEmail = {
  285. confirmCode: '123456',
  286. email: ctx.newEmail,
  287. confirmCodeExpiresTimestamp: new Date(Math.max),
  288. affiliationOptions: {},
  289. }
  290. ctx.req.body.code = '123456'
  291. await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
  292. ctx.req,
  293. {
  294. json: vi.fn().mockResolvedValue(undefined),
  295. }
  296. )
  297. const emailCall = ctx.EmailHandler.promises.sendEmail.mock.calls[0]
  298. expect(emailCall[0]).to.equal('securityAlert')
  299. expect(emailCall[1].to).to.equal(ctx.user.email)
  300. expect(emailCall[1].actionDescribed).to.contain(
  301. 'a secondary email address'
  302. )
  303. expect(emailCall[1].message[0]).to.contain(ctx.newEmail)
  304. })
  305. })
  306. describe('with an invalid confirmation code', function () {
  307. beforeEach(function (ctx) {
  308. ctx.req.body = {
  309. code: '999999',
  310. }
  311. })
  312. it('does not add the email', async function (ctx) {
  313. expect.assertions(2)
  314. await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
  315. ctx.req,
  316. {
  317. status: () => {
  318. expect(
  319. ctx.UserUpdater.promises.addEmailAddress
  320. ).not.toHaveBeenCalled()
  321. expect(
  322. ctx.UserUpdater.promises.confirmEmail
  323. ).not.toHaveBeenCalled()
  324. return { json: ctx.next }
  325. },
  326. }
  327. )
  328. })
  329. it('responds with a 403', async function (ctx) {
  330. expect.assertions(1)
  331. await ctx.UserEmailsController.checkNewSecondaryEmailConfirmationCode(
  332. ctx.req,
  333. {
  334. status: code => {
  335. expect(code).to.equal(403)
  336. return { json: ctx.next }
  337. },
  338. }
  339. )
  340. })
  341. })
  342. })
  343. describe('resendNewSecondaryEmailConfirmationCode', function () {
  344. beforeEach(function (ctx) {
  345. ctx.newEmail = 'new_email@baz.com'
  346. ctx.req.session.pendingSecondaryEmail = {
  347. confirmCode: '123456',
  348. email: ctx.newEmail,
  349. confirmCodeExpiresTimestamp: new Date(Math.max),
  350. }
  351. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode = vi
  352. .fn()
  353. .mockResolvedValue({
  354. confirmCode: '123456',
  355. confirmCodeExpiresTimestamp: new Date(),
  356. })
  357. })
  358. it('should send the email', async function (ctx) {
  359. expect.assertions(2)
  360. await ctx.UserEmailsController.resendNewSecondaryEmailConfirmationCode(
  361. ctx.req,
  362. {
  363. status: code => {
  364. expect(code).to.equal(200)
  365. expect(
  366. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
  367. ).toHaveBeenCalledWith(ctx.newEmail, false)
  368. return { json: ctx.next }
  369. },
  370. }
  371. )
  372. })
  373. })
  374. describe('remove', function () {
  375. beforeEach(function (ctx) {
  376. ctx.email = 'email_to_remove@bar.com'
  377. ctx.req.body.email = ctx.email
  378. ctx.EmailHelper.parseEmail.mockReturnValue(ctx.email)
  379. })
  380. it('removes email', async function (ctx) {
  381. expect.assertions(3)
  382. const auditLog = {
  383. initiatorId: ctx.user._id,
  384. ipAddress: ctx.req.ip,
  385. }
  386. ctx.UserUpdater.promises.removeEmailAddress.mockResolvedValue(undefined)
  387. await ctx.UserEmailsController.remove(ctx.req, {
  388. sendStatus: code => {
  389. expect(code).to.equal(200)
  390. expect(ctx.EmailHelper.parseEmail).toHaveBeenCalledWith(ctx.email)
  391. expect(
  392. ctx.UserUpdater.promises.removeEmailAddress
  393. ).toHaveBeenCalledWith(ctx.user._id, ctx.email, auditLog)
  394. },
  395. })
  396. })
  397. it('handles email parse error', async function (ctx) {
  398. expect.assertions(2)
  399. ctx.EmailHelper.parseEmail.mockReturnValue(null)
  400. await ctx.UserEmailsController.remove(ctx.req, {
  401. sendStatus: code => {
  402. expect(code).to.equal(422)
  403. expect(
  404. ctx.UserUpdater.promises.removeEmailAddress
  405. ).not.toHaveBeenCalled()
  406. },
  407. })
  408. })
  409. })
  410. describe('setDefault', function () {
  411. beforeEach(function (ctx) {
  412. ctx.email = 'email_to_set_default@bar.com'
  413. ctx.req.body.email = ctx.email
  414. ctx.EmailHelper.parseEmail.mockReturnValue(ctx.email)
  415. ctx.SessionManager.setInSessionUser.mockReturnValue(null)
  416. })
  417. it('sets default email', async function (ctx) {
  418. expect.assertions(4)
  419. await ctx.UserEmailsController.setDefault(ctx.req, {
  420. sendStatus: code => {
  421. expect(code).to.equal(200)
  422. expect(ctx.EmailHelper.parseEmail).toHaveBeenCalledWith(ctx.email)
  423. expect(ctx.SessionManager.setInSessionUser).toHaveBeenCalledWith(
  424. ctx.req.session,
  425. {
  426. email: ctx.email,
  427. }
  428. )
  429. expect(
  430. ctx.UserUpdater.promises.setDefaultEmailAddress
  431. ).toHaveBeenCalledWith(
  432. ctx.user._id,
  433. ctx.email,
  434. false,
  435. { initiatorId: 'mock-user-id', ipAddress: '42.42.42.42' },
  436. true,
  437. false
  438. )
  439. },
  440. })
  441. })
  442. it('deletes unconfirmed primary if delete-unconfirmed-primary is set', async function (ctx) {
  443. expect.assertions(1)
  444. ctx.user.emails = [{ email: 'example@overleaf.com' }]
  445. ctx.req.query['delete-unconfirmed-primary'] = ''
  446. await ctx.UserEmailsController.setDefault(ctx.req, {
  447. sendStatus: () => {
  448. expect(
  449. ctx.UserUpdater.promises.removeEmailAddress
  450. ).toHaveBeenCalledWith(ctx.user._id, 'example@overleaf.com', {
  451. initiatorId: ctx.user._id,
  452. ipAddress: ctx.req.ip,
  453. extraInfo: {
  454. info: 'removed unconfirmed email after setting new primary',
  455. },
  456. })
  457. },
  458. })
  459. })
  460. it('doesnt delete a confirmed primary', async function (ctx) {
  461. expect.assertions(1)
  462. ctx.user.emails = [
  463. { email: 'example@overleaf.com', confirmedAt: '2000-01-01' },
  464. ]
  465. ctx.req.query['delete-unconfirmed-primary'] = ''
  466. await ctx.UserEmailsController.setDefault(ctx.req, {
  467. sendStatus: () => {
  468. expect(
  469. ctx.UserUpdater.promises.removeEmailAddress
  470. ).not.toHaveBeenCalled()
  471. },
  472. })
  473. })
  474. it('doesnt delete primary if delete-unconfirmed-primary is not set', async function (ctx) {
  475. await ctx.UserEmailsController.setDefault(ctx.req, {
  476. sendStatus: () => {
  477. expect(
  478. ctx.UserUpdater.promises.removeEmailAddress
  479. ).not.toHaveBeenCalled()
  480. },
  481. })
  482. })
  483. it('handles email parse error', async function (ctx) {
  484. expect.assertions(2)
  485. ctx.EmailHelper.parseEmail.mockReturnValue(null)
  486. await ctx.UserEmailsController.setDefault(ctx.req, {
  487. sendStatus: code => {
  488. expect(code).to.equal(422)
  489. expect(
  490. ctx.UserUpdater.promises.setDefaultEmailAddress
  491. ).not.toHaveBeenCalled()
  492. },
  493. })
  494. })
  495. it('should reset the users other sessions', async function (ctx) {
  496. await ctx.UserEmailsController.setDefault(ctx.req, ctx.res)
  497. expect(
  498. ctx.UserSessionsManager.promises.removeSessionsFromRedis
  499. ).toHaveBeenCalledWith(ctx.user, ctx.req.sessionID)
  500. })
  501. it('handles error from revoking sessions and returns 200', async function (ctx) {
  502. const redisError = new Error('redis error')
  503. ctx.UserSessionsManager.promises.removeSessionsFromRedis = vi
  504. .fn()
  505. .mockRejectedValue(redisError)
  506. await ctx.UserEmailsController.setDefault(ctx.req, ctx.res)
  507. expect(ctx.res.statusCode).to.equal(200)
  508. // give revoke process time to run
  509. await setTimeout(0)
  510. expect(ctx.logger.warn).toHaveBeenCalledWith(
  511. expect.objectContaining({ err: redisError }),
  512. 'failed revoking secondary sessions after changing default email'
  513. )
  514. })
  515. })
  516. describe('endorse', function () {
  517. beforeEach(function (ctx) {
  518. ctx.email = 'email_to_endorse@bar.com'
  519. ctx.req.body.email = ctx.email
  520. ctx.EmailHelper.parseEmail.mockReturnValue(ctx.email)
  521. })
  522. it('endorses affiliation', async function (ctx) {
  523. expect.assertions(2)
  524. ctx.req.body.role = 'Role'
  525. ctx.req.body.department = 'Department'
  526. await ctx.UserEmailsController.endorse(ctx.req, {
  527. sendStatus: code => {
  528. expect(code).to.equal(204)
  529. expect(ctx.endorseAffiliation).toHaveBeenCalledWith(
  530. ctx.user._id,
  531. ctx.email,
  532. 'Role',
  533. 'Department',
  534. expect.any(Function)
  535. )
  536. },
  537. })
  538. })
  539. })
  540. describe('confirm', function () {
  541. beforeEach(function (ctx) {
  542. ctx.UserEmailsConfirmationHandler.confirmEmailFromToken = vi
  543. .fn()
  544. .mockImplementation((req, token, callback) => {
  545. callback(null, { userId: ctx.user._id, email: ctx.user.email })
  546. })
  547. ctx.token = 'mock-token'
  548. ctx.req.body = { token: ctx.token }
  549. ctx.req.ip = '0.0.0.0'
  550. ctx.next = vi.fn()
  551. ctx.res = new MockResponse(vi)
  552. })
  553. describe('successfully', function () {
  554. beforeEach(function (ctx) {
  555. ctx.UserEmailsController.confirm(ctx.req, ctx.res, ctx.next)
  556. })
  557. it('should confirm the email from the token', function (ctx) {
  558. expect(
  559. ctx.UserEmailsConfirmationHandler.confirmEmailFromToken
  560. ).toHaveBeenCalledWith(ctx.req, ctx.token, expect.any(Function))
  561. })
  562. it('should return a 200 status', function (ctx) {
  563. expect(ctx.res.sendStatus).toHaveBeenCalledWith(200)
  564. })
  565. it('should log the confirmation to the audit log', function (ctx) {
  566. expect(ctx.UserAuditLogHandler.addEntry).toHaveBeenCalledWith(
  567. ctx.user._id,
  568. 'confirm-email',
  569. ctx.user._id,
  570. ctx.req.ip,
  571. {
  572. token: ctx.token.substring(0, 10),
  573. email: ctx.user.email,
  574. },
  575. expect.any(Function)
  576. )
  577. })
  578. })
  579. describe('without a token', function () {
  580. beforeEach(function (ctx) {
  581. ctx.req.body.token = null
  582. ctx.UserEmailsController.confirm(ctx.req, ctx.res, ctx.next)
  583. })
  584. it('should return a 422 status', function (ctx) {
  585. expect(ctx.res.status).toHaveBeenCalledWith(422)
  586. })
  587. })
  588. describe('when confirming fails', function () {
  589. beforeEach(function (ctx) {
  590. ctx.UserEmailsConfirmationHandler.confirmEmailFromToken = vi
  591. .fn()
  592. .mockImplementation((req, token, callback) => {
  593. callback(new Errors.NotFoundError('not found'))
  594. })
  595. })
  596. it('should return a 404 error code with a message', function (ctx) {
  597. ctx.UserEmailsController.confirm(ctx.req, ctx.res, ctx.next)
  598. expect(ctx.res.status).toHaveBeenCalledWith(404)
  599. expect(ctx.res.json).toHaveBeenCalledWith({
  600. message: ctx.req.i18n.translate('confirmation_token_invalid'),
  601. })
  602. })
  603. })
  604. })
  605. describe('sendExistingEmailConfirmationCode', function () {
  606. beforeEach(function (ctx) {
  607. ctx.email = 'existing-email@example.com'
  608. ctx.req.body.email = ctx.email
  609. ctx.EmailHelper.parseEmail.mockReturnValue(ctx.email)
  610. ctx.UserGetter.promises.getUserByAnyEmail.mockResolvedValue({
  611. _id: ctx.user._id,
  612. email: ctx.email,
  613. })
  614. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode = vi
  615. .fn()
  616. .mockResolvedValue({
  617. confirmCode: '123456',
  618. confirmCodeExpiresTimestamp: new Date(),
  619. })
  620. })
  621. it('should send confirmation code for existing email', async function (ctx) {
  622. expect.assertions(2)
  623. await ctx.UserEmailsController.sendExistingEmailConfirmationCode(
  624. ctx.req,
  625. {
  626. sendStatus: code => {
  627. expect(code).to.equal(204)
  628. expect(
  629. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
  630. ).toHaveBeenCalledWith(ctx.email, false)
  631. },
  632. }
  633. )
  634. })
  635. it('should store confirmation code in session', async function (ctx) {
  636. const confirmCode = '123456'
  637. const confirmCodeExpiresTimestamp = new Date()
  638. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode.mockResolvedValue(
  639. {
  640. confirmCode,
  641. confirmCodeExpiresTimestamp,
  642. }
  643. )
  644. await ctx.UserEmailsController.sendExistingEmailConfirmationCode(
  645. ctx.req,
  646. { sendStatus: vi.fn() }
  647. )
  648. expect(ctx.req.session.pendingExistingEmail).to.deep.equal({
  649. email: ctx.email,
  650. confirmCode,
  651. confirmCodeExpiresTimestamp,
  652. affiliationOptions: undefined,
  653. })
  654. })
  655. it('should handle invalid email', async function (ctx) {
  656. expect.assertions(2)
  657. ctx.EmailHelper.parseEmail.mockReturnValue(null)
  658. await ctx.UserEmailsController.sendExistingEmailConfirmationCode(
  659. ctx.req,
  660. {
  661. sendStatus: code => {
  662. expect(code).to.equal(400)
  663. expect(
  664. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
  665. ).not.toHaveBeenCalled()
  666. },
  667. }
  668. )
  669. })
  670. it('should handle email not belonging to user', async function (ctx) {
  671. expect.assertions(2)
  672. ctx.UserGetter.promises.getUserByAnyEmail.mockResolvedValue({
  673. _id: 'another-user-id',
  674. })
  675. await ctx.UserEmailsController.sendExistingEmailConfirmationCode(
  676. ctx.req,
  677. {
  678. sendStatus: code => {
  679. expect(code).to.equal(422)
  680. expect(
  681. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
  682. ).not.toHaveBeenCalled()
  683. },
  684. }
  685. )
  686. })
  687. })
  688. describe('checkExistingEmailConfirmationCode', function () {
  689. beforeEach(function (ctx) {
  690. ctx.email = 'existing-email@example.com'
  691. ctx.req.session.pendingExistingEmail = {
  692. confirmCode: '123456',
  693. email: ctx.email,
  694. confirmCodeExpiresTimestamp: new Date(Math.max),
  695. }
  696. ctx.UserUpdater.promises.confirmEmail.mockResolvedValue(undefined)
  697. ctx.res = new MockResponse(vi)
  698. })
  699. describe('with a valid confirmation code', function () {
  700. beforeEach(function (ctx) {
  701. ctx.req.body = { code: '123456' }
  702. })
  703. it('confirms the email', async function (ctx) {
  704. const mockRes = new MockResponse(vi)
  705. await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
  706. ctx.req,
  707. mockRes
  708. )
  709. expect(ctx.UserUpdater.promises.confirmEmail).toHaveBeenCalledWith(
  710. ctx.user._id,
  711. ctx.email,
  712. undefined
  713. )
  714. })
  715. it('adds audit log entry', async function (ctx) {
  716. await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
  717. ctx.req,
  718. { json: vi.fn() }
  719. )
  720. expect(ctx.UserAuditLogHandler.promises.addEntry).toHaveBeenCalledWith(
  721. ctx.user._id,
  722. 'confirm-email-via-code',
  723. ctx.user._id,
  724. ctx.req.ip,
  725. { email: ctx.email }
  726. )
  727. })
  728. it('records analytics event', async function (ctx) {
  729. await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
  730. ctx.req,
  731. { json: vi.fn() }
  732. )
  733. expect(ctx.AnalyticsManager.recordEventForSession).toHaveBeenCalledWith(
  734. ctx.req.session,
  735. 'email-verified',
  736. {
  737. provider: 'email',
  738. verification_type: 'token',
  739. isPrimary: ctx.user.email === ctx.email,
  740. }
  741. )
  742. })
  743. it('removes pendingExistingEmail from session', async function (ctx) {
  744. await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
  745. ctx.req,
  746. { json: vi.fn() }
  747. )
  748. expect(ctx.req.session.pendingExistingEmail).to.be.undefined
  749. })
  750. })
  751. describe('with an invalid confirmation code', function () {
  752. beforeEach(function (ctx) {
  753. ctx.req.body = { code: '999999' }
  754. })
  755. it('does not confirm the email', async function (ctx) {
  756. expect.assertions(1)
  757. await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
  758. ctx.req,
  759. {
  760. status: () => {
  761. expect(
  762. ctx.UserUpdater.promises.confirmEmail
  763. ).not.toHaveBeenCalled()
  764. return { json: ctx.next }
  765. },
  766. }
  767. )
  768. })
  769. it('responds with a 403', async function (ctx) {
  770. expect.assertions(1)
  771. await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
  772. ctx.req,
  773. {
  774. status: code => {
  775. expect(code).to.equal(403)
  776. return { json: ctx.next }
  777. },
  778. }
  779. )
  780. })
  781. })
  782. describe('with an expired confirmation code', function () {
  783. beforeEach(function (ctx) {
  784. ctx.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp =
  785. new Date(0)
  786. ctx.req.body = { code: '123456' }
  787. })
  788. it('responds with a 403', async function (ctx) {
  789. expect.assertions(1)
  790. await ctx.UserEmailsController.checkExistingEmailConfirmationCode(
  791. ctx.req,
  792. {
  793. status: code => {
  794. expect(code).to.equal(403)
  795. return { json: ctx.next }
  796. },
  797. }
  798. )
  799. })
  800. })
  801. })
  802. describe('resendExistingSecondaryEmailConfirmationCode', function () {
  803. beforeEach(function (ctx) {
  804. ctx.email = 'existing-email@example.com'
  805. ctx.req.session.pendingExistingEmail = {
  806. confirmCode: '123456',
  807. email: ctx.email,
  808. confirmCodeExpiresTimestamp: new Date(Math.max),
  809. }
  810. ctx.res.status = vi.fn().mockReturnValue({ json: vi.fn() })
  811. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode = vi
  812. .fn()
  813. .mockResolvedValue({
  814. confirmCode: '654321',
  815. confirmCodeExpiresTimestamp: new Date(),
  816. })
  817. })
  818. it('should resend confirmation code', async function (ctx) {
  819. expect.assertions(2)
  820. await ctx.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
  821. ctx.req,
  822. {
  823. status: code => {
  824. expect(code).to.equal(200)
  825. expect(
  826. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode
  827. ).toHaveBeenCalledWith(ctx.email, false)
  828. return { json: vi.fn() }
  829. },
  830. }
  831. )
  832. })
  833. it('should update session with new code', async function (ctx) {
  834. const newCode = '654321'
  835. const newExpiryTime = new Date()
  836. ctx.UserEmailsConfirmationHandler.promises.sendConfirmationCode.mockResolvedValue(
  837. {
  838. confirmCode: newCode,
  839. confirmCodeExpiresTimestamp: newExpiryTime,
  840. }
  841. )
  842. await ctx.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
  843. ctx.req,
  844. { status: () => ({ json: vi.fn() }) }
  845. )
  846. expect(ctx.req.session.pendingExistingEmail.confirmCode).to.equal(newCode)
  847. expect(
  848. ctx.req.session.pendingExistingEmail.confirmCodeExpiresTimestamp
  849. ).to.equal(newExpiryTime)
  850. })
  851. it('should add audit log entry', async function (ctx) {
  852. await ctx.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
  853. ctx.req,
  854. { status: () => ({ json: vi.fn() }) }
  855. )
  856. expect(ctx.UserAuditLogHandler.promises.addEntry).toHaveBeenCalledWith(
  857. ctx.user._id,
  858. 'resend-confirm-email-code',
  859. ctx.user._id,
  860. ctx.req.ip,
  861. { email: ctx.email }
  862. )
  863. })
  864. it('should handle rate limiting', async function (ctx) {
  865. expect.assertions(1)
  866. ctx.rateLimiter.consume.mockRejectedValue({ remainingPoints: 0 })
  867. await ctx.UserEmailsController.resendExistingSecondaryEmailConfirmationCode(
  868. ctx.req,
  869. {
  870. status: code => {
  871. expect(code).to.equal(429)
  872. return { json: vi.fn() }
  873. },
  874. }
  875. )
  876. })
  877. })
  878. })