Merge pull request #36221 from overleaf/jpa-zod-web-b2c

[web] b2c: adopt strict zod schema and extend test coverage

GitOrigin-RevId: 5c612e3ce83b81cfff7b8f1c5bc4522d6feb665a
This commit is contained in:
Jakob Ackermann
2026-09-17 11:55:30 +00:00
committed by Copybot
parent 784b9c64ec
commit f26a48fc4c
24 changed files with 2038 additions and 179 deletions
@@ -1,4 +1,8 @@
import { beforeEach, describe, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
getRawReqInput,
setReqValidationModeForTests,
} from '@overleaf/validation-tools'
const modulePath = new URL(
'../../../../app/src/Features/Referal/ReferalConnect.mjs',
import.meta.url
@@ -9,6 +13,10 @@ describe('Referal connect middle wear', function () {
ctx.connect = (await import(modulePath)).default
})
afterEach(function () {
setReqValidationModeForTests(null)
})
it('should take a referal query string and put it on the session if it exists', async function (ctx) {
await new Promise(resolve => {
const req = {
@@ -16,7 +24,35 @@ describe('Referal connect middle wear', function () {
session: {},
}
ctx.connect.use(req, {}, () => {
req.session.referal_id.should.equal(req.query.referal)
// case 3: raw test assertion on the fake req fixture built above
req.session.referal_id.should.equal(getRawReqInput(req).query.referal)
resolve()
})
})
})
it('should take the short-hand r referal query param and put it on the session', async function (ctx) {
await new Promise(resolve => {
const req = {
query: { r: '12345' },
session: {},
}
ctx.connect.use(req, {}, () => {
// case 3: raw test assertion on the fake req fixture built above
req.session.referal_id.should.equal(getRawReqInput(req).query.r)
resolve()
})
})
})
it('should tolerate unrelated query params from the request', async function (ctx) {
await new Promise(resolve => {
const req = {
query: { referal: '12345', utm_source: 'newsletter' },
session: {},
}
ctx.connect.use(req, {}, () => {
req.session.referal_id.should.equal('12345')
resolve()
})
})
@@ -42,7 +78,8 @@ describe('Referal connect middle wear', function () {
session: {},
}
ctx.connect.use(req, {}, () => {
req.session.referal_id.should.equal(req.query.fb_ref)
// case 3: raw test assertion on the fake req fixture built above
req.session.referal_id.should.equal(getRawReqInput(req).query.fb_ref)
resolve()
})
})
@@ -151,4 +188,20 @@ describe('Referal connect middle wear', function () {
})
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects a non-string referal query param', function (ctx) {
const req = {
query: { rm: ['fb', 't'] },
session: {},
}
expect(() => ctx.connect.use(req, {}, () => {})).toThrowError(
expect.objectContaining({ name: 'InvalidRequestError' })
)
})
})
})
@@ -1,5 +1,7 @@
import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import sinon from 'sinon'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.mjs'
@@ -374,7 +376,17 @@ describe('SubscriptionController', function () {
ctx.stubbedCurrencyCode = 'GBP'
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('successfulSubscription', function () {
beforeEach(function (ctx) {
// this route only ever reads `upgrade` from the query -- the shared
// `planCode` fixture above belongs to previewSubscription's route.
ctx.req.query = {}
})
it('without a personal subscription', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
@@ -428,10 +440,53 @@ describe('SubscriptionController', function () {
)
})
})
it('sets isUpgrade when the upgrade query param is true', async function (ctx) {
await new Promise(resolve => {
ctx.req.query = { upgrade: 'true' }
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
{
personalSubscription: 'foo',
}
)
ctx.res.render = (url, variables) => {
expect(variables.isUpgrade).to.equal(true)
resolve()
}
ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
})
})
it('tolerates an unrecognized extra query param via the fallback schema', async function (ctx) {
setReqValidationModeForTests('log')
await new Promise(resolve => {
ctx.req.query = { upgrade: 'true', promo: 'ABC' }
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
{
personalSubscription: 'foo',
}
)
ctx.res.render = (url, variables) => {
expect(variables.isUpgrade).to.equal(true)
resolve()
}
ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
})
})
it('rejects a non-boolean-ish upgrade value', async function (ctx) {
ctx.req.query = { upgrade: 'sometimes' }
await expect(
ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
).to.be.rejected
})
})
describe('userSubscriptionPage', function () {
beforeEach(async function (ctx) {
// this route only ever reads `errorCode` from the query -- the shared
// `planCode` fixture above belongs to previewSubscription's route.
ctx.req.query = {}
await new Promise((resolve, reject) => {
ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
{
@@ -536,7 +591,7 @@ describe('SubscriptionController', function () {
describe('when errorCode query param is present', function () {
beforeEach(async function (ctx) {
ctx.req.query.errorCode = 'payment_failed'
ctx.req.query = { errorCode: 'payment_failed' }
await new Promise((resolve, reject) => {
ctx.res.render = (view, data) => {
ctx.data = data
@@ -555,6 +610,54 @@ describe('SubscriptionController', function () {
expect(ctx.data.redirectedPaymentErrorCode).to.equal('payment_failed')
})
})
describe('when an unrecognized query param is present', function () {
beforeEach(async function (ctx) {
setReqValidationModeForTests('log')
ctx.req.query = { errorCode: 'payment_failed', promo: 'ABC' }
await new Promise((resolve, reject) => {
ctx.res.render = (view, data) => {
ctx.data = data
resolve()
}
ctx.SubscriptionController.userSubscriptionPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
})
it('still renders using the raw query under the log-only rollout', function (ctx) {
expect(ctx.data.redirectedPaymentErrorCode).to.equal('payment_failed')
})
})
describe('when hasSubscription query param is present', function () {
it('does not reject the request', async function (ctx) {
ctx.req.query = { hasSubscription: 'true' }
await new Promise((resolve, reject) => {
ctx.res.render = (view, data) => {
expect(view).to.equal('subscriptions/dashboard-react')
resolve()
}
ctx.SubscriptionController.userSubscriptionPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
})
})
describe('when hasSubscription query param is invalid', function () {
it('rejects the request', async function (ctx) {
ctx.req.query = { hasSubscription: 'maybe' }
await expect(
ctx.SubscriptionController.userSubscriptionPage(ctx.req, ctx.res)
).to.be.rejected
})
})
})
describe('updateAccountEmailAddress via put', function () {
@@ -753,6 +856,16 @@ describe('SubscriptionController', function () {
ctx.SubscriptionHandler.promises.pauseSubscription.called
).to.equal(false)
})
it('rejects an unrecognized extra param', async function (ctx) {
ctx.res = new MockResponse(vi)
ctx.req = new MockRequest(vi)
ctx.req.params = { pauseCycles: '3', unexpected: 'field' }
ctx.next = sinon.stub()
await expect(
ctx.SubscriptionController.pauseSubscription(ctx.req, ctx.res, ctx.next)
).to.be.rejectedWith('Invalid request parameters')
})
})
describe('resumeSubscription', function () {
@@ -933,6 +1046,53 @@ describe('SubscriptionController', function () {
})
})
describe('recurlyNotificationParser', function () {
beforeEach(function (ctx) {
ctx.RecurlyWrapper._parseXml = sinon
.stub()
.callsFake((xml, callback) => callback(null, { parsedFrom: xml }))
})
it('parses the streamed XML body and calls next', function (ctx) {
const fakeReq = new EventEmitter()
fakeReq.body = undefined
const next = sinon.stub()
ctx.SubscriptionController.recurlyNotificationParser(
fakeReq,
ctx.res,
next
)
fakeReq.emit('data', '<xml>')
fakeReq.emit('data', 'payload</xml>')
fakeReq.emit('end')
expect(ctx.RecurlyWrapper._parseXml).to.have.been.calledWith(
'<xml>payload</xml>'
)
expect(fakeReq.body).to.deep.equal({ parsedFrom: '<xml>payload</xml>' })
expect(next).to.have.been.calledWith()
})
it('still parses and calls next under the log-only rollout when the body was already parsed', function (ctx) {
setReqValidationModeForTests('log')
const fakeReq = new EventEmitter()
fakeReq.body = { alreadyParsed: true }
const next = sinon.stub()
ctx.SubscriptionController.recurlyNotificationParser(
fakeReq,
ctx.res,
next
)
fakeReq.emit('data', '<xml>event</xml>')
fakeReq.emit('end')
expect(next).to.have.been.calledWith()
expect(fakeReq.body).to.deep.equal({ parsedFrom: '<xml>event</xml>' })
})
})
describe('removeAddon', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(ctx.user)
@@ -997,6 +1157,73 @@ describe('SubscriptionController', function () {
'Cannot remove add-on while there are multiple pending subscription changes. Please contact support.',
})
})
it('rejects an unrecognized extra param', async function (ctx) {
ctx.req.params = { addOnCode: AI_ADD_ON_CODE, unexpected: 'field' }
await expect(
ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)
).to.be.rejectedWith('Invalid request parameters')
expect(ctx.SubscriptionHandler.promises.removeAddon).to.not.have.been
.called
})
})
describe('reactivateAddon', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getSessionUser.returns(ctx.user)
ctx.req.params = { addOnCode: AI_ADD_ON_CODE }
ctx.SubscriptionHandler.promises.reactivateAddon = sinon.stub().resolves()
})
it('should return 200 on successful reactivation of AI add-on', async function (ctx) {
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.reactivateAddon(ctx.req, ctx.res)
expect(
ctx.SubscriptionHandler.promises.reactivateAddon
).to.have.been.calledWith(ctx.user._id, AI_ADD_ON_CODE)
expect(ctx.res.sendStatus).to.have.been.calledWith(200)
})
it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
ctx.req.params = { addOnCode: 'some-other-addon' }
ctx.res.sendStatus = sinon.spy()
await ctx.SubscriptionController.reactivateAddon(ctx.req, ctx.res)
expect(ctx.SubscriptionHandler.promises.reactivateAddon).to.not.have.been
.called
expect(ctx.res.sendStatus).to.have.been.calledWith(404)
})
it('should handle AddOnNotPresentError and send badRequest', async function (ctx) {
ctx.SubscriptionHandler.promises.reactivateAddon.rejects(
new SubscriptionErrors.AddOnNotPresentError()
)
await ctx.SubscriptionController.reactivateAddon(ctx.req, ctx.res)
expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
ctx.req,
ctx.res,
'The requested add-on is not pending cancellation',
{ addon: AI_ADD_ON_CODE }
)
})
it('rejects an unrecognized extra param', async function (ctx) {
ctx.req.params = { addOnCode: AI_ADD_ON_CODE, unexpected: 'field' }
await expect(
ctx.SubscriptionController.reactivateAddon(ctx.req, ctx.res)
).to.be.rejectedWith('Invalid request parameters')
expect(ctx.SubscriptionHandler.promises.reactivateAddon).to.not.have.been
.called
})
})
describe('checkSubscriptionPauseStatus', function () {
@@ -1711,4 +1938,175 @@ describe('SubscriptionController', function () {
)
})
})
describe('refreshUserFeatures', function () {
beforeEach(function (ctx) {
ctx.res.sendStatus = sinon.spy()
})
it('should refresh features for a valid user id', async function (ctx) {
const userId = '507f1f77bcf86cd799439011'
ctx.req.params = { user_id: userId }
await ctx.SubscriptionController.refreshUserFeatures(ctx.req, ctx.res)
expect(
ctx.FeaturesUpdater.promises.refreshFeatures
).to.have.been.calledWith(userId, 'acceptance-test')
expect(ctx.res.sendStatus).to.have.been.calledWith(200)
})
it('tolerates a malformed user id under the log-only rollout', async function (ctx) {
setReqValidationModeForTests('log')
ctx.req.params = { user_id: 'not-a-mongo-id' }
await ctx.SubscriptionController.refreshUserFeatures(ctx.req, ctx.res)
expect(
ctx.FeaturesUpdater.promises.refreshFeatures
).to.have.been.calledWith('not-a-mongo-id', 'acceptance-test')
expect(ctx.res.sendStatus).to.have.been.calledWith(200)
})
})
describe('getRecommendedCurrency', function () {
beforeEach(function (ctx) {
ctx.req.query = {}
})
it('should return the recommended currency using the request ip by default', async function (ctx) {
const result = await ctx.SubscriptionController.getRecommendedCurrency(
ctx.req,
ctx.res
)
expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.have.been.calledWith(
ctx.req.ip
)
expect(result).to.deep.equal({
currency: 'USD',
recommendedCurrency: 'USD',
countryCode: 'US',
})
})
it('should use an admin-supplied ip override', async function (ctx) {
ctx.AuthorizationManager.promises.isUserSiteAdmin.resolves(true)
ctx.req.query = { ip: '1.2.3.4' }
await ctx.SubscriptionController.getRecommendedCurrency(ctx.req, ctx.res)
expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.have.been.calledWith(
'1.2.3.4'
)
})
it('should ignore an ip override from a non-admin user', async function (ctx) {
ctx.AuthorizationManager.promises.isUserSiteAdmin.resolves(false)
ctx.req.query = { ip: '1.2.3.4' }
await ctx.SubscriptionController.getRecommendedCurrency(ctx.req, ctx.res)
expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.have.been.calledWith(
ctx.req.ip
)
})
it('rejects a malformed ip override', async function (ctx) {
ctx.AuthorizationManager.promises.isUserSiteAdmin.resolves(true)
ctx.req.query = { ip: 'not-an-ip' }
await expect(
ctx.SubscriptionController.getRecommendedCurrency(ctx.req, ctx.res)
).to.be.rejected
expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.not.have.been.called
})
it('tolerates a non-string ip query value under the log-only rollout', async function (ctx) {
setReqValidationModeForTests('log')
ctx.AuthorizationManager.promises.isUserSiteAdmin.resolves(true)
ctx.req.query = { ip: ['1.2.3.4', '5.6.7.8'] }
await expect(
ctx.SubscriptionController.getRecommendedCurrency(ctx.req, ctx.res)
).to.not.be.rejected
expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.have.been.called
})
})
describe('getLatamCountryBannerDetails', function () {
beforeEach(function (ctx) {
ctx.req.query = {}
})
it('should return an empty object for a non-LATAM country', async function (ctx) {
const result =
await ctx.SubscriptionController.getLatamCountryBannerDetails(
ctx.req,
ctx.res
)
expect(result).to.deep.equal({})
})
it('should return the Mexico banner details', async function (ctx) {
ctx.GeoIpLookup.promises.getCurrencyCode.resolves({
countryCode: 'MX',
currencyCode: 'MXN',
})
const result =
await ctx.SubscriptionController.getLatamCountryBannerDetails(
ctx.req,
ctx.res
)
expect(result).to.deep.equal({
latamCountryFlag: '🇲🇽',
country: 'Mexico',
discount: '25%',
currency: 'Mexican Pesos',
})
})
it('should use an admin-supplied ip override', async function (ctx) {
ctx.AuthorizationManager.promises.isUserSiteAdmin.resolves(true)
ctx.req.query = { ip: '1.2.3.4' }
await ctx.SubscriptionController.getLatamCountryBannerDetails(
ctx.req,
ctx.res
)
expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.have.been.calledWith(
'1.2.3.4'
)
})
it('rejects a malformed ip override', async function (ctx) {
ctx.AuthorizationManager.promises.isUserSiteAdmin.resolves(true)
ctx.req.query = { ip: 'not-an-ip' }
await expect(
ctx.SubscriptionController.getLatamCountryBannerDetails(
ctx.req,
ctx.res
)
).to.be.rejected
expect(ctx.GeoIpLookup.promises.getCurrencyCode).to.not.have.been.called
})
it('tolerates a non-string ip query value under the log-only rollout', async function (ctx) {
setReqValidationModeForTests('log')
ctx.AuthorizationManager.promises.isUserSiteAdmin.resolves(true)
ctx.req.query = { ip: ['1.2.3.4'] }
await expect(
ctx.SubscriptionController.getLatamCountryBannerDetails(
ctx.req,
ctx.res
)
).to.not.be.rejected
})
})
})
@@ -1,5 +1,9 @@
import { beforeEach, describe, it, vi } from 'vitest'
import sinon from 'sinon'
import {
InvalidParamsError,
InvalidRequestError,
} from '@overleaf/validation-tools'
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionGroupController'
@@ -8,7 +12,7 @@ describe('SubscriptionGroupController', function () {
beforeEach(async function (ctx) {
ctx.user = { _id: '!@312431', email: 'user@email.com' }
ctx.adminUserId = '123jlkj'
ctx.subscriptionId = '123434325412'
ctx.subscriptionId = '123434325412341234123412'
ctx.user_email = 'bob@gmail.com'
ctx.req = {
session: {
@@ -81,6 +85,7 @@ describe('SubscriptionGroupController', function () {
ctx.SubscriptionLocator = {
promises: {
getSubscription: sinon.stub().resolves(ctx.subscription),
getUsersSubscription: sinon.stub().resolves(ctx.subscription),
},
}
@@ -237,8 +242,8 @@ describe('SubscriptionGroupController', function () {
describe('removeUserFromGroup', function () {
it('should use the subscription id for the logged in user and take the user id from the params', async function (ctx) {
await new Promise(resolve => {
const userIdToRemove = '31231'
ctx.req.params = { user_id: userIdToRemove }
const userIdToRemove = '222222222222222222222222'
ctx.req.params = { id: ctx.subscriptionId, user_id: userIdToRemove }
ctx.req.entity = ctx.subscription
const res = {
@@ -258,8 +263,8 @@ describe('SubscriptionGroupController', function () {
it('should log that the user has been removed', async function (ctx) {
await new Promise(resolve => {
const userIdToRemove = '31231'
ctx.req.params = { user_id: userIdToRemove }
const userIdToRemove = '222222222222222222222222'
ctx.req.params = { id: ctx.subscriptionId, user_id: userIdToRemove }
ctx.req.entity = ctx.subscription
const res = {
@@ -281,8 +286,8 @@ describe('SubscriptionGroupController', function () {
it('should unlink the user from group SSO when they are linked, even if SSO is disabled', async function (ctx) {
await new Promise(resolve => {
const userIdToRemove = '31231'
ctx.req.params = { user_id: userIdToRemove }
const userIdToRemove = '222222222222222222222222'
ctx.req.params = { id: ctx.subscriptionId, user_id: userIdToRemove }
ctx.req.entity = ctx.subscription
ctx.UserGetter.promises.getUser.resolves({
_id: userIdToRemove,
@@ -307,8 +312,8 @@ describe('SubscriptionGroupController', function () {
it('should not unlink the user when they have no group SSO link', async function (ctx) {
await new Promise(resolve => {
const userIdToRemove = '31231'
ctx.req.params = { user_id: userIdToRemove }
const userIdToRemove = '222222222222222222222222'
ctx.req.params = { id: ctx.subscriptionId, user_id: userIdToRemove }
ctx.req.entity = ctx.subscription
ctx.UserGetter.promises.getUser.resolves({
_id: userIdToRemove,
@@ -326,6 +331,18 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.removeUserFromGroup(ctx.req, res, resolve)
})
})
it('rejects a malformed user id', async function (ctx) {
ctx.req.params = { id: ctx.subscriptionId, user_id: 'not-an-object-id' }
ctx.req.entity = ctx.subscription
await ctx.Controller.removeUserFromGroup(
ctx.req,
{}
).should.be.rejectedWith(InvalidParamsError)
ctx.SubscriptionGroupHandler.promises.removeUserFromGroup.called.should.equal(
false
)
})
})
describe('removeSelfFromGroup', function () {
@@ -427,6 +444,17 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.removeSelfFromGroup(ctx.req, res, resolve)
})
})
it('rejects a malformed subscriptionId', async function (ctx) {
ctx.req.query = { subscriptionId: 'not-an-object-id' }
await ctx.Controller.removeSelfFromGroup(
ctx.req,
{}
).should.be.rejectedWith(InvalidRequestError)
ctx.SubscriptionLocator.promises.getSubscription.called.should.equal(
false
)
})
})
describe('addSeatsToGroupSubscription', function () {
@@ -571,6 +599,17 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.addSeatsToGroupSubscription(ctx.req, res)
})
})
it('rejects an invalid query value', async function (ctx) {
ctx.req.query = { errorCode: { foo: 'bar' } }
await ctx.Controller.addSeatsToGroupSubscription(
ctx.req,
{}
).should.be.rejectedWith(InvalidRequestError)
ctx.SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails.called.should.equal(
false
)
})
})
describe('previewAddSeatsSubscriptionChange', function () {
@@ -638,6 +677,17 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.previewAddSeatsSubscriptionChange(ctx.req, res)
})
})
it('rejects adding fewer than one seat', async function (ctx) {
ctx.req.body = { adding: 0 }
await ctx.Controller.previewAddSeatsSubscriptionChange(
ctx.req,
{}
).should.be.rejectedWith(InvalidRequestError)
ctx.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange.called.should.equal(
false
)
})
})
describe('createAddSeatsSubscriptionChange', function () {
@@ -661,6 +711,7 @@ describe('SubscriptionGroupController', function () {
it('should fail applying "add seats" change', async function (ctx) {
await new Promise(resolve => {
ctx.req.body = { adding: 2 }
ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
sinon.stub().rejects()
@@ -759,6 +810,17 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.createAddSeatsSubscriptionChange(ctx.req, res)
})
})
it('rejects adding fewer than one seat', async function (ctx) {
ctx.req.body = { adding: 0 }
await ctx.Controller.createAddSeatsSubscriptionChange(
ctx.req,
{}
).should.be.rejectedWith(InvalidRequestError)
ctx.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange.called.should.equal(
false
)
})
})
describe('submitForm', function () {
@@ -828,6 +890,14 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.submitForm(ctx.req, res, resolve)
})
})
it('rejects adding fewer than the minimum increase', async function (ctx) {
ctx.req.body = { adding: 5 }
await ctx.Controller.submitForm(ctx.req, {}).should.be.rejectedWith(
InvalidRequestError
)
ctx.Modules.promises.hooks.fire.called.should.equal(false)
})
})
describe('subscriptionUpgradePage', function () {
@@ -928,6 +998,17 @@ describe('SubscriptionGroupController', function () {
ctx.Controller.subscriptionUpgradePage(ctx.req, res)
})
})
it('rejects an invalid query value', async function (ctx) {
ctx.req.query = { errorCode: { foo: 'bar' } }
await ctx.Controller.subscriptionUpgradePage(
ctx.req,
{}
).should.be.rejectedWith(InvalidRequestError)
ctx.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview.called.should.equal(
false
)
})
})
describe('upgradeSubscription', function () {
@@ -1019,4 +1100,109 @@ describe('SubscriptionGroupController', function () {
})
})
})
describe('manuallyCollectedSubscription', function () {
it('should render the "manually collected subscription" page', async function (ctx) {
await new Promise(resolve => {
ctx.req.query = { error_type: 'no-additional-license' }
const res = {
render: (page, data) => {
ctx.SubscriptionLocator.promises.getUsersSubscription
.calledWith(ctx.req.session.user._id)
.should.equal(true)
page.should.equal('subscriptions/manually-collected-subscription')
data.groupName.should.equal(ctx.subscription.teamName)
data.errorType.should.equal('no-additional-license')
resolve()
},
}
ctx.Controller.manuallyCollectedSubscription(ctx.req, res)
})
})
it('should redirect to subscription page when loading the subscription fails', async function (ctx) {
await new Promise(resolve => {
ctx.SubscriptionLocator.promises.getUsersSubscription = sinon
.stub()
.rejects()
const res = {
redirect: url => {
url.should.equal('/user/subscription')
resolve()
},
}
ctx.Controller.manuallyCollectedSubscription(ctx.req, res)
})
})
it('rejects an invalid query value', async function (ctx) {
ctx.req.query = { error_type: { foo: 'bar' } }
await ctx.Controller.manuallyCollectedSubscription(
ctx.req,
{}
).should.be.rejectedWith(InvalidRequestError)
ctx.SubscriptionLocator.promises.getUsersSubscription.called.should.equal(
false
)
})
})
describe('getGroupPlanPerUserPrices', function () {
it('should return the per-user prices for the requested currency', async function (ctx) {
await new Promise(resolve => {
ctx.req.query = { currency: 'USD' }
const prices = [{ USD: { collaborator: 1500 } }]
ctx.Modules.promises.hooks.fire
.withArgs(
'getGroupPlanPerUserPrices',
ctx.req.session.user._id,
'USD'
)
.resolves(prices)
const res = {
json: data => {
data.should.deep.equal(prices[0])
resolve()
},
}
ctx.Controller.getGroupPlanPerUserPrices(ctx.req, res)
})
})
it('should send a 500 response when the hook fails', async function (ctx) {
await new Promise(resolve => {
ctx.Modules.promises.hooks.fire
.withArgs(
'getGroupPlanPerUserPrices',
sinon.match.any,
sinon.match.any
)
.rejects()
const res = {
sendStatus: code => {
code.should.equal(500)
resolve()
},
}
ctx.Controller.getGroupPlanPerUserPrices(ctx.req, res)
})
})
it('rejects an invalid query value', async function (ctx) {
ctx.req.query = { currency: { foo: 'bar' } }
await ctx.Controller.getGroupPlanPerUserPrices(
ctx.req,
{}
).should.be.rejectedWith(InvalidRequestError)
ctx.Modules.promises.hooks.fire.called.should.equal(false)
})
})
})
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import {
InvalidParamsError,
InvalidRequestError,
setReqValidationModeForTests,
} from '@overleaf/validation-tools'
import MockResponse from '../helpers/MockResponse.mjs'
const modulePath =
@@ -146,6 +151,10 @@ describe('TeamInvitesController', function () {
ctx.Controller = (await import(modulePath)).default
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('acceptInvite', function () {
it('should add an audit log entry', async function (ctx) {
await new Promise(resolve => {
@@ -242,7 +251,7 @@ describe('TeamInvitesController', function () {
},
}
ctx.Controller.viewInvite(
{ params: { token: 'token123' }, session: {} },
{ params: { token: 'token123' }, query: {}, session: {} },
res
)
})
@@ -258,7 +267,7 @@ describe('TeamInvitesController', function () {
},
}
ctx.Controller.viewInvite(
{ params: { token: 'token123' }, session: {} },
{ params: { token: 'token123' }, query: {}, session: {} },
res
)
})
@@ -364,4 +373,57 @@ describe('TeamInvitesController', function () {
})
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('createInvite rejects an unrecognized field in the body', async function (ctx) {
ctx.req.body = { email: 'invited@example.com', notAField: 'nope' }
await new Promise(resolve => {
ctx.Controller.createInvite(ctx.req, new MockResponse(vi), err => {
expect(err).to.be.instanceof(InvalidRequestError)
resolve()
})
})
})
it('viewInvite rejects a request missing the token param', async function (ctx) {
ctx.req.params = {}
await new Promise(resolve => {
ctx.Controller.viewInvite(ctx.req, new MockResponse(vi), err => {
expect(err).to.be.instanceof(InvalidParamsError)
resolve()
})
})
})
it('acceptInvite rejects a request missing the token param', async function (ctx) {
ctx.req.params = {}
await new Promise(resolve => {
ctx.Controller.acceptInvite(ctx.req, new MockResponse(vi), err => {
expect(err).to.be.instanceof(InvalidParamsError)
resolve()
})
})
})
it('revokeInvite rejects a malformed group id param', function (ctx) {
ctx.req.params = { id: 'not-an-object-id', email: 'user@example.com' }
expect(() =>
ctx.Controller.revokeInvite(ctx.req, new MockResponse(vi), sinon.stub())
).to.throw(InvalidParamsError)
})
it('resendInvite rejects a non-string email in the body', async function (ctx) {
ctx.req.body = { email: 12345 }
await new Promise(resolve => {
ctx.Controller.resendInvite(ctx.req, new MockResponse(vi), err => {
expect(err).to.be.instanceof(InvalidRequestError)
resolve()
})
})
})
})
})
@@ -49,7 +49,7 @@ describe('TemplatesController', function () {
ctx.next = sinon.stub()
ctx.req = {
body: {
brandVariationId: 'brand-variation-id',
brandVariationId: '789',
compiler: 'compiler',
mainFile: 'main-file',
templateId: '123',
@@ -86,7 +86,7 @@ describe('TemplatesController', function () {
it('should call TemplatesManager', function (ctx) {
return ctx.TemplatesManager.promises.createProjectFromV1Template.should.have.been.calledWithMatch(
'brand-variation-id',
789,
'compiler',
'main-file',
'123',
@@ -145,6 +145,19 @@ describe('TemplatesController', function () {
ctx.TemplatesManager.promises.createProjectFromV1Template.should.not
.have.been.called
})
it('should reject a path-traversal-shaped brandVariationId', async function (ctx) {
ctx.req.body.brandVariationId = '1/../../v1/x'
await ctx.TemplatesController.createProjectFromV1Template(
ctx.req,
ctx.res,
ctx.next
)
ctx.next.should.have.been.calledWithMatch(sinon.match.instanceOf(Error))
ctx.TemplatesManager.promises.createProjectFromV1Template.should.not
.have.been.called
ctx.res.redirect.called.should.equal(false)
})
})
describe('on error', function () {
@@ -218,5 +231,12 @@ describe('TemplatesController', function () {
ctx.next.should.have.been.calledWithMatch(sinon.match.instanceOf(Error))
ctx.res.render.called.should.equal(false)
})
it('should reject a path-traversal-shaped brandVariationId query param', async function (ctx) {
ctx.req.query.brandVariationId = '1/../../v1/x'
await ctx.TemplatesController.getV1Template(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledWithMatch(sinon.match.instanceOf(Error))
ctx.res.render.called.should.equal(false)
})
})
})
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
const modulePath =
'../../../../app/src/Features/Tutorial/TutorialController.mjs'
describe('TutorialController', function () {
const userId = 'user-id-123'
const validKey = 'workbench-consent'
beforeEach(async function (ctx) {
ctx.TutorialHandler = {
setTutorialState: sinon.stub().resolves(),
}
ctx.SessionManager = {
getLoggedInUserId: sinon.stub().returns(userId),
}
vi.doMock('../../../../app/src/Features/Tutorial/TutorialHandler', () => ({
default: ctx.TutorialHandler,
}))
vi.doMock(
'../../../../app/src/Features/Authentication/SessionManager',
() => ({
default: ctx.SessionManager,
})
)
ctx.controller = (await import(modulePath)).default
ctx.req = new MockRequest(vi)
ctx.res = new MockResponse(vi)
ctx.next = sinon.stub()
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('completeTutorial', function () {
it('should mark the tutorial as completed', async function (ctx) {
ctx.req.params = { tutorialKey: validKey }
await ctx.controller.completeTutorial(ctx.req, ctx.res, ctx.next)
expect(
ctx.TutorialHandler.setTutorialState.calledWith(
userId,
validKey,
'completed'
)
).to.equal(true)
expect(ctx.res.sendStatus).toHaveBeenCalledWith(204)
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('should reject an unrecognized tutorial key with 404', async function (ctx) {
ctx.req.params = { tutorialKey: 'not-a-real-tutorial' }
await ctx.controller.completeTutorial(ctx.req, ctx.res, ctx.next)
expect(ctx.TutorialHandler.setTutorialState.called).to.equal(false)
expect(ctx.next.calledOnce).to.equal(true)
expect(ctx.next.firstCall.args[0].name).to.equal('InvalidParamsError')
})
})
describe('with the schema in log-only mode', function () {
beforeEach(function () {
setReqValidationModeForTests('log')
})
it('should still reject an unrecognized tutorial key with 404', async function (ctx) {
ctx.req.params = { tutorialKey: 'not-a-real-tutorial' }
await ctx.controller.completeTutorial(ctx.req, ctx.res, ctx.next)
expect(ctx.TutorialHandler.setTutorialState.called).to.equal(false)
expect(ctx.res.sendStatus).toHaveBeenCalledWith(404)
})
})
})
describe('postponeTutorial', function () {
it('should postpone the tutorial with no postponedUntil', async function (ctx) {
ctx.req.params = { tutorialKey: validKey }
ctx.req.body = {}
await ctx.controller.postponeTutorial(ctx.req, ctx.res, ctx.next)
expect(
ctx.TutorialHandler.setTutorialState.calledWith(
userId,
validKey,
'postponed',
undefined
)
).to.equal(true)
expect(ctx.res.sendStatus).toHaveBeenCalledWith(204)
})
it('should postpone the tutorial with a postponedUntil date', async function (ctx) {
ctx.req.params = { tutorialKey: validKey }
ctx.req.body = { postponedUntil: '2026-08-01T00:00:00.000Z' }
await ctx.controller.postponeTutorial(ctx.req, ctx.res, ctx.next)
const call = ctx.TutorialHandler.setTutorialState.lastCall
expect(call.args[0]).to.equal(userId)
expect(call.args[1]).to.equal(validKey)
expect(call.args[2]).to.equal('postponed')
expect(call.args[3]).to.be.an.instanceof(Date)
expect(call.args[3].toISOString()).to.equal('2026-08-01T00:00:00.000Z')
expect(ctx.res.sendStatus).toHaveBeenCalledWith(204)
})
it('tolerates a non-ISO postponedUntil value via the fallback schema', async function (ctx) {
setReqValidationModeForTests('log')
ctx.req.params = { tutorialKey: validKey }
ctx.req.body = { postponedUntil: '2026/08/01' }
await ctx.controller.postponeTutorial(ctx.req, ctx.res, ctx.next)
const call = ctx.TutorialHandler.setTutorialState.lastCall
expect(call.args[1]).to.equal(validKey)
expect(call.args[2]).to.equal('postponed')
expect(call.args[3]).to.be.an.instanceof(Date)
expect(ctx.res.sendStatus).toHaveBeenCalledWith(204)
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('should reject an unrecognized tutorial key with 404', async function (ctx) {
ctx.req.params = { tutorialKey: 'not-a-real-tutorial' }
ctx.req.body = {}
await ctx.controller.postponeTutorial(ctx.req, ctx.res, ctx.next)
expect(ctx.TutorialHandler.setTutorialState.called).to.equal(false)
expect(ctx.next.calledOnce).to.equal(true)
})
it('should reject a malformed postponedUntil value', async function (ctx) {
ctx.req.params = { tutorialKey: validKey }
ctx.req.body = { postponedUntil: 'not-a-date' }
await ctx.controller.postponeTutorial(ctx.req, ctx.res, ctx.next)
expect(ctx.TutorialHandler.setTutorialState.called).to.equal(false)
expect(ctx.next.calledOnce).to.equal(true)
})
})
describe('with the schema in log-only mode', function () {
beforeEach(function () {
setReqValidationModeForTests('log')
})
it('should still reject an unrecognized tutorial key with 404', async function (ctx) {
ctx.req.params = { tutorialKey: 'not-a-real-tutorial' }
ctx.req.body = {}
await ctx.controller.postponeTutorial(ctx.req, ctx.res, ctx.next)
expect(ctx.TutorialHandler.setTutorialState.called).to.equal(false)
expect(ctx.res.sendStatus).toHaveBeenCalledWith(404)
})
})
})
})
@@ -149,6 +149,28 @@ describe('ThirdPartyIdentityManager', function () {
expect(ctx.User.findOneAndUpdate).to.not.have.been.called
})
it('should reject providerId values inherited from Object.prototype', async function (ctx) {
await expect(
ctx.ThirdPartyIdentityManager.promises.link(
ctx.userId,
'__proto__',
ctx.externalUserId,
ctx.externalData,
ctx.auditLog
)
).to.be.rejectedWith('Not a valid provider')
await expect(
ctx.ThirdPartyIdentityManager.promises.link(
ctx.userId,
'constructor',
ctx.externalUserId,
ctx.externalData,
ctx.auditLog
)
).to.be.rejectedWith('Not a valid provider')
expect(ctx.User.findOneAndUpdate).to.not.have.been.called
})
describe('EmailHandler', function () {
beforeEach(function (ctx) {
ctx.EmailHandler.promises.sendEmail.rejects(anError)
@@ -224,6 +246,24 @@ describe('ThirdPartyIdentityManager', function () {
expect(ctx.User.findOneAndUpdate).to.not.have.been.called
})
it('should reject providerId values inherited from Object.prototype', async function (ctx) {
await expect(
ctx.ThirdPartyIdentityManager.promises.unlink(
ctx.userId,
'__proto__',
ctx.auditLog
)
).to.be.rejectedWith('Not a valid provider')
await expect(
ctx.ThirdPartyIdentityManager.promises.unlink(
ctx.userId,
'constructor',
ctx.auditLog
)
).to.be.rejectedWith('Not a valid provider')
expect(ctx.User.findOneAndUpdate).to.not.have.been.called
})
describe('EmailHandler', function () {
beforeEach(function (ctx) {
ctx.EmailHandler.promises.sendEmail.rejects(anError)
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import OError from '@overleaf/o-error'
import {
InvalidParamsError,
InvalidRequestError,
setReqValidationModeForTests,
} from '@overleaf/validation-tools'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
const modulePath = '../../../../app/src/Features/User/UserController.mjs'
@@ -42,7 +47,12 @@ describe('UserController', function () {
},
}
ctx.UserDeleter = { promises: { deleteUser: sinon.stub().resolves() } }
ctx.UserDeleter = {
promises: {
deleteUser: sinon.stub().resolves(),
expireDeletedUser: sinon.stub().resolves(),
},
}
ctx.UserGetter = {
promises: { getUser: sinon.stub().resolves(ctx.user) },
@@ -250,6 +260,10 @@ describe('UserController', function () {
ctx.callback = sinon.stub()
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('tryDeleteUser', function () {
beforeEach(function (ctx) {
ctx.req.body.password = 'wat'
@@ -435,6 +449,22 @@ describe('UserController', function () {
})
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects an unrecognized body field', async function (ctx) {
ctx.req.body.extraField = 'nope'
await ctx.UserController.tryDeleteUser(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidRequestError)
expect(ctx.AuthenticationManager.promises.authenticate).to.not.have.been
.called
})
})
})
describe('updateUserSettings', function () {
@@ -830,6 +860,29 @@ describe('UserController', function () {
})
})
})
describe('request validation', function () {
it('rejects a non-string first_name', async function (ctx) {
ctx.req.body = { first_name: 12345 }
await ctx.UserController.updateUserSettings(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidRequestError)
expect(ctx.user.save).to.not.have.been.called
})
it('tolerates an unrecognized body field via the fallback schema', function (ctx) {
setReqValidationModeForTests('log')
return new Promise(resolve => {
ctx.req.body = { someNewSetting: 'x' }
ctx.res.sendStatus = code => {
code.should.equal(200)
resolve()
}
ctx.UserController.updateUserSettings(ctx.req, ctx.res)
})
})
})
})
describe('logout', function () {
@@ -918,6 +971,20 @@ describe('UserController', function () {
ctx.UserController.logout(ctx.req, ctx.res)
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects an unrecognized body field', async function (ctx) {
ctx.req.body.extraField = 'nope'
await ctx.UserController.logout(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidRequestError)
})
})
})
describe('clearSessions', function () {
@@ -1244,6 +1311,26 @@ describe('UserController', function () {
})
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects an unrecognized body field', async function (ctx) {
ctx.req.body = {
newPassword1: 'newpass',
newPassword2: 'newpass',
extraField: 'nope',
}
await ctx.UserController.changePassword(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidRequestError)
expect(ctx.AuthenticationManager.promises.authenticate).to.not.have.been
.called
})
})
})
describe('ensureAffiliationMiddleware', function () {
@@ -1456,5 +1543,59 @@ describe('UserController', function () {
expect(ctx.next).to.be.calledWith(sinon.match.instanceOf(Error))
})
})
describe('request validation', function () {
beforeEach(function (ctx) {
ctx.user.emails = []
ctx.Features.hasFeature.withArgs('affiliations').returns(true)
ctx.req.query = { ensureAffiliation: true, extraField: 'nope' }
})
it('tolerates an unrecognized query field via the fallback schema', async function (ctx) {
setReqValidationModeForTests('log')
await ctx.UserController.ensureAffiliationMiddleware(
ctx.req,
ctx.res,
ctx.next
)
expect(ctx.next).to.have.been.calledWith()
expect(ctx.UserGetter.promises.getUser).to.have.been.called
})
})
})
describe('expireDeletedUser', function () {
beforeEach(function (ctx) {
ctx.req.params = { userId: '507f191e810c19729de860ea' }
})
it('should expire the deleted user and return 204', function (ctx) {
return new Promise(resolve => {
ctx.res.sendStatus = code => {
expect(code).to.equal(204)
expect(
ctx.UserDeleter.promises.expireDeletedUser
).to.have.been.calledWith('507f191e810c19729de860ea')
resolve()
}
ctx.UserController.expireDeletedUser(ctx.req, ctx.res, ctx.next)
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects a malformed userId', async function (ctx) {
ctx.req.params = { userId: 'not-an-object-id' }
await ctx.UserController.expireDeletedUser(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.UserDeleter.promises.expireDeletedUser).to.not.have.been
.called
})
})
})
})
@@ -967,4 +967,24 @@ describe('UserEmailsController', function () {
)
})
})
describe('showConfirm', function () {
it('renders the confirm email page with the token from the query string', async function (ctx) {
ctx.req.query = { token: 'mock-confirm-token' }
await ctx.UserEmailsController.showConfirm(ctx.req, ctx.res)
expect(ctx.res.renderedTemplate).to.equal('user/confirm_email')
expect(ctx.res.renderedVariables).to.deep.equal({
token: 'mock-confirm-token',
title: 'confirm_email',
})
})
it('renders with an undefined token when none is supplied', async function (ctx) {
await ctx.UserEmailsController.showConfirm(ctx.req, ctx.res)
expect(ctx.res.renderedVariables).to.deep.equal({
token: undefined,
title: 'confirm_email',
})
})
})
})
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
import MockResponse from '../helpers/MockResponse.mjs'
import MockRequest from '../helpers/MockRequest.mjs'
import mongodb from 'mongodb-legacy'
@@ -50,6 +51,10 @@ describe('UserInfoController', function () {
ctx.next = sinon.stub()
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('getPersonalInfo', function () {
describe('when the user exists with mongo id', function () {
beforeEach(function (ctx) {
@@ -113,11 +118,24 @@ describe('UserInfoController', function () {
ctx.user_id = 'invalid'
ctx.req.params = { user_id: ctx.user_id }
ctx.UserGetter.getUser = sinon.stub().callsArgWith(2, null, null)
ctx.UserInfoController.getPersonalInfo(ctx.req, ctx.res, ctx.next)
})
it('should return 400 to the client', function (ctx) {
it('should throw an InvalidParamsError (404 via handleValidationError)', function (ctx) {
setReqValidationModeForTests('enforce')
expect(() =>
ctx.UserInfoController.getPersonalInfo(ctx.req, ctx.res, ctx.next)
).to.throw()
ctx.UserGetter.getUser.called.should.equal(false)
})
it('should return 400 without validation enforcement', function (ctx) {
setReqValidationModeForTests('log')
expect(() =>
ctx.UserInfoController.getPersonalInfo(ctx.req, ctx.res, ctx.next)
).to.not.throw()
ctx.res.statusCode.should.equal(400)
ctx.UserGetter.getUser.called.should.equal(false)
ctx.next.called.should.equal(false)
})
})
})
@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import assert from 'node:assert'
import sinon from 'sinon'
import {
InvalidRequestError,
setReqValidationModeForTests,
} from '@overleaf/validation-tools'
import MockResponse from '../helpers/MockResponse.mjs'
import MockRequest from '../helpers/MockRequest.mjs'
@@ -150,6 +154,10 @@ describe('UserPagesController', function () {
ctx.res = new MockResponse(vi)
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('registerPage', function () {
it('should render the register page', async function (ctx) {
await new Promise((resolve, reject) => {
@@ -240,12 +248,14 @@ describe('UserPagesController', function () {
})
describe('when an explicit redirect is set via query string', function () {
const redir = '/somewhere/in/particular'
beforeEach(function (ctx) {
ctx.AuthenticationController.getRedirectFromSession = sinon
.stub()
.returns(null)
ctx.AuthenticationController.setRedirectInSession = sinon.stub()
ctx.req.query.redir = '/somewhere/in/particular'
ctx.req.query = { redir }
})
it('should set a redirect', async function (ctx) {
@@ -256,7 +266,7 @@ describe('UserPagesController', function () {
)
expect(
ctx.AuthenticationController.setRedirectInSession.lastCall.args[1]
).to.equal(ctx.req.query.redir)
).to.equal(redir)
resolve()
}
ctx.UserPagesController.loginPage(
@@ -267,6 +277,44 @@ describe('UserPagesController', function () {
})
})
})
describe('request validation', function () {
beforeEach(function (ctx) {
// a bracketed query param (e.g. ?redir[foo]=bar) parses to a nested
// object, which the query schema (string | string[] values only)
// rejects
ctx.req.query = { redir: { foo: 'bar' } }
ctx.AuthenticationController.getRedirectFromSession = sinon
.stub()
.returns(null)
})
it('should still process the request in log mode', async function (ctx) {
setReqValidationModeForTests('log')
await new Promise((resolve, reject) => {
ctx.res.callback = () => {
expect(
ctx.AuthenticationController.setRedirectInSession
).to.have.been.calledWith(ctx.req, { foo: 'bar' })
resolve()
}
ctx.UserPagesController.loginPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
})
it('should reject the request when enforced', function (ctx) {
setReqValidationModeForTests('enforce')
expect(() =>
ctx.UserPagesController.loginPage(ctx.req, ctx.res)
).to.throw(InvalidRequestError)
expect(ctx.AuthenticationController.setRedirectInSession).to.not.have
.been.called
})
})
})
describe('sessionsPage', function () {
@@ -603,5 +651,60 @@ describe('UserPagesController', function () {
})
})
})
describe('request validation', function () {
beforeEach(function (ctx) {
// an unrecognized query param (e.g. a tracking param on an old
// link) is rejected by the strict query schema
ctx.req.query = {
remove: 'someone@example.com',
utm_source: 'newsletter',
}
})
it('should still process the request in log mode', async function (ctx) {
setReqValidationModeForTests('log')
await new Promise((resolve, reject) => {
ctx.res.callback = () => {
ctx.res.renderedVariables.reconfirmationRemoveEmail.should.equal(
'someone@example.com'
)
resolve()
}
ctx.UserPagesController.settingsPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
})
it('should reject an invalid remove query value', async function (ctx) {
setReqValidationModeForTests('enforce')
ctx.req.query = { remove: { foo: 'bar' } }
await ctx.UserPagesController.settingsPage(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidRequestError)
expect(ctx.UserGetter.promises.getUser).to.not.have.been.called
})
})
describe('with an oauth-complete query param', function () {
it('accepts any string value', async function (ctx) {
ctx.req.query = { 'oauth-complete': 'github' }
await new Promise((resolve, reject) => {
ctx.res.callback = () => {
ctx.res.renderedTemplate.should.equal('user/settings')
resolve()
}
ctx.UserPagesController.settingsPage(
ctx.req,
ctx.res,
ctx.rejectOnError(reject)
)
})
})
})
})
})