Files
Verso/services/web/test/unit/src/User/UserEmailsConfirmationHandler.test.mjs
T
Antoine Clausse 3140e46e68 [web] Replace token-link email verification with 6-digit code on SSO registration (ORCID) (#33889)
* Replace token-link email with 6-digit code on SSO registration

Unverified SSO emails previously received a long-lived token link
(90-day TTL) via UserEmailsConfirmationHandler. This replaces that
flow with the same 6-digit code verification used for password
registration, redirecting through /registration/confirm-email.

- SSOManager.registerSSO now always confirms email (caller must
  verify first); removes sendConfirmationEmail / _finishRegistration
- SSOController._signUp sends confirmation code and stores
  pendingSSORegistration in session when IdP email_verified is false
- New SSOConfirmEmailHandler completes registration after code check
  via completeSSOEmailConfirmation module hook
- OnboardingController confirm-email handlers accept
  pendingSSORegistration alongside pendingUserRegistration

confirmEmailFromToken (POST /user/emails/confirm) removal is deferred
to a follow-up PR to avoid breaking in-flight 90-day tokens.

Closes #28607

* Fix unverified-email edge cases; Add ORCID e2e tests;

* Rename `confirmEmail` parameter to `emailVerifiedByIdP` in _signUp function

* Remove `sendConfirmationEmail`

* Mock getUserByAnyEmail in tests

* Extract _finishSSORegistration helper to deduplicate the register →
set session flags → allocate referral → finishSaasLogin → finishLogin
sequence shared by both the direct and deferred (code-confirmed) paths.

* Stop duplicating session data in pendingSSORegistration

analyticsId, splitTests, and referal_* are already in the session at
confirmation time — no need to copy them into pendingSSORegistration.
Re-fetch splitTests fresh on completion instead.

* Simplify the code

* Remove dead confirmEmail template

No callers remain after sendConfirmationEmail was deleted. The token-link
flow (confirmEmailFromToken) only validates tokens, never sends email.

* Remove dead reconfirmEmail template

* Address comments from Copilot

* Clear stale pending registration when starting a new flow

* Add unit tests for completeSSOEmailConfirmation

* Add `verificationMethod` param

* Fix camelcase issues

* Extract _createSSOUser and _registerAndFinish helpers to deduplicate registration logic

* Remove obscure "registration_error"

* Prevent FormTextIcon from shrinking

* Enable "email_already_registered_sso" error

* Misc. improvements to confirm-email-form.tsx

* Remove `UserEmailsConfirmationHandler` mock

Co-authored-by: Olzhas Askar <olzhas.askar@overleaf.com>

* Add info on sso_email.pug page

---------

Co-authored-by: Olzhas Askar <olzhas.askar@overleaf.com>
GitOrigin-RevId: d0196ebc6d81ff61bcd27726d0b899b743d08d64
2026-06-05 08:06:34 +00:00

219 lines
6.7 KiB
JavaScript

// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { vi, expect } from 'vitest'
import sinon from 'sinon'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import EmailHelper from '../../../../app/src/Features/Helpers/EmailHelper.mjs'
const modulePath =
'../../../../app/src/Features/User/UserEmailsConfirmationHandler'
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('UserEmailsConfirmationHandler', function () {
beforeEach(async function (ctx) {
ctx.mockUser = {
_id: 'mock-user-id',
email: 'mock@example.com',
emails: [{ email: 'mock@example.com' }],
}
ctx.user_id = ctx.mockUser._id
ctx.email = ctx.mockUser.email
ctx.req = {}
vi.doMock(
'../../../../app/src/Features/Security/OneTimeTokenHandler',
() => ({
default: (ctx.OneTimeTokenHandler = {
promises: {},
}),
})
)
vi.doMock('../../../../app/src/Features/User/UserUpdater', () => ({
default: (ctx.UserUpdater = {
promises: {},
}),
}))
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
default: (ctx.UserGetter = {
getUser: sinon.stub().yields(null, ctx.mockUser),
promises: {
getUser: sinon.stub().resolves(ctx.mockUser),
},
}),
}))
vi.doMock('../../../../app/src/Features/Email/EmailHandler', () => ({
default: (ctx.EmailHandler = {
promises: {},
}),
}))
vi.doMock('../../../../app/src/Features/Helpers/EmailHelper', () => ({
default: EmailHelper,
}))
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
default: (ctx.SessionManager = {
getLoggedInUserId: sinon.stub().returns(ctx.mockUser._id),
}),
})
)
ctx.UserEmailsConfirmationHandler = (await import(modulePath)).default
return (ctx.callback = sinon.stub())
})
describe('confirmEmailFromToken', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { user_id: ctx.user_id, email: ctx.email } })
ctx.OneTimeTokenHandler.promises.expireToken = sinon.stub().resolves()
ctx.UserUpdater.promises.confirmEmail = sinon.stub().resolves()
})
describe('successfully', function () {
beforeEach(async function (ctx) {
await ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
})
it('should call peekValueFromToken', function (ctx) {
return ctx.OneTimeTokenHandler.promises.peekValueFromToken
.calledWith('email_confirmation', ctx.token)
.should.equal(true)
})
it('should call expireToken', function (ctx) {
return ctx.OneTimeTokenHandler.promises.expireToken
.calledWith('email_confirmation', ctx.token)
.should.equal(true)
})
it('should confirm the email of the user_id', function (ctx) {
return ctx.UserUpdater.promises.confirmEmail
.calledWith(ctx.user_id, ctx.email)
.should.equal(true)
})
})
describe('with an expired token', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.rejects(new Errors.NotFoundError('no token found'))
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no user_id in the token', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { email: ctx.email } })
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no email in the token', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({ data: { user_id: ctx.user_id } })
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with no user found', function () {
beforeEach(function (ctx) {
ctx.UserGetter.promises.getUser.resolves(null)
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('with secondary email missing on user', function () {
beforeEach(function (ctx) {
ctx.OneTimeTokenHandler.promises.peekValueFromToken = sinon
.stub()
.resolves({
data: { user_id: ctx.user_id, email: 'deleted@email.com' },
})
})
it('should reject with a NotFoundError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.NotFoundError)
})
})
describe('when the logged in user is not the token user', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getLoggedInUserId = sinon
.stub()
.returns('other-user-id')
})
it('should reject with a ForbiddenError', async function (ctx) {
await expect(
ctx.UserEmailsConfirmationHandler.promises.confirmEmailFromToken(
ctx.req,
(ctx.token = 'mock-token')
)
).to.be.rejectedWith(Errors.ForbiddenError)
})
})
})
})