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,49 +1,68 @@
import { z, parseReq } from '../../infrastructure/Validation.mjs'
// Mounted globally ahead of every route's own schema (see
// `webRouter.use(ReferalConnect.use)` in infrastructure/Server.mjs), so this
// stays a non-strict, best-effort read of a handful of short referral/
// affiliate tracking query params -- rejecting/stripping fields that belong
// to the route's own schema isn't this middleware's job. `rm`/`rs` values
// outside the switch statements below are silently tolerated (left
// unmapped), so those stay bare optional strings rather than z.enum(...).
const referalQuerySchema = z.object({
query: z.object({
referal: z.string().optional(),
r: z.string().optional(),
fb_ref: z.string().optional(),
rm: z.string().optional(),
rs: z.string().optional(),
}),
})
export default {
use(req, res, next) {
if (req.query != null) {
if (req.query.referal != null) {
req.session.referal_id = req.query.referal
} else if (req.query.r != null) {
// Short hand for referal
req.session.referal_id = req.query.r
} else if (req.query.fb_ref != null) {
req.session.referal_id = req.query.fb_ref
}
const { query } = parseReq(req, referalQuerySchema, { logOnly: true })
if (req.query.rm != null) {
// referal medium e.g. twitter, facebook, email
switch (req.query.rm) {
case 'fb':
req.session.referal_medium = 'facebook'
break
case 't':
req.session.referal_medium = 'twitter'
break
case 'gp':
req.session.referal_medium = 'google_plus'
break
case 'e':
req.session.referal_medium = 'email'
break
case 'd':
req.session.referal_medium = 'direct'
break
}
}
if (query.referal != null) {
req.session.referal_id = query.referal
} else if (query.r != null) {
// Short hand for referal
req.session.referal_id = query.r
} else if (query.fb_ref != null) {
req.session.referal_id = query.fb_ref
}
if (req.query.rs != null) {
// referal source e.g. project share, bonus
switch (req.query.rs) {
case 'b':
req.session.referal_source = 'bonus'
break
case 'ps':
req.session.referal_source = 'public_share'
break
case 'ci':
req.session.referal_source = 'collaborator_invite'
break
}
if (query.rm != null) {
// referal medium e.g. twitter, facebook, email
switch (query.rm) {
case 'fb':
req.session.referal_medium = 'facebook'
break
case 't':
req.session.referal_medium = 'twitter'
break
case 'gp':
req.session.referal_medium = 'google_plus'
break
case 'e':
req.session.referal_medium = 'email'
break
case 'd':
req.session.referal_medium = 'direct'
break
}
}
if (query.rs != null) {
// referal source e.g. project share, bonus
switch (query.rs) {
case 'b':
req.session.referal_source = 'bonus'
break
case 'ps':
req.session.referal_source = 'public_share'
break
case 'ci':
req.session.referal_source = 'collaborator_invite'
break
}
}
@@ -28,7 +28,7 @@ import { DEFAULT_PRICE_VERSION } from './PriceVersions.mjs'
import { User } from '../../models/User.mjs'
import UserGetter from '../User/UserGetter.mjs'
import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.mjs'
import { z, parseReq } from '../../infrastructure/Validation.mjs'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
const { AddOnNotPresentError, MultiplePendingChangesError } = Errors
@@ -149,11 +149,24 @@ function formatGroupPlansDataForDash() {
}
}
const userSubscriptionPageSchema = z.object({
query: z.object({
// rendered verbatim into the subscription dashboard; not consumed as a
// real error-code enum by this handler.
errorCode: z.string().optional(),
// not consumed
hasSubscription: z.stringbool().optional(),
}),
})
/**
* @param {any} req
* @param {any} res
*/
async function userSubscriptionPage(req, res) {
const { query } = parseReq(req, userSubscriptionPageSchema, {
logOnly: true,
})
const user = SessionManager.getSessionUser(req.session)
await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates')
await SplitTestHandler.promises.getAssignment(
@@ -200,7 +213,7 @@ async function userSubscriptionPage(req, res) {
const userCanExtendTrial = (
await Modules.promises.hooks.fire('userCanExtendTrial', user)
)?.[0]
const redirectedPaymentErrorCode = req.query.errorCode
const redirectedPaymentErrorCode = query.errorCode
const isInTrial = SubscriptionHelper.isInTrial(
personalSubscription?.payment?.trialEndsAt
)
@@ -338,11 +351,35 @@ async function userSubscriptionPage(req, res) {
res.render('subscriptions/dashboard-react', data)
}
const successfulSubscriptionSchema = z.object({
query: z.object({
// only ever sent as the literal 'true', or omitted entirely -- see
// callers in modules/subscriptions/.../root.tsx.
upgrade: z.stringbool().optional(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
// `query.upgrade` is compared against the literal boolean `true` below, so
// a raw passthrough still needs to produce a real boolean.
const successfulSubscriptionFallbackSchema = z.object({
query: z.object({
upgrade: z
.unknown()
.optional()
.transform(v => v === 'true' || v === true),
}),
})
/**
* @param {any} req
* @param {any} res
*/
async function successfulSubscription(req, res) {
const { query } = parseReq(req, successfulSubscriptionSchema, {
logOnly: true,
fallbackSchema: successfulSubscriptionFallbackSchema,
})
const user = SessionManager.getSessionUser(req.session)
if (!user) {
throw new Error('User is not logged in')
@@ -354,7 +391,7 @@ async function successfulSubscription(req, res) {
)
const postCheckoutRedirect = req.session?.postCheckoutRedirect
const isUpgrade = req.query.upgrade === 'true'
const isUpgrade = query.upgrade === true
if (!personalSubscription) {
res.redirect('/user/subscription/plans')
@@ -382,7 +419,7 @@ async function successfulSubscription(req, res) {
}
const pauseSubscriptionSchema = z.object({
params: z.object({
params: z.strictObject({
pauseCycles: z.coerce.number().int().max(12),
}),
})
@@ -520,12 +557,21 @@ function cancelV1Subscription(req, res, next) {
)
}
const previewAddonPurchaseSchema = z.object({
params: z.strictObject({
addOnCode: z.string(),
}),
})
/**
* @param {any} req
* @param {any} res
*/
async function previewAddonPurchase(req, res) {
const addOnCode = req.params.addOnCode
const { params } = parseReq(req, previewAddonPurchaseSchema, {
logOnly: true,
})
const addOnCode = params.addOnCode
if (addOnCode !== AI_ADD_ON_CODE) {
return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
@@ -546,7 +592,7 @@ async function purchaseAddon(req, res, next) {
}
const removeAddonSchema = z.object({
params: z.object({
params: z.strictObject({
addOnCode: z.string(),
}),
})
@@ -601,7 +647,7 @@ async function removeAddon(req, res, next) {
}
const reactivateAddonSchema = z.object({
params: z.object({
params: z.strictObject({
addOnCode: z.string(),
}),
})
@@ -639,13 +685,25 @@ async function reactivateAddon(req, res) {
}
}
const previewSubscriptionSchema = z.object({
query: z.object({
planCode: z.string().optional(),
// rendered verbatim into the preview page; not consumed as a real
// error-code enum by this handler.
errorCode: z.string().optional(),
}),
})
/**
* @param {any} req
* @param {any} res
* @param {any} next
*/
async function previewSubscription(req, res, next) {
const planCode = req.query.planCode
const { query } = parseReq(req, previewSubscriptionSchema, {
logOnly: true,
})
const planCode = query.planCode
if (!planCode) {
return HttpErrorHandler.notFound(req, res, 'Missing plan code')
}
@@ -698,7 +756,7 @@ async function previewSubscription(req, res, next) {
res.render('subscriptions/preview-change', {
changePreview,
redirectedPaymentErrorCode: req.query.errorCode,
redirectedPaymentErrorCode: query.errorCode,
trialDisabledReason,
})
}
@@ -777,15 +835,27 @@ function reactivateSubscription(req, res, next) {
})
}
// Recurly's webhook body is `{ <event_name>: { ...event-specific fields } }`
// -- the event name is one of an open-ended set defined by Recurly (this
// handler only actively branches on a known subset; anything else falls
// through to the generic 200 response below), and the payload shape varies
// per event type. This is a genuinely open map, not a shape we can name
// field-by-field.
const recurlyCallbackSchema = z.object({
body: z.record(z.string(), z.unknown()),
})
/**
* @param {any} req
* @param {any} res
* @param {any} next
*/
function recurlyCallback(req, res, next) {
logger.debug({ data: req.body }, 'received recurly callback')
const event = Object.keys(req.body)[0]
const eventData = req.body[event]
const { body } = parseReq(req, recurlyCallbackSchema, { logOnly: true })
logger.debug({ data: body }, 'received recurly callback')
const event = Object.keys(body)[0]
/** @type {any} the shape varies per Recurly event type -- see the schema comment above */
const eventData = body[event]
RecurlyEventHandler.sendRecurlyAnalyticsEvent(event, eventData).catch(error =>
logger.error(
@@ -885,36 +955,63 @@ function recurlyNotificationParser(req, res, next) {
)
}
const refreshUserFeaturesSchema = z.object({
params: z.strictObject({
user_id: zz.objectId(),
}),
})
/**
* @param {any} req
* @param {any} res
*/
async function refreshUserFeatures(req, res) {
const { user_id: userId } = req.params
const { params } = parseReq(req, refreshUserFeaturesSchema, {
logOnly: true,
})
const { user_id: userId } = params
await FeaturesUpdater.promises.refreshFeatures(userId, 'acceptance-test')
res.sendStatus(200)
}
// This is invoked as a shared helper from several different routes'
// handlers (PlansController, InterstitialPaymentController,
// PaymentController), not mounted as a route itself -- like middleware, it
// validates only the fields it reads, non-strictly, so it doesn't reject
// fields that belong to whichever route's own schema actually owns the
// request.
const getRecommendedCurrencySchema = z.object({
query: z.object({
// only trusted for site admins (checked below); an override for
// testing/support purposes.
ip: z.ipv4().optional(),
currency: z.string().optional(),
}),
})
/**
* @param {any} req
* @param {any} res
* @returns {Promise<{currency: CurrencyCode, recommendedCurrency: CurrencyCode, countryCode: string|undefined}>}
*/
async function getRecommendedCurrency(req, res) {
const { query } = parseReq(req, getRecommendedCurrencySchema, {
logOnly: true,
})
const userId = SessionManager.getLoggedInUserId(req.session)
let ip = req.ip
if (
req.query?.ip &&
query?.ip &&
(await AuthorizationManager.promises.isUserSiteAdmin(userId))
) {
ip = req.query.ip
ip = query.ip
}
const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(ip)
const countryCode = currencyLookup.countryCode
const recommendedCurrency = currencyLookup.currencyCode
let currency = null
const queryCurrency = req.query.currency?.toUpperCase()
const queryCurrency = query.currency?.toUpperCase()
if (queryCurrency && GeoIpLookup.isValidCurrencyParam(queryCurrency)) {
currency = queryCurrency
} else if (recommendedCurrency) {
@@ -922,24 +1019,37 @@ async function getRecommendedCurrency(req, res) {
}
return {
currency,
// `currency` can genuinely be null (no query override and no
// GeoIP-recommended currency); the return type below is looser than
// the cast.
currency: /** @type {any} */ (currency),
recommendedCurrency,
countryCode,
}
}
// Shared helper, same caveat as getRecommendedCurrency above.
const getLatamCountryBannerDetailsSchema = z.object({
query: z.object({
ip: z.ipv4().optional(),
}),
})
/**
* @param {any} req
* @param {any} res
*/
async function getLatamCountryBannerDetails(req, res) {
const { query } = parseReq(req, getLatamCountryBannerDetailsSchema, {
logOnly: true,
})
const userId = SessionManager.getLoggedInUserId(req.session)
let ip = req.ip
if (
req.query?.ip &&
query?.ip &&
(await AuthorizationManager.promises.isUserSiteAdmin(userId))
) {
ip = req.query.ip
ip = query.ip
}
const currencyLookup = await GeoIpLookup.promises.getCurrencyCode(ip)
const countryCode = currencyLookup.countryCode
@@ -10,7 +10,7 @@ import { expressify } from '@overleaf/promise-utils'
import Modules from '../../infrastructure/Modules.mjs'
import UserGetter from '../User/UserGetter.mjs'
import { Subscription } from '../../models/Subscription.mjs'
import { z, parseReq } from '../../infrastructure/Validation.mjs'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
import { isProfessionalGroupPlan } from './PlansHelper.mjs'
import {
MissingBillingInfoError,
@@ -30,14 +30,28 @@ const MAX_NUMBER_OF_PO_NUMBER_CHARACTERS = 50
* @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js"
*/
const removeUserFromGroupSchema = z.object({
// mounted at /manage/groups/:id/user/:user_id -- `id` is consumed
// upstream by UserMembershipMiddleware.requireGroupMemberManagement()
// (which sets req.entity), but it's still present on req.params, so the
// strict schema below has to name it too.
params: z.strictObject({
id: zz.objectId(),
user_id: zz.objectId(),
}),
})
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function removeUserFromGroup(req, res) {
const { params } = parseReq(req, removeUserFromGroupSchema, {
logOnly: true,
})
const subscription = req.entity
const userToRemoveId = req.params.user_id
const userToRemoveId = params.user_id
const loggedInUserId = SessionManager.getLoggedInUserId(req.session)
const subscriptionId = subscription._id
logger.debug(
@@ -52,15 +66,24 @@ async function removeUserFromGroup(req, res) {
})
}
const removeSelfFromGroupSchema = z.object({
query: z.object({
subscriptionId: zz.objectId(),
}),
})
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function removeSelfFromGroup(req, res) {
const { query } = parseReq(req, removeSelfFromGroupSchema, {
logOnly: true,
})
const userToRemoveId = SessionManager.getLoggedInUserId(req.session)
const subscription = await SubscriptionLocator.promises.getSubscription(
req.query.subscriptionId
query.subscriptionId
)
await _removeUserFromGroup(req, res, {
@@ -137,12 +160,23 @@ async function _removeUserFromGroup(
res.sendStatus(200)
}
const addSeatsToGroupSubscriptionSchema = z.object({
query: z.object({
// rendered verbatim into the add-seats page; not consumed as a real
// error-code enum by this handler.
errorCode: z.string().optional(),
}),
})
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function addSeatsToGroupSubscription(req, res) {
const { query } = parseReq(req, addSeatsToGroupSubscriptionSchema, {
logOnly: true,
})
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const { subscription, paymentProviderSubscription, plan } =
@@ -171,7 +205,7 @@ async function addSeatsToGroupSubscription(req, res) {
isProfessional: isProfessionalGroupPlan(subscription),
isCollectionMethodManual:
paymentProviderSubscription.isCollectionMethodManual,
redirectedPaymentErrorCode: req.query.errorCode,
redirectedPaymentErrorCode: query.errorCode,
})
} catch (error) {
if (error instanceof MissingBillingInfoError) {
@@ -202,7 +236,17 @@ async function addSeatsToGroupSubscription(req, res) {
}
}
const addSeatsBodySchema = z.strictObject({
adding: z.number().int().min(1).max(MAX_NUMBER_OF_USERS),
poNumber: z.string().max(MAX_NUMBER_OF_PO_NUMBER_CHARACTERS).optional(),
})
const previewAddSeatsSubscriptionChangeSchema = z.object({
body: addSeatsBodySchema,
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const previewAddSeatsSubscriptionChangeFallbackSchema = z.object({
body: z.object({
adding: z.number().int().min(1).max(MAX_NUMBER_OF_USERS),
poNumber: z.string().max(MAX_NUMBER_OF_PO_NUMBER_CHARACTERS).optional(),
@@ -215,7 +259,9 @@ const previewAddSeatsSubscriptionChangeSchema = z.object({
* @returns {Promise<void>}
*/
async function previewAddSeatsSubscriptionChange(req, res) {
const { body } = parseReq(req, previewAddSeatsSubscriptionChangeSchema)
const { body } = parseReq(req, previewAddSeatsSubscriptionChangeSchema, {
fallbackSchema: previewAddSeatsSubscriptionChangeFallbackSchema,
})
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const preview =
@@ -251,19 +297,26 @@ async function previewAddSeatsSubscriptionChange(req, res) {
}
}
const createAddSeatsSubscriptionChangeSchema = z.object({
body: addSeatsBodySchema,
})
/**
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function createAddSeatsSubscriptionChange(req, res) {
const { body } = parseReq(req, createAddSeatsSubscriptionChangeSchema, {
logOnly: true,
})
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const create =
await SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange(
userId,
req.body.adding,
req.body.poNumber
body.adding,
body.poNumber
)
res.json(create)
@@ -281,7 +334,7 @@ async function createAddSeatsSubscriptionChange(req, res) {
if (error instanceof SubtotalLimitExceededError) {
return res.status(422).json({
code: 'subtotal_limit_exceeded',
adding: req.body.adding,
adding: body.adding,
})
}
@@ -303,6 +356,14 @@ async function createAddSeatsSubscriptionChange(req, res) {
}
const submitFormSchema = z.object({
body: z.strictObject({
adding: z.coerce.number().int().min(MAX_NUMBER_OF_USERS),
poNumber: z.string().optional(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const submitFormFallbackSchema = z.object({
body: z.object({
adding: z.coerce.number().int().min(MAX_NUMBER_OF_USERS),
poNumber: z.string().optional(),
@@ -310,7 +371,9 @@ const submitFormSchema = z.object({
})
async function submitForm(req, res) {
const { body } = parseReq(req, submitFormSchema)
const { body } = parseReq(req, submitFormSchema, {
fallbackSchema: submitFormFallbackSchema,
})
const { adding, poNumber } = body
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -361,7 +424,18 @@ async function submitForm(req, res) {
res.sendStatus(204)
}
const subscriptionUpgradePageSchema = z.object({
query: z.object({
// rendered verbatim into the upgrade page; not consumed as a real
// error-code enum by this handler.
errorCode: z.string().optional(),
}),
})
async function subscriptionUpgradePage(req, res) {
const { query } = parseReq(req, subscriptionUpgradePageSchema, {
logOnly: true,
})
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const changePreview =
@@ -373,7 +447,7 @@ async function subscriptionUpgradePage(req, res) {
changePreview,
totalLicenses: olSubscription.membersLimit,
groupName: olSubscription.teamName,
redirectedPaymentErrorCode: req.query.errorCode,
redirectedPaymentErrorCode: query.errorCode,
})
} catch (error) {
if (error instanceof MissingBillingInfoError) {
@@ -445,7 +519,17 @@ async function missingBillingInformation(req, res) {
}
}
const manuallyCollectedSubscriptionSchema = z.object({
query: z.object({
// rendered verbatim into the manually-collected-subscription page.
error_type: z.string().optional(),
}),
})
async function manuallyCollectedSubscription(req, res) {
const { query } = parseReq(req, manuallyCollectedSubscriptionSchema, {
logOnly: true,
})
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const subscription =
@@ -453,7 +537,7 @@ async function manuallyCollectedSubscription(req, res) {
res.render('subscriptions/manually-collected-subscription', {
groupName: subscription.teamName,
errorType: req.query.error_type,
errorType: query.error_type,
})
} catch (error) {
logger.err(
@@ -479,13 +563,24 @@ async function subtotalLimitExceeded(req, res) {
}
}
const getGroupPlanPerUserPricesSchema = z.object({
query: z.object({
// forwarded to the Stripe client as a currency filter; Stripe itself
// rejects an unrecognised code, so no enum is duplicated here.
currency: z.string().optional(),
}),
})
async function getGroupPlanPerUserPrices(req, res) {
const { query } = parseReq(req, getGroupPlanPerUserPricesSchema, {
logOnly: true,
})
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const prices = await Modules.promises.hooks.fire(
'getGroupPlanPerUserPrices',
userId,
req.query.currency
query.currency
)
return res.json(prices[0])
} catch (error) {
@@ -9,31 +9,63 @@ import { parseReq, z } from '../../infrastructure/Validation.mjs'
const numericId = z.string().regex(/^[0-9]+$/)
const getV1TemplateSchema = z.object({
params: z.object({ Template_version_id: numericId }),
query: z.object({ id: numericId }),
params: z.strictObject({ Template_version_id: numericId }),
query: z.object({
id: numericId,
templateName: z.string().optional(),
latexEngine: z.string().optional(),
texImage: z.string().optional(),
mainFile: z.string().optional(),
brandVariationId: z.coerce.number().int().positive().optional(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const getV1TemplateFallbackSchema = z.object({
params: z.object({ Template_version_id: numericId }).passthrough(),
query: z.object({ id: numericId }).passthrough(),
})
const createProjectFromV1TemplateSchema = z.object({
body: z.object({
body: z.strictObject({
templateId: numericId,
templateVersionId: numericId,
brandVariationId: z.coerce.number().int().positive().optional(),
compiler: z.string().optional(),
mainFile: z.string().optional(),
templateName: z.string().optional(),
imageName: z.string().optional(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const createProjectFromV1TemplateFallbackSchema = z.object({
body: z
.object({
templateId: numericId,
templateVersionId: numericId,
})
.passthrough(),
})
const TemplatesController = {
async getV1Template(req, res) {
const {
params: { Template_version_id: templateVersionId },
query: { id: templateId },
} = parseReq(req, getV1TemplateSchema)
query,
} = parseReq(req, getV1TemplateSchema, {
fallbackSchema: getV1TemplateFallbackSchema,
})
const data = {
templateVersionId,
templateId,
name: req.query.templateName,
compiler: ProjectHelper.compilerFromV1Engine(req.query.latexEngine),
imageName: req.query.texImage,
mainFile: req.query.mainFile,
brandVariationId: req.query.brandVariationId,
templateId: query.id,
name: query.templateName,
compiler: ProjectHelper.compilerFromV1Engine(query.latexEngine),
imageName: query.texImage,
mainFile: query.mainFile,
brandVariationId: query.brandVariationId,
}
res.render(
path.resolve(
@@ -45,19 +77,19 @@ const TemplatesController = {
},
async createProjectFromV1Template(req, res) {
const {
body: { templateId, templateVersionId },
} = parseReq(req, createProjectFromV1TemplateSchema)
const { body } = parseReq(req, createProjectFromV1TemplateSchema, {
fallbackSchema: createProjectFromV1TemplateFallbackSchema,
})
const userId = SessionManager.getLoggedInUserId(req.session)
const project = await TemplatesManager.promises.createProjectFromV1Template(
req.body.brandVariationId,
req.body.compiler,
req.body.mainFile,
templateId,
req.body.templateName,
templateVersionId,
body.brandVariationId,
body.compiler,
body.mainFile,
body.templateId,
body.templateName,
body.templateVersionId,
userId,
req.body.imageName
body.imageName
)
delete req.session.templateData
if (!project) {
@@ -1,21 +1,20 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import settings from '@overleaf/settings'
import { z, parseReq } from '../../infrastructure/Validation.mjs'
import logger from '@overleaf/logger'
const saveTemplateDataInSessionSchema = z.object({
query: z.object({
templateName: z.string().optional(),
}),
})
export default {
saveTemplateDataInSession(req, res, next) {
if (req.query.templateName) {
req.session.templateData = req.query
const { query } = parseReq(req, saveTemplateDataInSessionSchema, {
logOnly: true,
})
if (query.templateName) {
// only `templateName` is ever read back out of session.templateData
// (see UserPagesController.registerPage) — store just that field
req.session.templateData = { templateName: query.templateName }
}
return next()
},
@@ -4,22 +4,39 @@ import TemplatesMiddleware from './TemplatesMiddleware.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.mjs'
import { z, parseReq } from '../../infrastructure/Validation.mjs'
const rateLimiter = new RateLimiter('create-project-from-template', {
points: 20,
duration: 60,
})
// Non-strict, and deliberately looser than TemplatesController.getV1Template's
// own numeric-id schema: this only threads the param through to
// AnalyticsRegistrationSourceMiddleware's session bookkeeping, and rejecting
// or reformatting it here isn't this middleware's job -- getV1Template
// (later in the same chain) still enforces the real numeric v1
// template-version-id format for the route.
const templateVersionIdSchema = z.object({
params: z.object({
Template_version_id: z.string(),
}),
})
export default {
rateLimiter,
apply(app) {
app.get(
'/project/new/template/:Template_version_id',
(req, res, next) =>
AnalyticsRegistrationSourceMiddleware.setSource(
(req, res, next) => {
const { params } = parseReq(req, templateVersionIdSchema, {
logOnly: true,
})
return AnalyticsRegistrationSourceMiddleware.setSource(
'template',
req.params.Template_version_id
)(req, res, next),
params.Template_version_id
)(req, res, next)
},
TemplatesMiddleware.saveTemplateDataInSession,
AuthenticationController.requireLogin(),
TemplatesController.getV1Template,
@@ -1,6 +1,7 @@
import SessionManager from '../Authentication/SessionManager.mjs'
import TutorialHandler from './TutorialHandler.mjs'
import { expressify } from '@overleaf/promise-utils'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
const VALID_KEYS = [
'react-history-buttons-tutorial',
@@ -34,9 +35,49 @@ const VALID_KEYS = [
'dimensions-rail-popover',
]
// tutorialKey is validated against the same known-key enum the handlers
// also check by hand below. Both the schema and the manual
// VALID_KEYS.includes() guard enforce the same allow-list during this
// rollout's logOnly phase, so an unrecognized key still 404s even while the
// schema failure alone would not throw.
const tutorialKeyParamsSchema = z.strictObject({
tutorialKey: z.enum(VALID_KEYS),
})
const completeTutorialSchema = z.object({
params: tutorialKeyParamsSchema,
})
const postponeTutorialSchema = z.object({
params: tutorialKeyParamsSchema,
body: z.strictObject({
postponedUntil: zz.datetime().optional(),
}),
})
// Rollout-temporary fallback (loosened primary schema; no zod validation
// existed for this route on main); delete when this route's
// REQ_VALIDATION_MODE instrumentation is removed.
const postponeTutorialFallbackSchema = z.object({
params: z.object({
tutorialKey: z.enum(VALID_KEYS),
}),
body: z.object({
postponedUntil: z
.union([z.date(), z.string()])
.optional()
.transform(dt =>
dt === undefined ? undefined : dt instanceof Date ? dt : new Date(dt)
),
}),
})
async function completeTutorial(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const tutorialKey = req.params.tutorialKey
const { params } = parseReq(req, completeTutorialSchema, {
logOnly: true,
})
const { tutorialKey } = params
if (!VALID_KEYS.includes(tutorialKey)) {
return res.sendStatus(404)
@@ -48,16 +89,21 @@ async function completeTutorial(req, res, next) {
async function postponeTutorial(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const tutorialKey = req.params.tutorialKey
let postponedUntil
if (req.body.postponedUntil) {
postponedUntil = new Date(req.body.postponedUntil)
}
const { params, body } = parseReq(req, postponeTutorialSchema, {
logOnly: true,
fallbackSchema: postponeTutorialFallbackSchema,
})
const { tutorialKey } = params
if (!VALID_KEYS.includes(tutorialKey)) {
return res.sendStatus(404)
}
let postponedUntil
if (body.postponedUntil) {
postponedUntil = new Date(body.postponedUntil)
}
await TutorialHandler.setTutorialState(
userId,
tutorialKey,
@@ -7,7 +7,7 @@ import metrics from '@overleaf/metrics'
import AuthenticationManager from '../Authentication/AuthenticationManager.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
import Features from '../../infrastructure/Features.mjs'
import { z, parseReq } from '../../infrastructure/Validation.mjs'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
import UserAuditLogHandler from './UserAuditLogHandler.mjs'
import UserSessionsManager from './UserSessionsManager.mjs'
import UserUpdater from './UserUpdater.mjs'
@@ -65,13 +65,22 @@ async function _ensureAffiliation(userId, emailData) {
}
}
const changePasswordSchema = z.object({
body: z.strictObject({
currentPassword: z.string().optional(),
newPassword1: z.string().optional(),
newPassword2: z.string().optional(),
}),
})
async function changePassword(req, res, next) {
metrics.inc('user.password-change')
const { body } = parseReq(req, changePasswordSchema, { logOnly: true })
const userId = SessionManager.getLoggedInUserId(req.session)
const { user } = await AuthenticationManager.promises.authenticate(
{ _id: userId },
req.body.currentPassword,
body.currentPassword,
null,
{ enforceHIBPCheck: false }
)
@@ -83,7 +92,7 @@ async function changePassword(req, res, next) {
)
}
if (req.body.newPassword1 !== req.body.newPassword2) {
if (body.newPassword1 !== body.newPassword2) {
return HttpErrorHandler.badRequest(
req,
res,
@@ -94,7 +103,7 @@ async function changePassword(req, res, next) {
try {
await AuthenticationManager.promises.setUserPassword(
user,
req.body.newPassword1
body.newPassword1
)
} catch (error) {
if (error.name === 'InvalidPasswordError') {
@@ -192,9 +201,20 @@ async function ensureAffiliation(user) {
await _ensureAffiliation(user._id, flaggedEmails[0])
}
const ensureAffiliationMiddlewareSchema = z.object({
query: z.object({
// just a truthiness flag on whether to run the affiliation check; the
// real caller sends the query string `?ensureAffiliation=true`
ensureAffiliation: z.coerce.boolean().optional(),
}),
})
async function ensureAffiliationMiddleware(req, res, next) {
let user
if (!Features.hasFeature('affiliations') || !req.query.ensureAffiliation) {
const { query } = parseReq(req, ensureAffiliationMiddlewareSchema, {
logOnly: true,
})
if (!Features.hasFeature('affiliations') || !query.ensureAffiliation) {
return next()
}
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -215,9 +235,17 @@ async function ensureAffiliationMiddleware(req, res, next) {
return next()
}
const tryDeleteUserSchema = z.object({
body: z.strictObject({
password: z.string().optional(),
}),
})
async function tryDeleteUser(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { password } = req.body
const { password } = parseReq(req, tryDeleteUserSchema, {
logOnly: true,
}).body
req.logger.addFields({ userId })
logger.debug({ userId }, 'trying to delete user account')
@@ -334,6 +362,16 @@ async function unsubscribe(req, res, next) {
}
const refProviderSettingsSchema = z
.strictObject({
enabled: z.boolean().optional(),
groups: z.array(z.strictObject({ id: z.string() })).optional(),
disablePersonalLibrary: z.boolean().optional(),
})
.optional()
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const refProviderSettingsFallbackSchema = z
.object({
enabled: z.boolean().optional(),
groups: z.array(z.object({ id: z.string() })).optional(),
@@ -342,20 +380,62 @@ const refProviderSettingsSchema = z
.optional()
const updateUserSettingsSchema = z.object({
// the frontend posts one changed setting at a time (`{[key]: value}`), so
// every field here is optional
body: z.strictObject({
first_name: z.string().max(255).nullish(),
last_name: z.string().max(255).nullish(),
role: z.string().optional(),
institution: z.string().optional(),
email: z.string().optional(),
mode: z.string().optional(),
editorTheme: z.string().optional(),
editorLightTheme: z.string().optional(),
editorDarkTheme: z.string().optional(),
overallTheme: z.string().optional(),
fontSize: z.number().optional(),
autoComplete: z.boolean().optional(),
autoPairDelimiters: z.boolean().optional(),
spellCheckLanguage: z.string().optional(),
pdfViewer: z.string().optional(),
syntaxValidation: z.boolean().optional(),
// these six settings are cast with `Boolean(...)` below rather than
// checked for a strict boolean, so any truthy/falsy value is accepted
// here too
previewTabs: z.coerce.boolean().optional(),
fontFamily: z.string().optional(),
lineHeight: z.string().optional(),
mathPreview: z.boolean().optional(),
breadcrumbs: z.coerce.boolean().optional(),
editorTabs: z.coerce.boolean().optional(),
nonBlinkingCursor: z.coerce.boolean().optional(),
referencesSearchMode: z.string().optional(),
darkModePdf: z.coerce.boolean().optional(),
floatingMenu: z.coerce.boolean().optional(),
zotero: refProviderSettingsSchema,
mendeley: refProviderSettingsSchema,
papers: refProviderSettingsSchema,
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const updateUserSettingsFallbackSchema = z.object({
body: z
.object({
first_name: z.string().max(255).nullish(),
last_name: z.string().max(255).nullish(),
zotero: refProviderSettingsSchema,
mendeley: refProviderSettingsSchema,
papers: refProviderSettingsSchema,
zotero: refProviderSettingsFallbackSchema,
mendeley: refProviderSettingsFallbackSchema,
papers: refProviderSettingsFallbackSchema,
})
.passthrough(),
// TODO: complete the schema and remove the passthrough
})
async function updateUserSettings(req, res, next) {
const { body } = parseReq(req, updateUserSettingsSchema)
const { body } = parseReq(req, updateUserSettingsSchema, {
fallbackSchema: updateUserSettingsFallbackSchema,
})
const userId = SessionManager.getLoggedInUserId(req.session)
req.logger.addFields({ userId })
@@ -541,9 +621,16 @@ async function doLogout(req) {
}
}
const logoutSchema = z.object({
body: z.strictObject({
redirect: z.string().optional(),
}),
})
async function logout(req, res, next) {
const requestedRedirect = req.body.redirect
? UrlHelper.getSafeRedirectPath(req.body.redirect)
const { body } = parseReq(req, logoutSchema, { logOnly: true })
const requestedRedirect = body.redirect
? UrlHelper.getSafeRedirectPath(body.redirect)
: undefined
const redirectUrl = requestedRedirect || '/login'
@@ -556,9 +643,17 @@ async function logout(req, res, next) {
}
}
const expireDeletedUserSchema = z.object({
params: z.strictObject({
userId: zz.objectId(),
}),
})
async function expireDeletedUser(req, res, next) {
const userId = req.params.userId
await UserDeleter.promises.expireDeletedUser(userId)
const { params } = parseReq(req, expireDeletedUserSchema, {
logOnly: true,
})
await UserDeleter.promises.expireDeletedUser(params.userId)
res.sendStatus(204)
}
@@ -20,6 +20,7 @@ import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import Features from '../../infrastructure/Features.mjs'
import tsscmp from 'tsscmp'
import Modules from '../../infrastructure/Modules.mjs'
import { z, parseReq } from '../../infrastructure/Validation.mjs'
const AUDIT_LOG_TOKEN_PREFIX_LENGTH = 10
@@ -54,9 +55,18 @@ async function _sendSecurityAlertEmail(user, email) {
await EmailHandler.promises.sendEmail('securityAlert', emailOptions)
}
const sendExistingEmailConfirmationCodeSchema = z.object({
body: z.strictObject({
email: z.string().optional(),
}),
})
async function sendExistingEmailConfirmationCode(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
const { body } = parseReq(req, sendExistingEmailConfirmationCodeSchema, {
logOnly: true,
})
const email = EmailHelper.parseEmail(body.email)
if (!email) {
return res.sendStatus(400)
}
@@ -71,6 +81,23 @@ async function sendExistingEmailConfirmationCode(req, res) {
res.sendStatus(204)
}
const universitySchema = z
.strictObject({
id: z.number().optional(),
name: z.string().optional(),
country_code: z.string().optional(),
})
.optional()
const addWithConfirmationCodeSchema = z.object({
body: z.strictObject({
email: z.string().optional(),
university: universitySchema,
role: z.string().optional(),
department: z.string().optional(),
}),
})
/**
* This method is for adding a secondary email to be confirmed via a code.
*/
@@ -78,11 +105,14 @@ async function addWithConfirmationCode(req, res) {
delete req.session.pendingSecondaryEmail
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
const { body } = parseReq(req, addWithConfirmationCodeSchema, {
logOnly: true,
})
const email = EmailHelper.parseEmail(body.email)
const affiliationOptions = {
university: req.body.university,
role: req.body.role,
department: req.body.department,
university: body.university,
role: body.role,
department: body.department,
}
if (!email) {
@@ -175,6 +205,12 @@ async function sendCodeAndStoreInSession(
}
}
const checkConfirmationCodeSchema = z.object({
body: z.strictObject({
code: z.string().optional(),
}),
})
/**
* @param {string} sessionKey
* @param {(req: import('express').Request, user: any, email: string, affiliationOptions: any) => Promise<void>} beforeConfirmEmail
@@ -183,7 +219,9 @@ async function sendCodeAndStoreInSession(
const _checkConfirmationCode =
(sessionKey, beforeConfirmEmail) => async (req, res) => {
const userId = SessionManager.getLoggedInUserId(req.session)
const code = req.body.code
const { code } = parseReq(req, checkConfirmationCodeSchema, {
logOnly: true,
}).body
const user = await UserGetter.promises.getUser(userId, {
email: 1,
'emails.email': 1,
@@ -481,16 +519,30 @@ async function primaryEmailCheck(req, res) {
AsyncFormHelper.redirect(req, res, '/project')
}
const showConfirmSchema = z.object({
query: z.object({
token: z.string().optional(),
}),
})
async function showConfirm(req, res, next) {
const { query } = parseReq(req, showConfirmSchema, { logOnly: true })
res.render('user/confirm_email', {
token: req.query.token,
token: query.token,
title: 'confirm_email',
})
}
const removeEmailSchema = z.object({
body: z.strictObject({
email: z.string().optional(),
}),
})
async function remove(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
const { body } = parseReq(req, removeEmailSchema, { logOnly: true })
const email = EmailHelper.parseEmail(body.email)
if (!email) {
return res.sendStatus(422)
}
@@ -502,9 +554,21 @@ async function remove(req, res) {
res.sendStatus(200)
}
const setDefaultEmailSchema = z.object({
body: z.strictObject({
email: z.string().optional(),
}),
query: z.object({
'delete-unconfirmed-primary': z.string().optional(),
}),
})
async function setDefault(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
const { body, query } = parseReq(req, setDefaultEmailSchema, {
logOnly: true,
})
const email = EmailHelper.parseEmail(body.email)
if (!email) {
return res.sendStatus(422)
@@ -516,7 +580,7 @@ async function setDefault(req, res, next) {
)
const primaryEmailData = emails?.find(email => email.email === oldDefault)
const deleteOldEmail =
req.query['delete-unconfirmed-primary'] !== undefined &&
query['delete-unconfirmed-primary'] !== undefined &&
primaryEmailData &&
!primaryEmailData.confirmedAt
@@ -550,7 +614,7 @@ async function setDefault(req, res, next) {
)
}
if (
req.query['delete-unconfirmed-primary'] !== undefined &&
query['delete-unconfirmed-primary'] !== undefined &&
primaryEmailData &&
!primaryEmailData.confirmedAt
) {
@@ -569,6 +633,20 @@ async function setDefault(req, res, next) {
res.sendStatus(200)
}
const endorseEmailSchema = z.object({
body: z.strictObject({
email: z.string().optional(),
role: z.string().optional(),
department: z.string().optional(),
}),
})
const confirmEmailSchema = z.object({
body: z.strictObject({
token: z.string().nullish(),
}),
})
const UserEmailsController = {
list(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -604,7 +682,8 @@ const UserEmailsController = {
endorse(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const email = EmailHelper.parseEmail(req.body.email)
const { body } = parseReq(req, endorseEmailSchema, { logOnly: true })
const email = EmailHelper.parseEmail(body.email)
if (!email) {
return res.sendStatus(422)
}
@@ -612,8 +691,8 @@ const UserEmailsController = {
InstitutionsAPI.endorseAffiliation(
userId,
email,
req.body.role,
req.body.department,
body.role,
body.department,
function (error) {
if (error) {
return next(error)
@@ -638,7 +717,9 @@ const UserEmailsController = {
showConfirm: expressify(showConfirm),
confirm(req, res, next) {
const { token } = req.body
const { token } = parseReq(req, confirmEmailSchema, {
logOnly: true,
}).body
if (!token) {
return res.status(422).json({
message: req.i18n.translate('confirmation_link_broken'),
@@ -2,9 +2,18 @@ import UserGetter from './UserGetter.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
import mongodb from 'mongodb-legacy'
import { expressify } from '@overleaf/promise-utils'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
const { ObjectId } = mongodb
// user_id is either a real Mongo ObjectId or a legacy v1-only numeric
// (overleaf.id) id.
const getPersonalInfoSchema = z.object({
params: z.strictObject({
user_id: zz.objectId().or(z.coerce.number()),
}),
})
function getLoggedInUsersPersonalInfo(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
if (!userId) {
@@ -30,8 +39,10 @@ function getLoggedInUsersPersonalInfo(req, res, next) {
}
function getPersonalInfo(req, res, next) {
const { params } = parseReq(req, getPersonalInfoSchema, { logOnly: true })
let query
const userId = req.params.user_id
const userId = params.user_id
if (/^\d+$/.test(userId)) {
query = { 'overleaf.id': parseInt(userId, 10) }
@@ -11,6 +11,11 @@ import { expressify } from '@overleaf/promise-utils'
import Features from '../../infrastructure/Features.mjs'
import Modules from '../../infrastructure/Modules.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import {
z,
parseReq,
getRawReqInput,
} from '../../infrastructure/Validation.mjs'
function popSessionValue(session, key) {
const value = session[key]
@@ -20,9 +25,17 @@ function popSessionValue(session, key) {
return value
}
const settingsPageSchema = z.object({
query: z.object({
remove: z.string().optional(),
'oauth-complete': z.string().optional(),
}),
})
async function settingsPage(req, res) {
const { query } = parseReq(req, settingsPageSchema, { logOnly: true })
const userId = SessionManager.getLoggedInUserId(req.session)
const reconfirmationRemoveEmail = req.query.remove
const reconfirmationRemoveEmail = query.remove
// SSO
delete req.session.ssoError
const ssoErrorMessage = popSessionValue(req.session, 'ssoErrorMessage')
@@ -247,6 +260,12 @@ async function emailPreferencesPage(req, res) {
})
}
const loginPageSchema = z.object({
query: z.object({
redir: z.string().optional(),
}),
})
const UserPagesController = {
accountSuspended: expressify(accountSuspended),
logout: expressify(logout),
@@ -268,16 +287,21 @@ const UserPagesController = {
},
loginPage(req, res) {
const { query } = parseReq(req, loginPageSchema, { logOnly: true })
// if user is being sent to /login with explicit redirect (redir=/foo),
// such as being sent from the editor to /login, then set the redirect explicitly
if (
req.query.redir != null &&
query.redir != null &&
AuthenticationController.getRedirectFromSession(req) == null
) {
AuthenticationController.setRedirectInSession(req, req.query.redir)
AuthenticationController.setRedirectInSession(req, query.redir)
}
const metadata = { robotsNoindexNofollow: false }
if (Object.keys(req.query).length !== 0) {
// any query param at all (not just `redir`) marks this page as noindex
// -- e.g. error/SSO-flow flags added by other callers -- read the raw
// query here rather than by name (case 1: verbatim forwarding)
if (Object.keys(getRawReqInput(req).query).length !== 0) {
metadata.robotsNoindexNofollow = true
}
res.render('user/login', {
@@ -53,6 +53,35 @@ describe('Add secondary email address confirmation code email', function () {
expect(confirmCode.length).to.equal(6)
})
it('should accept an explicit null g-recaptcha-response', async function () {
// The frontend's ReCaptcha2 component (recaptcha-2.tsx) sends a literal
// JSON `null` -- not an omitted field -- whenever it never mounted the
// widget (no sitekey, action disabled, or `window.Cypress` in dev E2E
// runs). CaptchaMiddleware's schema accepts that shape rather than
// 400ing on "expected string, received null", then strips the field
// from req.body before addWithConfirmationCodeSchema -- which doesn't
// declare the field at all -- ever sees it.
const nullCaptchaRes = await user.doRequest('POST', {
json: {
email: 'secondary-null-captcha@overleaf.com',
'g-recaptcha-response': null,
},
uri: `/user/emails/secondary`,
})
expect(nullCaptchaRes.response.statusCode).to.equal(200)
})
it('should reject a non-string g-recaptcha-response', async function () {
const badCaptchaRes = await user.doRequest('POST', {
json: {
email: 'secondary-bad-captcha@overleaf.com',
'g-recaptcha-response': ['not', 'a', 'string'],
},
uri: `/user/emails/secondary`,
})
expect(badCaptchaRes.response.statusCode).to.equal(400)
})
describe('with a valid confirmation code', function () {
beforeEach(async function () {
this.result = await user.doRequest('POST', {
@@ -102,6 +131,16 @@ describe('Add secondary email address confirmation code email', function () {
})
})
it('should reject a non-string confirmation code', async function () {
const badCodeRes = await user.doRequest('POST', {
json: {
code: ['not', 'a', 'string'],
},
uri: '/user/emails/confirm-secondary',
})
expect(badCodeRes.response.statusCode).to.equal(400)
})
describe('with a duplicate email', async function () {
beforeEach(async function () {
await user.doRequest('POST', {
@@ -0,0 +1,79 @@
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
const User = UserHelper.promises
describe('Tutorial', function () {
let user
const tutorialKey = 'workbench-consent'
beforeEach(async function () {
user = new User()
await user.login()
})
describe('POST /tutorial/:tutorialKey/complete', function () {
it('should mark the tutorial as completed', async function () {
const { response } = await user.doRequest('post', {
url: `/tutorial/${tutorialKey}/complete`,
json: true,
})
expect(response.statusCode).to.equal(204)
})
it('should reject an unrecognized tutorial key with 404', async function () {
const { response, body } = await user.doRequest('post', {
url: '/tutorial/not-a-real-tutorial/complete',
json: true,
})
expectValidationErrorRaw(
{ statusCode: response.statusCode, body },
404,
'tutorialKey'
)
})
})
describe('POST /tutorial/:tutorialKey/postpone', function () {
it('should postpone the tutorial with no body', async function () {
const { response } = await user.doRequest('post', {
url: `/tutorial/${tutorialKey}/postpone`,
json: true,
})
expect(response.statusCode).to.equal(204)
})
it('should postpone the tutorial with a postponedUntil date', async function () {
const { response } = await user.doRequest('post', {
url: `/tutorial/${tutorialKey}/postpone`,
json: { postponedUntil: '2026-08-01T00:00:00.000Z' },
})
expect(response.statusCode).to.equal(204)
})
it('should reject a malformed postponedUntil value', async function () {
const { response, body } = await user.doRequest('post', {
url: `/tutorial/${tutorialKey}/postpone`,
json: { postponedUntil: 'not-a-date' },
})
expectValidationErrorRaw(
{ statusCode: response.statusCode, body },
400,
'postponedUntil'
)
})
it('should reject an unrecognized tutorial key with 404', async function () {
const { response, body } = await user.doRequest('post', {
url: '/tutorial/not-a-real-tutorial/postpone',
json: true,
})
expectValidationErrorRaw(
{ statusCode: response.statusCode, body },
404,
'tutorialKey'
)
})
})
})
@@ -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)
)
})
})
})
})
})