mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
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:
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user