PasswordResetController.test.mjs 18 KB

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