UserEmailsController.test.mjs 29 KB

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