PasswordResetController.test.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import { beforeEach, describe, expect, it, vi } from 'vitest'
  2. import sinon from 'sinon'
  3. import MockResponse from '../helpers/MockResponse.mjs'
  4. const MODULE_PATH =
  5. '../../../../app/src/Features/PasswordReset/PasswordResetController.mjs'
  6. describe('PasswordResetController', function () {
  7. beforeEach(async function (ctx) {
  8. ctx.email = 'bob@bob.com'
  9. ctx.user_id = '507f1f77bcf86cd799439011'
  10. ctx.token = 'my security token that was emailed to me'
  11. ctx.password = 'my new password'
  12. ctx.req = {
  13. body: {
  14. email: ctx.email,
  15. passwordResetToken: ctx.token,
  16. password: ctx.password,
  17. },
  18. i18n: {
  19. translate() {
  20. return '.'
  21. },
  22. },
  23. session: {},
  24. query: {},
  25. }
  26. ctx.res = new MockResponse(vi)
  27. ctx.settings = {}
  28. ctx.PasswordResetHandler = {
  29. generateAndEmailResetToken: sinon.stub(),
  30. promises: {
  31. generateAndEmailResetToken: sinon.stub(),
  32. setNewUserPassword: sinon.stub().resolves({
  33. found: true,
  34. reset: true,
  35. userID: ctx.user_id,
  36. mustReconfirm: true,
  37. }),
  38. getUserForPasswordResetToken: sinon
  39. .stub()
  40. .withArgs(ctx.token)
  41. .resolves({
  42. user: { _id: ctx.user_id },
  43. remainingPeeks: 1,
  44. }),
  45. },
  46. }
  47. ctx.UserSessionsManager = {
  48. promises: {
  49. removeSessionsFromRedis: sinon.stub().resolves(),
  50. },
  51. }
  52. ctx.UserUpdater = {
  53. promises: {
  54. removeReconfirmFlag: sinon.stub().resolves(),
  55. },
  56. }
  57. ctx.SplitTestHandler = {
  58. promises: {
  59. getAssignment: sinon.stub().resolves({ variant: 'default' }),
  60. },
  61. }
  62. vi.doMock('@overleaf/settings', () => ({
  63. default: ctx.settings,
  64. }))
  65. vi.doMock(
  66. '../../../../app/src/Features/PasswordReset/PasswordResetHandler',
  67. () => ({
  68. default: ctx.PasswordResetHandler,
  69. })
  70. )
  71. vi.doMock(
  72. '../../../../app/src/Features/Authentication/AuthenticationManager',
  73. () => ({
  74. default: {
  75. validatePassword: sinon.stub().returns(null),
  76. },
  77. })
  78. )
  79. vi.doMock(
  80. '../../../../app/src/Features/Authentication/AuthenticationController',
  81. () => ({
  82. default: (ctx.AuthenticationController = {
  83. getLoggedInUserId: sinon.stub(),
  84. finishLogin: sinon.stub(),
  85. setAuditInfo: sinon.stub(),
  86. }),
  87. })
  88. )
  89. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  90. default: (ctx.UserGetter = {
  91. promises: {
  92. getUser: sinon.stub(),
  93. },
  94. }),
  95. }))
  96. vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({
  97. default: ctx.UserSessionsManager,
  98. }))
  99. vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
  100. default: ctx.UserUpdater,
  101. }))
  102. vi.doMock(
  103. '../../../../app/src/Features/SplitTests/SplitTestHandler',
  104. () => ({
  105. default: ctx.SplitTestHandler,
  106. })
  107. )
  108. ctx.PasswordResetController = (await import(MODULE_PATH)).default
  109. })
  110. describe('requestReset', function () {
  111. it('should tell the handler to process that email', async function (ctx) {
  112. ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
  113. 'primary'
  114. )
  115. await ctx.PasswordResetController.requestReset(ctx.req, ctx.res)
  116. expect(ctx.res.statusCode).to.equal(200)
  117. expect(ctx.res.json).toHaveBeenCalledWith(
  118. expect.objectContaining({ message: expect.anything() })
  119. )
  120. expect(
  121. ctx.PasswordResetHandler.promises.generateAndEmailResetToken.lastCall
  122. .args[0]
  123. ).equal(ctx.email)
  124. })
  125. it('should send a 500 if there is an error', async function (ctx) {
  126. await new Promise(resolve => {
  127. ctx.PasswordResetHandler.promises.generateAndEmailResetToken.rejects(
  128. new Error('error')
  129. )
  130. ctx.PasswordResetController.requestReset(ctx.req, ctx.res, error => {
  131. expect(error).to.exist
  132. resolve()
  133. })
  134. })
  135. })
  136. it("should send a 404 if the email doesn't exist", async function (ctx) {
  137. ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
  138. null
  139. )
  140. await ctx.PasswordResetController.requestReset(ctx.req, ctx.res)
  141. expect(ctx.res.statusCode).to.equal(404)
  142. expect(ctx.res.json).toHaveBeenCalledWith(
  143. expect.objectContaining({ message: expect.anything() })
  144. )
  145. })
  146. it('should send a 404 if the email is registered as a secondard email', async function (ctx) {
  147. ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
  148. 'secondary'
  149. )
  150. await ctx.PasswordResetController.requestReset(ctx.req, ctx.res)
  151. expect(ctx.res.statusCode).to.equal(404)
  152. expect(ctx.res.json).toHaveBeenCalledWith(
  153. expect.objectContaining({ message: expect.anything() })
  154. )
  155. })
  156. it('should normalize the email address', async function (ctx) {
  157. ctx.email = ' UPperCaseEMAILWithSpacesAround@example.Com '
  158. ctx.req.body.email = ctx.email
  159. ctx.PasswordResetHandler.promises.generateAndEmailResetToken.resolves(
  160. 'primary'
  161. )
  162. await ctx.PasswordResetController.requestReset(ctx.req, ctx.res)
  163. expect(ctx.res.statusCode).to.equal(200)
  164. expect(ctx.res.json).toHaveBeenCalledWith(
  165. expect.objectContaining({ message: expect.anything() })
  166. )
  167. })
  168. })
  169. describe('setNewUserPassword', function () {
  170. beforeEach(function (ctx) {
  171. ctx.req.session.resetToken = ctx.token
  172. })
  173. it('should tell the user handler to reset the password', async function (ctx) {
  174. await new Promise(resolve => {
  175. ctx.res.sendStatus = code => {
  176. code.should.equal(200)
  177. ctx.PasswordResetHandler.promises.setNewUserPassword
  178. .calledWith(ctx.token, ctx.password)
  179. .should.equal(true)
  180. resolve()
  181. }
  182. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  183. })
  184. })
  185. it('should preserve spaces in the password', async function (ctx) {
  186. await new Promise(resolve => {
  187. ctx.password = ctx.req.body.password = ' oh! clever! spaces around! '
  188. ctx.res.sendStatus = code => {
  189. code.should.equal(200)
  190. ctx.PasswordResetHandler.promises.setNewUserPassword.should.have.been.calledWith(
  191. ctx.token,
  192. ctx.password
  193. )
  194. resolve()
  195. }
  196. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  197. })
  198. })
  199. it('should send 404 if the token was not found', async function (ctx) {
  200. await new Promise(resolve => {
  201. ctx.PasswordResetHandler.promises.setNewUserPassword.resolves({
  202. found: false,
  203. reset: false,
  204. userId: ctx.user_id,
  205. })
  206. ctx.res.status = code => {
  207. code.should.equal(404)
  208. return ctx.res
  209. }
  210. ctx.res.json = data => {
  211. data.message.key.should.equal('token-expired')
  212. resolve()
  213. }
  214. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  215. })
  216. })
  217. it('should return 500 if not reset', async function (ctx) {
  218. await new Promise(resolve => {
  219. ctx.PasswordResetHandler.promises.setNewUserPassword.resolves({
  220. found: true,
  221. reset: false,
  222. userId: ctx.user_id,
  223. })
  224. ctx.res.status = code => {
  225. code.should.equal(500)
  226. return ctx.res
  227. }
  228. ctx.res.json = data => {
  229. expect(data.message).to.exist
  230. resolve()
  231. }
  232. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  233. })
  234. })
  235. it('should return 400 (Bad Request) if there is no password', async function (ctx) {
  236. await new Promise(resolve => {
  237. ctx.req.body.password = ''
  238. ctx.res.status = code => {
  239. code.should.equal(400)
  240. return ctx.res
  241. }
  242. ctx.res.json = data => {
  243. data.message.key.should.equal('invalid-password')
  244. ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
  245. false
  246. )
  247. resolve()
  248. }
  249. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  250. })
  251. })
  252. it('should return 400 (Bad Request) if there is no passwordResetToken', async function (ctx) {
  253. await new Promise(resolve => {
  254. ctx.req.body.passwordResetToken = ''
  255. ctx.res.status = code => {
  256. code.should.equal(400)
  257. return ctx.res
  258. }
  259. ctx.res.json = data => {
  260. data.message.key.should.equal('invalid-password')
  261. ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
  262. false
  263. )
  264. resolve()
  265. }
  266. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  267. })
  268. })
  269. it('should return 400 (Bad Request) if the password is invalid', async function (ctx) {
  270. await new Promise(resolve => {
  271. ctx.req.body.password = 'correct horse battery staple'
  272. const err = new Error('bad')
  273. err.name = 'InvalidPasswordError'
  274. ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(err)
  275. ctx.res.status = code => {
  276. code.should.equal(400)
  277. return ctx.res
  278. }
  279. ctx.res.json = data => {
  280. data.message.key.should.equal('invalid-password')
  281. ctx.PasswordResetHandler.promises.setNewUserPassword.called.should.equal(
  282. true
  283. )
  284. resolve()
  285. }
  286. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  287. })
  288. })
  289. it('should clear sessions', async function (ctx) {
  290. await new Promise(resolve => {
  291. ctx.res.sendStatus = code => {
  292. ctx.UserSessionsManager.promises.removeSessionsFromRedis.callCount.should.equal(
  293. 1
  294. )
  295. resolve()
  296. }
  297. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  298. })
  299. })
  300. it('should call removeReconfirmFlag if user.must_reconfirm', async function (ctx) {
  301. await new Promise(resolve => {
  302. ctx.res.sendStatus = code => {
  303. ctx.UserUpdater.promises.removeReconfirmFlag.callCount.should.equal(1)
  304. resolve()
  305. }
  306. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  307. })
  308. })
  309. describe('catch errors', function () {
  310. it('should return 404 for NotFoundError', async function (ctx) {
  311. await new Promise(resolve => {
  312. const anError = new Error('oops')
  313. anError.name = 'NotFoundError'
  314. ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
  315. ctx.res.status = code => {
  316. code.should.equal(404)
  317. return ctx.res
  318. }
  319. ctx.res.json = data => {
  320. data.message.key.should.equal('token-expired')
  321. resolve()
  322. }
  323. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  324. })
  325. })
  326. it('should return 400 for InvalidPasswordError', async function (ctx) {
  327. await new Promise(resolve => {
  328. const anError = new Error('oops')
  329. anError.name = 'InvalidPasswordError'
  330. ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
  331. ctx.res.status = code => {
  332. code.should.equal(400)
  333. return ctx.res
  334. }
  335. ctx.res.json = data => {
  336. data.message.key.should.equal('invalid-password')
  337. resolve()
  338. }
  339. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  340. })
  341. })
  342. it('should return 500 for other errors', async function (ctx) {
  343. await new Promise(resolve => {
  344. const anError = new Error('oops')
  345. ctx.PasswordResetHandler.promises.setNewUserPassword.rejects(anError)
  346. ctx.res.status = code => {
  347. code.should.equal(500)
  348. return ctx.res
  349. }
  350. ctx.res.json = data => {
  351. expect(data.message).to.exist
  352. resolve()
  353. }
  354. ctx.res.sendStatus = code => {
  355. code.should.equal(500)
  356. resolve()
  357. }
  358. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  359. })
  360. })
  361. })
  362. describe('when doLoginAfterPasswordReset is set', function () {
  363. beforeEach(function (ctx) {
  364. ctx.user = {
  365. _id: ctx.userId,
  366. email: 'joe@example.com',
  367. }
  368. ctx.UserGetter.promises.getUser.resolves(ctx.user)
  369. ctx.req.session.doLoginAfterPasswordReset = 'true'
  370. })
  371. it('should login user', async function (ctx) {
  372. await new Promise(resolve => {
  373. ctx.AuthenticationController.finishLogin.callsFake((...args) => {
  374. expect(args[0]).to.equal(ctx.user)
  375. resolve()
  376. })
  377. ctx.PasswordResetController.setNewUserPassword(ctx.req, ctx.res)
  378. })
  379. })
  380. })
  381. })
  382. describe('renderSetPasswordForm', function () {
  383. describe('with token in query-string', function () {
  384. beforeEach(function (ctx) {
  385. ctx.req.query.passwordResetToken = ctx.token
  386. ctx.req.query.email = 'test@example.com'
  387. })
  388. it('should set session.resetToken and redirect', async function (ctx) {
  389. await new Promise(resolve => {
  390. ctx.req.session.should.not.have.property('resetToken')
  391. ctx.res.redirect = path => {
  392. path.should.equal('/user/password/set?email=test%40example.com')
  393. ctx.req.session.resetToken.should.equal(ctx.token)
  394. resolve()
  395. }
  396. ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
  397. })
  398. })
  399. })
  400. describe('with expired token in query', function () {
  401. beforeEach(function (ctx) {
  402. ctx.req.query.email = 'test@example.com'
  403. ctx.req.query.passwordResetToken = ctx.token
  404. ctx.PasswordResetHandler.promises.getUserForPasswordResetToken = sinon
  405. .stub()
  406. .withArgs(ctx.token)
  407. .resolves({ user: { _id: ctx.user_id }, remainingPeeks: 0 })
  408. })
  409. it('should redirect to the reset request page with an error message', async function (ctx) {
  410. await new Promise((resolve, reject) => {
  411. ctx.res.redirect = path => {
  412. path.should.equal('/user/password/reset?error=token_expired')
  413. ctx.req.session.should.not.have.property('resetToken')
  414. resolve()
  415. }
  416. ctx.res.render = (templatePath, options) => {
  417. reject(new Error('should not render'))
  418. }
  419. ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
  420. })
  421. })
  422. })
  423. describe('with token and email in query-string', function () {
  424. beforeEach(function (ctx) {
  425. ctx.req.query.passwordResetToken = ctx.token
  426. ctx.req.query.email = 'foo@bar.com'
  427. })
  428. it('should set session.resetToken and redirect with email', async function (ctx) {
  429. await new Promise(resolve => {
  430. ctx.req.session.should.not.have.property('resetToken')
  431. ctx.res.redirect = path => {
  432. path.should.equal('/user/password/set?email=foo%40bar.com')
  433. ctx.req.session.resetToken.should.equal(ctx.token)
  434. resolve()
  435. }
  436. ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
  437. })
  438. })
  439. })
  440. describe('with token and invalid email in query-string', function () {
  441. beforeEach(function (ctx) {
  442. ctx.req.query.passwordResetToken = ctx.token
  443. ctx.req.query.email = 'not-an-email'
  444. })
  445. it('should set session.resetToken and redirect without email', async function (ctx) {
  446. await new Promise(resolve => {
  447. ctx.req.session.should.not.have.property('resetToken')
  448. ctx.res.redirect = path => {
  449. path.should.equal('/user/password/set')
  450. ctx.req.session.resetToken.should.equal(ctx.token)
  451. resolve()
  452. }
  453. ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
  454. })
  455. })
  456. })
  457. describe('with token and non-string email in query-string', function () {
  458. beforeEach(function (ctx) {
  459. ctx.req.query.passwordResetToken = ctx.token
  460. ctx.req.query.email = { foo: 'bar' }
  461. })
  462. it('should call next with an error', async function (ctx) {
  463. await new Promise(resolve => {
  464. ctx.req.session.should.not.have.property('resetToken')
  465. const next = error => {
  466. expect(error).to.exist
  467. resolve()
  468. }
  469. ctx.PasswordResetController.renderSetPasswordForm(
  470. ctx.req,
  471. ctx.res,
  472. next
  473. )
  474. })
  475. })
  476. })
  477. describe('without a token in query-string', function () {
  478. describe('with token in session', function () {
  479. beforeEach(function (ctx) {
  480. ctx.req.session.resetToken = ctx.token
  481. ctx.req.query.email = 'test@example.com'
  482. })
  483. it('should render the page, passing the reset token', async function (ctx) {
  484. await new Promise(resolve => {
  485. ctx.res.render = (templatePath, options) => {
  486. options.passwordResetToken.should.equal(ctx.token)
  487. resolve()
  488. }
  489. ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
  490. })
  491. })
  492. it('should clear the req.session.resetToken', async function (ctx) {
  493. await new Promise(resolve => {
  494. ctx.res.render = (templatePath, options) => {
  495. ctx.req.session.should.not.have.property('resetToken')
  496. resolve()
  497. }
  498. ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
  499. })
  500. })
  501. })
  502. describe('without a token in session', function () {
  503. it('should redirect to the reset request page', async function (ctx) {
  504. await new Promise(resolve => {
  505. ctx.res.redirect = path => {
  506. path.should.equal('/user/password/reset')
  507. ctx.req.session.should.not.have.property('resetToken')
  508. resolve()
  509. }
  510. ctx.req.query.email = 'test@example.com'
  511. ctx.PasswordResetController.renderSetPasswordForm(ctx.req, ctx.res)
  512. })
  513. })
  514. })
  515. })
  516. })
  517. })