[web] add infrastructure level zod change (#36190)

* [web] add infrastructure level zod change

* [web] set req validation mode for tests to 'enforce'

GitOrigin-RevId: 6dbc8bff7ed9f89045c398df93d84bca589de4e9
This commit is contained in:
Jakob Ackermann
2026-09-17 11:48:19 +00:00
committed by Copybot
parent 86bab9dc99
commit 6ca58edf72
52 changed files with 2657 additions and 301 deletions
@@ -0,0 +1,67 @@
diff --git a/lib/request.js b/lib/request.js
index f42960ca2b94b784497392cfb85ef31e4e63a1ac..7feea2ed29873e519aee0ba5e1f5b36e09fe865d 100644
--- a/lib/request.js
+++ b/lib/request.js
@@ -6,6 +6,30 @@
const InvalidArgumentError = require('./errors/invalid-argument-error');
/* type-is: https://github.com/jshttp/type-is */
const typeis = require('type-is');
+// --- Overleaf patch: request-input lockdown compatibility ---
+// The constructor below destructures query/body directly off the raw
+// framework request object it's handed (see AuthenticationController.mjs's
+// `new Oauth2Server.Request(req)`), which is otherwise indistinguishable
+// from unvalidated application code reading raw request input under
+// Overleaf's request-input lockdown (see libraries/validation-tools and the
+// patched express in .yarn/patches/express-npm-*.patch). The lockdown
+// captures the original parsed values in symbol-keyed fields, registered in
+// the global symbol registry (the same ones @overleaf/validation-tools'
+// lockdown.js reads), and only turns the *public* req.query/req.body
+// properties into throwing accessors -- reading through these raw fields
+// instead sidesteps that, and is identical to plain req.query/req.body when
+// the lockdown isn't installed (i.e. for every consumer of this package
+// outside this monorepo).
+const __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+const __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+const __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
/**
* Wrapper for webserver's request.
@@ -29,7 +53,12 @@ class Request {
* @param otherOptions {...object} any other properties that should be assigned to the request by your webserver
* @throws {InvalidArgumentError} if one of headers, method or query are missing.
*/
- constructor({ headers, method, query, body, ...otherOptions } = {}) {
+ constructor(req = {}) {
+ const headers = req.headers;
+ const method = req.method;
+ const query = __overleafRawQuery(req);
+ const body = __overleafRawBody(req);
+
if (!headers) {
throw new InvalidArgumentError('Missing parameter: `headers`');
}
@@ -52,7 +81,16 @@ class Request {
this.headers[header.toLowerCase()] = value;
});
- // Store additional properties of the request object passed in
+ // Store additional properties of the request object passed in (mirrors
+ // the original `...otherOptions` rest destructuring, but built off
+ // Object.assign so query/body -- already handled above via the raw
+ // lockdown fields -- aren't read a second time through the throwing
+ // accessors).
+ const otherOptions = Object.assign({}, req);
+ delete otherOptions.headers;
+ delete otherOptions.method;
+ delete otherOptions.query;
+ delete otherOptions.body;
Object.entries(otherOptions)
.filter(([property]) => !this[property])
.forEach(([property, value]) => {
@@ -0,0 +1,99 @@
diff --git a/lib/strategy.js b/lib/strategy.js
index 4de162f7e92a20b1ccd4d34dd9bcc0916f4c8c43..c67bb4e0b0287d615eac4c01b84c83d2bac91446 100644
--- a/lib/strategy.js
+++ b/lib/strategy.js
@@ -5,6 +5,29 @@ const passport_strategy_1 = require("passport-strategy");
const assert_1 = require("assert");
const url = require("url");
const node_saml_1 = require("@node-saml/node-saml");
+// --- Overleaf patch: request-input lockdown compatibility ---
+// This library reads req.query/req.body directly to parse the SAML protocol
+// fields (SAMLResponse, SAMLRequest, RelayState) it's built around, which is
+// otherwise indistinguishable from unvalidated application code reading raw
+// request input under Overleaf's request-input lockdown (see
+// libraries/validation-tools and the patched express in
+// .yarn/patches/express-npm-*.patch). The lockdown captures the original
+// parsed values in symbol-keyed fields, registered in the global symbol
+// registry (the same ones @overleaf/validation-tools' lockdown.js reads), and
+// only turns the *public* req.query/req.body properties into throwing
+// accessors -- reading through these raw fields instead sidesteps that, and
+// is identical to plain req.query/req.body when the lockdown isn't installed
+// (i.e. for every consumer of this package outside this monorepo).
+const __overleafLockdownInstalled = Symbol.for("overleaf.lockdown.installed");
+const __overleafLockdownRawQuery = Symbol.for("overleaf.lockdown.rawQuery");
+const __overleafLockdownRawBody = Symbol.for("overleaf.lockdown.rawBody");
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
class AbstractStrategy extends passport_strategy_1.Strategy {
constructor(options, signonVerify, logoutVerify) {
super();
@@ -52,7 +75,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
catch (_c) {
userMatch = false;
}
- const RelayState = ((_a = req.query) === null || _a === void 0 ? void 0 : _a.RelayState) || ((_b = req.body) === null || _b === void 0 ? void 0 : _b.RelayState);
+ const RelayState = ((_a = __overleafRawQuery(req)) === null || _a === void 0 ? void 0 : _a.RelayState) || ((_b = __overleafRawBody(req)) === null || _b === void 0 ? void 0 : _b.RelayState);
if (this._saml == null) {
return this.error(new Error("Can't get logout response URL without a SAML provider defined."));
}
@@ -125,22 +148,22 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
this.redirect(url);
}
};
- if (((_a = req.query) === null || _a === void 0 ? void 0 : _a.SAMLResponse) || ((_b = req.query) === null || _b === void 0 ? void 0 : _b.SAMLRequest)) {
+ if (((_a = __overleafRawQuery(req)) === null || _a === void 0 ? void 0 : _a.SAMLResponse) || ((_b = __overleafRawQuery(req)) === null || _b === void 0 ? void 0 : _b.SAMLRequest)) {
const originalQuery = (_c = url.parse(req.url).query) !== null && _c !== void 0 ? _c : "";
this._saml
- .validateRedirectAsync(req.query, originalQuery)
+ .validateRedirectAsync(__overleafRawQuery(req), originalQuery)
.then(validateCallback)
.catch((err) => this.error(err));
}
- else if ((_d = req.body) === null || _d === void 0 ? void 0 : _d.SAMLResponse) {
+ else if ((_d = __overleafRawBody(req)) === null || _d === void 0 ? void 0 : _d.SAMLResponse) {
this._saml
- .validatePostResponseAsync(req.body)
+ .validatePostResponseAsync(__overleafRawBody(req))
.then(validateCallback)
.catch((err) => this.error(err));
}
- else if ((_e = req.body) === null || _e === void 0 ? void 0 : _e.SAMLRequest) {
+ else if ((_e = __overleafRawBody(req)) === null || _e === void 0 ? void 0 : _e.SAMLRequest) {
this._saml
- .validatePostRequestAsync(req.body)
+ .validatePostRequestAsync(__overleafRawBody(req))
.then(validateCallback)
.catch((err) => this.error(err));
}
@@ -151,7 +174,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
if (this._saml == null) {
throw new Error("Can't process login request without a SAML provider defined.");
}
- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState);
+ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState);
const host = req.headers && req.headers.host;
if (this._saml.options.authnRequestBinding === "HTTP-POST") {
const data = await this._saml.getAuthorizeFormAsync(RelayState, host);
@@ -172,7 +195,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
throw new Error("Can't process logout request without a SAML provider defined.");
}
try {
- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState);
+ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState);
// Defaults to HTTP-Redirect
this.redirect(await this._saml.getLogoutUrlAsync(req.user, RelayState, options));
}
@@ -188,7 +211,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
if (this._saml == null) {
throw new Error("Can't logout without a SAML provider defined.");
}
- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState);
+ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState);
this._saml
.getLogoutUrlAsync(req.user, RelayState, {})
.then((url) => callback(null, url))
@@ -0,0 +1,46 @@
diff --git a/index.js b/index.js
index 367bdbafbc71b61daf3907913c3861d155140f07..b31ff2d98186ac29446718edfccc03a3562e796a 100644
--- a/index.js
+++ b/index.js
@@ -17,6 +17,30 @@ var Cookie = require('cookie')
var createError = require('http-errors')
var sign = require('cookie-signature').sign
var Tokens = require('csrf')
+// --- Overleaf patch: request-input lockdown compatibility ---
+// The default value getter reads req.body/req.query directly to find the
+// CSRF token submitted alongside a mutating request -- otherwise
+// indistinguishable from unvalidated application code reading raw request
+// input under Overleaf's request-input lockdown (see
+// libraries/validation-tools and the patched express in
+// .yarn/patches/express-npm-*.patch). The lockdown captures the original
+// parsed values in symbol-keyed fields, registered in the global symbol
+// registry (the same ones @overleaf/validation-tools' lockdown.js reads),
+// and only turns the *public* req.query/req.body properties into throwing
+// accessors -- reading through these raw fields instead sidesteps that, and
+// is identical to plain req.query/req.body when the lockdown isn't
+// installed (i.e. for every consumer of this package outside this
+// monorepo).
+var __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed')
+var __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery')
+var __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody')
+function __overleafRawQuery (req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query
+}
+function __overleafRawBody (req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body
+}
+// --- end Overleaf patch ---
/**
* Module exports.
@@ -128,8 +152,8 @@ function csurf (options) {
*/
function defaultValue (req) {
- return (req.body && req.body._csrf) ||
- (req.query && req.query._csrf) ||
+ return (__overleafRawBody(req) && __overleafRawBody(req)._csrf) ||
+ (__overleafRawQuery(req) && __overleafRawQuery(req)._csrf) ||
(req.headers['csrf-token']) ||
(req.headers['xsrf-token']) ||
(req.headers['x-csrf-token']) ||
@@ -0,0 +1,55 @@
diff --git a/index.js b/index.js
index 49f181005f74cf11db3bd5b0ecf512520163ec71..d47ad4270883b90766152342c99495a07cb952e0 100644
--- a/index.js
+++ b/index.js
@@ -1,5 +1,29 @@
const parseCookie = require('cookie').parse;
const decodeCookie = require('cookie-parser').signedCookie;
+// --- Overleaf patch: request-input lockdown compatibility ---
+// This middleware is mounted globally, before any router split, and reads
+// req.query/req.body directly to look for a bearer token -- otherwise
+// indistinguishable from unvalidated application code reading raw request
+// input under Overleaf's request-input lockdown (see
+// libraries/validation-tools and the patched express in
+// .yarn/patches/express-npm-*.patch). The lockdown captures the original
+// parsed values in symbol-keyed fields, registered in the global symbol
+// registry (the same ones @overleaf/validation-tools' lockdown.js reads),
+// and only turns the *public* req.query/req.body properties into throwing
+// accessors -- reading through these raw fields instead sidesteps that, and
+// is identical to plain req.query/req.body when the lockdown isn't
+// installed (i.e. for every consumer of this package outside this
+// monorepo).
+const __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+const __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+const __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
const getCookie = (serialized_cookies, key) => parseCookie(serialized_cookies)[key] || false;
@@ -32,16 +56,16 @@ module.exports = opts => {
let error;
// query
- if (req.query && req.query[queryKey]) {
- token = req.query[queryKey];
+ if (__overleafRawQuery(req) && __overleafRawQuery(req)[queryKey]) {
+ token = __overleafRawQuery(req)[queryKey];
}
// body
- if (req.body && req.body[bodyKey]) {
+ if (__overleafRawBody(req) && __overleafRawBody(req)[bodyKey]) {
if (token) {
error = true;
}
- token = req.body[bodyKey];
+ token = __overleafRawBody(req)[bodyKey];
}
// headers
@@ -0,0 +1,25 @@
diff --git a/lib/strategy.js b/lib/strategy.js
index e54e92a86c8e3d40338ed8c6e57e8729393d85a3..cad017ef2027ad676782515a9803835138efa6b3 100644
--- a/lib/strategy.js
+++ b/lib/strategy.js
@@ -66,10 +66,18 @@ util.inherits(Strategy, passport.Strategy);
* @param {Object} req
* @api protected
*/
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed');
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody');
+var LOCKDOWN_RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery');
+
Strategy.prototype.authenticate = function(req, options) {
options = options || {};
- var username = lookup(req.body, this._usernameField) || lookup(req.query, this._usernameField);
- var password = lookup(req.body, this._passwordField) || lookup(req.query, this._passwordField);
+ // with the Overleaf request-input lockdown installed, read the raw
+ // symbol-keyed fields; the public req.body/req.query accessors warn/throw
+ var body = req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body;
+ var query = req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_QUERY] : req.query;
+ var username = lookup(body, this._usernameField) || lookup(query, this._usernameField);
+ var password = lookup(body, this._passwordField) || lookup(query, this._passwordField);
if (!username || !password) {
return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400);
+73 -1
View File
@@ -1,10 +1,82 @@
diff --git a/lib/strategy.js b/lib/strategy.js
index b05aacce9a16fd32743a989b68f179bb35b20545..de1293a75c82452089eea37ab1031d4bbe42849c 100644
--- a/lib/strategy.js
+++ b/lib/strategy.js
@@ -14,6 +14,25 @@ var passport = require('passport-strategy')
, AuthorizationError = require('./errors/authorizationerror')
, TokenError = require('./errors/tokenerror')
, InternalOAuthError = require('./errors/internaloautherror');
+// --- Overleaf patch: request-input lockdown compatibility ---
+// This strategy reads req.query directly to parse the OAuth2 protocol
+// fields (error/error_description/error_uri, code, state) it's built
+// around, which is otherwise indistinguishable from unvalidated application
+// code reading raw request input under Overleaf's request-input lockdown
+// (see libraries/validation-tools and the patched express in
+// .yarn/patches/express-npm-*.patch). The lockdown captures the original
+// parsed values in symbol-keyed fields, registered in the global symbol
+// registry (the same ones @overleaf/validation-tools' lockdown.js reads),
+// and only turns the *public* req.query property into a throwing accessor
+// -- reading through this raw field instead sidesteps that, and is
+// identical to plain req.query when the lockdown isn't installed (i.e. for
+// every consumer of this package outside this monorepo).
+var __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+var __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+// --- end Overleaf patch ---
/**
@@ -131,11 +150,11 @@ OAuth2Strategy.prototype.authenticate = function(req, options) {
options = options || {};
var self = this;
- if (req.query && req.query.error) {
- if (req.query.error == 'access_denied') {
- return this.fail({ message: req.query.error_description });
+ if (__overleafRawQuery(req) && __overleafRawQuery(req).error) {
+ if (__overleafRawQuery(req).error == 'access_denied') {
+ return this.fail({ message: __overleafRawQuery(req).error_description });
} else {
- return this.error(new AuthorizationError(req.query.error_description, req.query.error, req.query.error_uri));
+ return this.error(new AuthorizationError(__overleafRawQuery(req).error_description, __overleafRawQuery(req).error, __overleafRawQuery(req).error_uri));
}
}
@@ -156,14 +175,14 @@ OAuth2Strategy.prototype.authenticate = function(req, options) {
callbackURL: callbackURL
}
- if (req.query && req.query.code) {
+ if (__overleafRawQuery(req) && __overleafRawQuery(req).code) {
function loaded(err, ok, state) {
if (err) { return self.error(err); }
if (!ok) {
return self.fail(state, 403);
}
- var code = req.query.code;
+ var code = __overleafRawQuery(req).code;
var params = self.tokenParams(options);
params.grant_type = 'authorization_code';
@@ -213,7 +232,7 @@ OAuth2Strategy.prototype.authenticate = function(req, options) {
);
}
- var state = req.query.state;
+ var state = __overleafRawQuery(req).state;
try {
var arity = this._stateStore.verify.length;
if (arity == 4) {
diff --git a/lib/utils.js b/lib/utils.js
index 486f9e1..4584507 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -24,7 +24,7 @@ exports.originalURL = function(req, options) {
var trustProxy = options.proxy;
var proto = (req.headers['x-forwarded-proto'] || '').toLowerCase()
- , tls = req.connection.encrypted || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
+ , tls = (req.connection && req.connection.encrypted) || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
@@ -1,5 +1,6 @@
import RefererParser from 'referer-parser'
import { URL } from 'node:url'
import { z } from '@overleaf/validation-tools'
const UTM_KEYS = [
'utm_campaign',
@@ -11,6 +12,12 @@ const UTM_KEYS = [
'utm_id',
]
const utmQuerySchema = z.object({
query: z.object(
Object.fromEntries(UTM_KEYS.map(key => [key, z.string().optional()]))
),
})
function parseUtm(query) {
const utmValues = {}
for (const utmKey of UTM_KEYS) {
@@ -61,6 +68,7 @@ function stripUTMKeys(query) {
export default {
UTM_KEYS,
REGISTRATION_UTM_KEYS,
utmQuerySchema,
parseUtm,
parseReferrer,
stripUTMKeys,
@@ -16,6 +16,11 @@ import passport from 'passport'
import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
import UrlHelper from '../Helpers/UrlHelper.mjs'
import AsyncFormHelper from '../Helpers/AsyncFormHelper.mjs'
import {
getRawReqInput,
parseReq,
z,
} from '../../infrastructure/Validation.mjs'
import _ from 'lodash'
import UserAuditLogHandler from '../User/UserAuditLogHandler.mjs'
import AnalyticsRegistrationSourceHelper from '../Analytics/AnalyticsRegistrationSourceHelper.mjs'
@@ -29,6 +34,12 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
const { hasAdminAccess } = AdminAuthorizationHelper
// middleware schema is non-strict: it validates only the field this
// middleware consumes; the route schema stays responsible for strictness
const zipUrlQuerySchema = z.object({
query: z.object({ zipUrl: z.string().optional() }),
})
function send401WithChallenge(res) {
res.setHeader('WWW-Authenticate', 'OverleafLogin')
res.sendStatus(401)
@@ -534,9 +545,12 @@ const AuthenticationController = {
setRedirectInSession(req, value) {
if (value == null) {
// the full query string is re-encoded into the post-login redirect
// path verbatim, never read by name here (case 1: verbatim forwarding)
const { query } = getRawReqInput(req)
value =
Object.keys(req.query).length > 0
? `${req.path}?${querystring.stringify(req.query)}`
Object.keys(query).length > 0
? `${req.path}?${querystring.stringify(query)}`
: `${req.path}`
}
if (
@@ -550,8 +564,9 @@ const AuthenticationController = {
},
_redirectToLoginOrRegisterPage(req, res) {
const { query } = parseReq(req, zipUrlQuerySchema, { logOnly: true })
if (
req.query.zipUrl != null ||
query.zipUrl != null ||
req.session.sharedProjectData ||
req.path === '/user/subscription/new'
) {
@@ -567,7 +582,9 @@ const AuthenticationController = {
'user not logged in so redirecting to login page'
)
AuthenticationController.setRedirectInSession(req)
const url = `/login?${querystring.stringify(req.query)}`
// the full query string is re-encoded into the login redirect URL
// verbatim, never read by name here (case 1: verbatim forwarding)
const url = `/login?${querystring.stringify(getRawReqInput(req).query)}`
res.redirect(url)
Metrics.inc('security.login-redirect')
},
@@ -588,7 +605,9 @@ const AuthenticationController = {
'user not logged in so redirecting to register page'
)
AuthenticationController.setRedirectInSession(req)
const url = `/register?${querystring.stringify(req.query)}`
// the full query string is re-encoded into the register redirect URL
// verbatim, never read by name here (case 1: verbatim forwarding)
const url = `/register?${querystring.stringify(getRawReqInput(req).query)}`
res.redirect(url)
Metrics.inc('security.login-redirect')
},
@@ -1,8 +1,6 @@
import AuthorizationManager from './AuthorizationManager.mjs'
import logger from '@overleaf/logger'
import mongodb from 'mongodb-legacy'
import Errors from '../Errors/Errors.js'
import HttpErrorHandler from '../Errors/HttpErrorHandler.mjs'
import AuthenticationController from '../Authentication/AuthenticationController.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
@@ -11,8 +9,41 @@ import { expressify } from '@overleaf/promise-utils'
import AdminAuthorizationHelper from '../Helpers/AdminAuthorizationHelper.mjs'
import UrlHelper from '../Helpers/UrlHelper.mjs'
import ChatApiHandler from '../Chat/ChatApiHandler.mjs'
import {
getRawReqInput,
parseReq,
z,
zz,
} from '../../infrastructure/Validation.mjs'
const { ObjectId } = mongodb
// middleware schemas are non-strict: they validate only the fields this
// middleware consumes; the route schema stays responsible for strictness
const projectIdsQuerySchema = z.object({
query: z.object({ project_ids: z.string().optional() }),
})
const projectIdParamsSchema = z.object({
params: z.object({
project_id: zz.objectId().optional(),
Project_id: zz.objectId().optional(),
}),
})
const threadIdParamsSchema = z.object({
params: z.object({ thread_id: zz.objectId().optional() }),
})
const messageIdParamsSchema = z.object({
params: z.object({ message_id: zz.objectId().optional() }),
})
const settingsBodySchema = z.object({
body: z.object({ name: z.string().optional() }),
})
const restrictedQuerySchema = z.object({
query: z.object({ from: z.string().optional() }),
})
function _handleAdminDomainRedirect(req, res) {
if (
@@ -28,7 +59,8 @@ function _handleAdminDomainRedirect(req, res) {
}
async function ensureUserCanReadMultipleProjects(req, res, next) {
const projectIds = (req.query.project_ids || '').split(',')
const { query } = parseReq(req, projectIdsQuerySchema, { logOnly: true })
const projectIds = (query.project_ids || '').split(',')
const userId = _getUserId(req)
for (const projectId of projectIds) {
const token = TokenAccessHandler.getRequestToken(req, projectId)
@@ -81,8 +113,9 @@ async function ensureUserCanWriteProjectSettings(req, res, next) {
const projectId = _getProjectId(req)
const userId = _getUserId(req)
const token = TokenAccessHandler.getRequestToken(req, projectId)
const { body } = parseReq(req, settingsBodySchema, { logOnly: true })
if (req.body.name != null) {
if (body.name != null) {
const canRename = await AuthorizationManager.promises.canUserRenameProject(
userId,
projectId,
@@ -93,7 +126,11 @@ async function ensureUserCanWriteProjectSettings(req, res, next) {
}
}
const otherParams = Object.keys(req.body).filter(x => x !== 'name')
// raw access justified: inspects the key set (not the values) to decide
// which authorization check applies; the route schema validates the values
const otherParams = Object.keys(getRawReqInput(req).body).filter(
x => x !== 'name'
)
if (otherParams.length > 0) {
const canWrite =
await AuthorizationManager.promises.canUserWriteProjectSettings(
@@ -243,35 +280,27 @@ async function ensureUserIsSiteAdmin(req, res, next) {
}
function _getProjectId(req) {
const projectId = req.params.project_id || req.params.Project_id
const { params } = parseReq(req, projectIdParamsSchema)
const projectId = params.project_id || params.Project_id
if (!projectId) {
throw new Error('Expected project_id in request parameters')
}
if (!ObjectId.isValid(projectId)) {
throw new Errors.NotFoundError(`invalid projectId: ${projectId}`)
}
return projectId
}
function _getThreadId(req) {
const threadId = req.params.thread_id
const threadId = parseReq(req, threadIdParamsSchema).params.thread_id
if (!threadId) {
throw new Error('Expected thread_id in request parameters')
}
if (!ObjectId.isValid(threadId)) {
throw new Errors.NotFoundError(`invalid threadId: ${threadId}`)
}
return threadId
}
function _getMessageId(req) {
const messageId = req.params.message_id
const messageId = parseReq(req, messageIdParamsSchema).params.message_id
if (!messageId) {
throw new Error('Expected message_id in request parameters')
}
if (!ObjectId.isValid(messageId)) {
throw new Errors.NotFoundError(`invalid messageId: ${messageId}`)
}
return messageId
}
@@ -292,7 +321,9 @@ function restricted(req, res, next) {
if (SessionManager.isUserLoggedIn(req.session)) {
return res.render('user/restricted', { title: 'restricted' })
}
const { from } = req.query
const { from } = parseReq(req, restrictedQuerySchema, {
logOnly: true,
}).query
logger.debug({ from }, 'redirecting to login')
if (from) {
AuthenticationController.setRedirectInSession(req, from)
@@ -7,6 +7,30 @@ import DeviceHistory from './DeviceHistory.mjs'
import AuthenticationController from '../Authentication/AuthenticationController.mjs'
import { expressify } from '@overleaf/promise-utils'
import EmailsHelper from '../Helpers/EmailHelper.mjs'
import {
z,
parseReq,
getRawReqInput,
} from '../../infrastructure/Validation.mjs'
// Non-strict: this middleware only reads the two fields it needs off bodies
// whose full shape is owned by each route's own controller (login,
// passwordReset, addEmail, register) -- rejecting/stripping other fields
// here isn't this middleware's job.
const captchaBodySchema = z.object({
body: z.object({
email: z.string().optional(),
// Google reCAPTCHA verification token: an opaque, third-party-defined
// string (its format is a Google implementation detail, not documented
// or guaranteed stable), so this stays a bare string rather than a
// tighter pattern. The frontend's ReCaptcha2 component (recaptcha-2.tsx)
// sends an explicit `null` -- not an omitted field -- whenever it never
// mounted the widget (no sitekey configured, the action's captcha is
// disabled, or `window.Cypress` in dev E2E runs), so this must accept
// null, not just a missing key.
'g-recaptcha-response': z.string().nullish(),
}),
})
function respondInvalidCaptcha(req, res) {
res.status(400).json({
@@ -27,24 +51,39 @@ async function initializeDeviceHistory(req) {
}
async function canSkipCaptcha(req, res) {
const { body: reqBody } = parseReq(req, captchaBodySchema, {
logOnly: true,
})
const trustedUser =
req.body?.email &&
(Settings.recaptcha.trustedUsers.includes(req.body.email) ||
Settings.recaptcha.trustedUsersRegex?.test(req.body.email))
reqBody.email &&
(Settings.recaptcha.trustedUsers.includes(reqBody.email) ||
Settings.recaptcha.trustedUsersRegex?.test(reqBody.email))
if (trustedUser) {
return res.json(true)
}
await initializeDeviceHistory(req)
const canSkip = req.deviceHistory.has(req.body?.email)
const canSkip = req.deviceHistory.has(reqBody.email)
Metrics.inc('captcha_pre_flight', 1, {
status: canSkip ? 'skipped' : 'missing',
})
res.json(canSkip)
}
function stripRecaptchaField(req) {
const { body } = getRawReqInput(req)
if (body && 'g-recaptcha-response' in body) {
const { 'g-recaptcha-response': _x, ...rest } = body
req.body = rest
}
}
function validateCaptcha(action) {
return expressify(async function (req, res, next) {
const email = EmailsHelper.parseEmail(req.body?.email)
const { body: reqBody } = parseReq(req, captchaBodySchema, {
logOnly: true,
})
stripRecaptchaField(req)
const email = EmailsHelper.parseEmail(reqBody.email)
const trustedUser =
email &&
(Settings.recaptcha.trustedUsers.includes(email) ||
@@ -63,7 +102,7 @@ function validateCaptcha(action) {
Metrics.inc('captcha', 1, { path: action, status: 'trusted' })
return next()
}
const reCaptchaResponse = req.body['g-recaptcha-response']
const reCaptchaResponse = reqBody['g-recaptcha-response']
if (action === 'login') {
await initializeDeviceHistory(req)
const fromKnownDevice = req.deviceHistory.has(email)
@@ -2,6 +2,7 @@ import logger from '@overleaf/logger'
import SessionManager from '../Authentication/SessionManager.mjs'
import LoginRateLimiter from './LoginRateLimiter.mjs'
import settings from '@overleaf/settings'
import { getRawReqInput } from '../../infrastructure/Validation.mjs'
/**
* Return a rate limiting middleware
@@ -36,7 +37,9 @@ function rateLimit(rateLimiter, opts = {}) {
let key = clientId
if (!opts.ipOnly) {
const params = (opts.params || []).map(p => req.params[p])
// rate limiting runs before validation; raw access is allowlisted
const rawParams = getRawReqInput(req).params
const params = (opts.params || []).map(p => rawParams[p])
params.push(clientId)
key = params.join(':')
}
@@ -58,7 +61,7 @@ function rateLimit(rateLimiter, opts = {}) {
function loginRateLimitEmail(emailField = 'email') {
return function (req, res, next) {
const email = req.body[emailField]
const email = getRawReqInput(req).body[emailField]
if (!email) {
return next()
}
+30 -1
View File
@@ -2,9 +2,27 @@ import csurf from 'csurf'
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import { callbackify } from '@overleaf/promise-utils'
import { getRawReqInput } from './Validation.mjs'
const csrf = csurf()
// Native HTML form submissions (and hydrate-form.ts's async-form helper,
// which packs all FormData into its JSON body) carry the token as a hidden
// `_csrf` field alongside the request's real payload. Strip it once it has
// served its purpose so route schemas never need to declare a field they
// don't otherwise care about.
function stripCsrfField(req) {
const { body, query } = getRawReqInput(req)
if (body && '_csrf' in body) {
const { _csrf, ...rest } = body
req.body = rest
}
if (query && '_csrf' in query) {
const { _csrf, ...rest } = query
req.query = rest
}
}
function blockCrossOriginRequests() {
return function (req, res, next) {
const { origin } = req.headers
@@ -79,6 +97,10 @@ export class Csrf {
// check whether the request method is excluded for the specified route
if (this.excluded_routes[req.path]?.[req.method] === 1) {
// ignore the error if it's due to a bad csrf token, and continue
// without stripping `_csrf`: excluded routes still see their own
// separate enforcement further down their middleware stack (see
// OpenInOverleafMiddleware.postMiddleware), which needs the field
// to still be there
csrf(req, res, err => {
if (err && err.code !== 'EBADCSRFTOKEN') {
next(err)
@@ -87,7 +109,14 @@ export class Csrf {
}
})
} else {
csrf(req, res, next)
csrf(req, res, err => {
if (err) {
next(err)
} else {
stripCsrfField(req)
next()
}
})
}
}
}
@@ -7,6 +7,7 @@ import moment from 'moment'
import { fetchJson } from '@overleaf/fetch-utils'
import contentDisposition from 'content-disposition'
import Features from './Features.mjs'
import { getRawReqInput } from './Validation.mjs'
import SessionManager from '../Features/Authentication/SessionManager.mjs'
import PackageVersions from './PackageVersions.js'
import Modules from './Modules.mjs'
@@ -256,8 +257,10 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
})
webRouter.use(function (req, res, next) {
// template locals helper predates validation; raw access is allowlisted
const { query } = getRawReqInput(req)
res.locals.getReqQueryParam = field =>
req.query != null ? req.query[field] : undefined
query != null ? query[field] : undefined
next()
})
@@ -333,7 +336,8 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
})
webRouter.use(function (req, res, next) {
res.locals.websiteRedesignOverride = req.query.redesign === 'enabled'
res.locals.websiteRedesignOverride =
getRawReqInput(req).query.redesign === 'enabled'
next()
})
@@ -14,6 +14,7 @@
*/
import settings from '@overleaf/settings'
import { URL } from 'node:url'
import { getRawReqInput } from './Validation.mjs'
let RedirectManager
@@ -54,7 +55,8 @@ export default RedirectManager = {
}
if (typeof target.url === 'function') {
url = target.url(req.params)
// redirects run outside validation; raw access is allowlisted
url = target.url(getRawReqInput(req).params)
if (!url) {
return next()
}
@@ -1,5 +1,6 @@
import Settings from '@overleaf/settings'
import OError from '@overleaf/o-error'
import { getRawReqInput } from './Validation.mjs'
const botUserAgents = [
'kube-probe',
@@ -79,7 +80,8 @@ class SessionAutostartMiddleware {
middleware(req, _res, next) {
if (
!req.signedCookies[this._cookieName] &&
req.query?.autostartSession !== 'true'
// session bootstrap runs before validation; raw access is allowlisted
getRawReqInput(req).query?.autostartSession !== 'true'
) {
const callback = this.autostartCallbackForRequest(req)
if (callback) {
@@ -117,11 +119,12 @@ class SessionAutostartMiddleware {
)
}
if (req.body.viaGateway) {
const { body } = getRawReqInput(req)
if (body.viaGateway) {
return next()
}
res.render('general/post-gateway', { form_data: req.body })
res.render('general/post-gateway', { form_data: body })
}
}
@@ -2,9 +2,24 @@ import Bowser from 'bowser'
import Settings from '@overleaf/settings'
import Url from 'node:url'
import UrlHelper from '../Features/Helpers/UrlHelper.mjs'
import { z, parseReq } from './Validation.mjs'
const { getSafeRedirectPath } = UrlHelper
// middleware schema is non-strict: it validates only the header this
// middleware consumes, applied globally to every route
const unsupportedBrowserMiddlewareSchema = z.object({
headers: z.object({
'user-agent': z.string().optional(),
}),
})
const renderUnsupportedBrowserPageSchema = z.object({
query: z.object({
fromURL: z.string().optional(),
}),
})
function unsupportedBrowserMiddleware(req, res, next) {
if (!Settings.unsupportedBrowsers) return next()
@@ -12,7 +27,10 @@ function unsupportedBrowserMiddleware(req, res, next) {
const path = req.path
if (path === '/unsupported-browser') return next()
const userAgent = req.headers['user-agent']
const { headers } = parseReq(req, unsupportedBrowserMiddlewareSchema, {
logOnly: true,
})
const userAgent = headers['user-agent']
if (!userAgent) return next()
@@ -36,11 +54,13 @@ function unsupportedBrowserMiddleware(req, res, next) {
}
function renderUnsupportedBrowserPage(req, res) {
const { query } = parseReq(req, renderUnsupportedBrowserPageSchema, {
logOnly: true,
})
let fromURL
if (typeof req.query.fromURL === 'string') {
if (typeof query.fromURL === 'string') {
try {
fromURL =
Settings.siteUrl + (getSafeRedirectPath(req.query.fromURL) || '/')
fromURL = Settings.siteUrl + (getSafeRedirectPath(query.fromURL) || '/')
} catch (e) {}
}
res.render('general/unsupported-browser', { fromURL })
@@ -1,11 +1,27 @@
// @ts-check
import { parseReq, z, zz } from '@overleaf/validation-tools'
import {
parseReq,
z,
zz,
getRawReqInput,
isLockdownInstalled,
} from '@overleaf/validation-tools'
export { InvalidParamsError, parseReq, z, zz } from '@overleaf/validation-tools'
export {
InvalidParamsError,
InvalidRequestError,
parseReq,
z,
zz,
getRawReqInput,
isLockdownInstalled,
} from '@overleaf/validation-tools'
export default {
parseReq,
z,
zz,
getRawReqInput,
isLockdownInstalled,
}
+22 -5
View File
@@ -65,6 +65,12 @@ import { plainTextResponse } from './infrastructure/Response.mjs'
import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs'
import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs'
import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.mjs'
import {
getRawReqInput,
parseReq,
z,
zz,
} from './infrastructure/Validation.mjs'
const { renderUnsupportedBrowserPage, unsupportedBrowserMiddleware } =
UnsupportedBrowserMiddleware
@@ -203,6 +209,12 @@ const rateLimiters = {
}),
}
const statusCompilerSchema = z.object({
params: z.strictObject({
Project_id: zz.objectId(),
}),
})
async function initialize(webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(unsupportedBrowserMiddleware)
@@ -1196,7 +1208,9 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
RateLimiterMiddleware.rateLimit(rateLimiters.statusCompiler),
AuthorizationMiddleware.ensureUserCanReadProject,
function (req, res) {
const projectId = req.params.Project_id
const {
params: { Project_id: projectId },
} = parseReq(req, statusCompilerSchema, { logOnly: true })
// use a valid user id for testing
const testUserId = '123456789012345678901234'
const sendRes = _.once(function (statusCode, message, clsiServerId) {
@@ -1247,10 +1261,13 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
)
webRouter.post('/error/client', function (req, res, next) {
logger.warn(
{ err: req.body.error, meta: req.body.meta },
'client side error'
)
// This route only logs whatever diagnostic payload the client reports
// (arbitrary properties copied off a JS Error, plus free-form metadata --
// see ide-react-context.tsx's reportError) and always 204s; it never
// branches on the content, so there is nothing to validate it against.
// (case 2: final error-handler logging)
const { error, meta } = getRawReqInput(req).body
logger.warn({ err: error, meta }, 'client side error')
metrics.inc('client-side-error')
res.sendStatus(204)
})
+5 -5
View File
@@ -89,8 +89,8 @@
"@customerio/cdp-analytics-node": "^0.3.9",
"@google-cloud/bigquery": "^8.1.1",
"@google-cloud/storage": "^7.19.0",
"@node-oauth/oauth2-server": "^5.3.0",
"@node-saml/passport-saml": "^5.1.0",
"@node-oauth/oauth2-server": "patch:@node-oauth/oauth2-server@npm%3A5.3.0#~/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch",
"@node-saml/passport-saml": "patch:@node-saml/passport-saml@npm%3A5.1.0#~/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch",
"@overleaf/access-token-encryptor": "workspace:*",
"@overleaf/fetch-utils": "workspace:*",
"@overleaf/logger": "workspace:*",
@@ -128,14 +128,14 @@
"cookie": "^0.2.3",
"cookie-parser": "1.4.6",
"crc-32": "^1.2.2",
"csurf": "^1.11.0",
"csurf": "patch:csurf@npm%3A1.11.0#~/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch",
"csv": "^6.2.5",
"dateformat": "1.0.4-1.2.3",
"ejs": "^3.1.10",
"email-addresses": "^5.0.0",
"eventsource-parser": "^1.1.2",
"express": "4.22.1",
"express-bearer-token": "^2.4.0",
"express-bearer-token": "patch:express-bearer-token@npm%3A2.4.0#~/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch",
"express-http-proxy": "^1.6.0",
"express-session": "^1.17.1",
"file-type": "^21.3.4",
@@ -174,7 +174,7 @@
"passport-google-oauth20": "^2.0.0",
"passport-ldapauth": "^2.1.4",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.5.0",
"passport-oauth2": "patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch",
"passport-orcid": "0.0.4",
"pug": "^3.0.3",
"pug-runtime": "^3.0.1",
@@ -3,6 +3,8 @@ import { expect } from 'chai'
import Settings from '@overleaf/settings'
import UserHelper from './helpers/User.mjs'
import MockHaveIBeenPwnedApiClass from './mocks/MockHaveIBeenPwnedApi.mjs'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
const User = UserHelper.promises
@@ -12,6 +14,14 @@ before(function () {
})
describe('Captcha', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
afterEach(function () {
setReqValidationModeForTests(null)
})
let user
beforeEach('create user', async function () {
@@ -92,6 +102,55 @@ describe('Captcha', function () {
expectSuccessfulLogin(response, body)
})
it('should reject a non-string g-recaptcha-response', async function () {
await user.getCsrfToken()
const { response, body } = await user.doRequest('POST', {
url: '/login',
json: {
email: user.email,
password: user.password,
'g-recaptcha-response': ['not', 'a', 'string'],
},
})
expectValidationErrorRaw(
{ statusCode: response.statusCode, body },
400,
'g-recaptcha-response'
)
})
it('should accept (and then reject for missing captcha) 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). captchaBodySchema must accept that shape (schema-level
// regression check); the request should still be treated as "no
// captcha provided" downstream, same as the empty-string case above.
await user.getCsrfToken()
const { response, body } = await user.doRequest('POST', {
url: '/login',
json: {
email: user.email,
password: user.password,
'g-recaptcha-response': null,
},
})
expectBadCaptchaResponse(response, body)
})
it('should reject a non-string email at can-skip-captcha', async function () {
await user.getCsrfToken()
const { response, body } = await user.doRequest('POST', {
url: '/login/can-skip-captcha',
json: { email: ['not', 'a', 'string'] },
})
expectValidationErrorRaw(
{ statusCode: response.statusCode, body },
400,
'email'
)
})
it('should note the solved captcha in audit log', async function () {
const { response, body } = await loginWithCaptcha('valid')
expectSuccessfulLogin(response, body)
@@ -4,6 +4,7 @@ import Features from '../../../app/src/infrastructure/Features.mjs'
import MockAnalyticsApi from './mocks/MockAnalyticsApi.mjs'
import MockChatApi from './mocks/MockChatApi.mjs'
import MockClsiApi from './mocks/MockClsiApi.mjs'
import MockClsiCache from './mocks/MockClsiCache.mjs'
import MockClsiNginxApi from './mocks/MockClsiNginxApi.mjs'
import MockDocstoreApi from './mocks/MockDocstoreApi.mjs'
import MockDocUpdaterApi from './mocks/MockDocUpdaterApi.mjs'
@@ -23,6 +24,7 @@ const mockOpts = {
MockChatApi.initialize(23010, mockOpts)
MockClsiApi.initialize(23013, mockOpts)
MockClsiCache.initialize(23081, mockOpts)
MockClsiNginxApi.initialize(23080, mockOpts)
MockDocstoreApi.initialize(23016, mockOpts)
MockDocUpdaterApi.initialize(23003, mockOpts)
@@ -417,6 +417,14 @@ describe('PasswordReset', function () {
})
expect(response.status).to.equal(400)
})
it('should return 200 when a g-recaptcha-response field is included', async function () {
response = await userHelper.fetch(`/user/password/reset`, {
method: 'POST',
body: new URLSearchParams({ email, 'g-recaptcha-response': 'valid' }),
})
expect(response.status).to.equal(200)
})
})
describe('password set', function () {
it('should return 200 if password and passwordResetToken fields are valid', async function () {
@@ -0,0 +1,47 @@
import UserHelper from './helpers/User.mjs'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
const User = UserHelper.promises
describe('Router', function () {
let user
beforeEach(async function () {
user = new User()
await user.login()
})
describe('GET /status/compiler/:Project_id', function () {
it('should reject a malformed project id with 404', async function () {
const { response, body } = await user.doRequest('get', {
url: '/status/compiler/not-an-object-id',
})
expectValidationErrorRaw(
{ statusCode: response.statusCode, body },
404,
'Project_id'
)
})
})
describe('POST /error/client', function () {
it('should log an arbitrary client-reported error and return 204', async function () {
const { response } = await user.doRequest('post', {
url: '/error/client',
json: {
error: { message: 'boom', stack: 'Error: boom\n at x' },
meta: { anything: 'goes', nested: { too: true } },
},
})
response.statusCode.should.equal(204)
})
it('should not crash when error/meta are missing', async function () {
const { response } = await user.doRequest('post', {
url: '/error/client',
json: {},
})
response.statusCode.should.equal(204)
})
})
})
@@ -1,5 +1,7 @@
import { expect } from 'chai'
import { expectValidationErrorRaw } from '@overleaf/validation-tools/testUtils.js'
import User from './helpers/User.mjs'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
const botUserAgents = new Map([
[
@@ -52,6 +54,14 @@ const supportedUserAgents = new Map([
])
describe('UnsupportedBrowsers', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
afterEach(function () {
setReqValidationModeForTests(null)
})
beforeEach(function () {
this.user = new User()
})
@@ -198,4 +208,22 @@ describe('UnsupportedBrowsers', function () {
}
)
})
describe('validation errors', function () {
it('should reject a malformed fromURL query value', function (done) {
this.user.request(
{
url: '/unsupported-browser?fromURL=a&fromURL=b',
headers: {
'user-agent': unsupportedUserAgents.get('IE 11'),
},
},
(error, response) => {
expect(error).to.not.exist
expectValidationErrorRaw(response, 400, 'fromURL')
done()
}
)
})
})
})
@@ -11,9 +11,29 @@ import SplitTestHandler from '../../../../app/src/Features/SplitTests/SplitTestH
import SplitTestSessionHandler from '../../../../app/src/Features/SplitTests/SplitTestSessionHandler.mjs'
import Modules from '../../../../app/src/infrastructure/Modules.mjs'
import testLogRecorder from '@overleaf/logger/test-log-recorder.js'
import { parseReq, z } from '@overleaf/validation-tools'
const app = Server.app
const devSessionSchema = z.object({
// dev-only test route: the whole point is to copy arbitrary
// caller-supplied query keys onto the session, so this is a
// genuinely open map, not an escape hatch.
query: z.record(z.string(), z.unknown()),
})
const devSetInSessionSchema = z.object({
// dev-only test route: same genuinely-open-map shape as above, for body.
body: z.record(z.string(), z.unknown()),
})
const devGetAssignmentSchema = z.object({
query: z.object({
splitTestName: z.string(),
includeReferer: z.string().optional(),
}),
})
MongoHelper.initialize()
RedisHelper.initialize()
MockReCAPTCHAApi.initialize(2222)
@@ -29,8 +49,9 @@ before('start main app', function (done) {
router.get('/dev/session', (req, res) => {
// allow changing the session directly for testing, assign any
// properties in the query string to req.session
if (req.query && Object.keys(req.query).length > 0) {
Object.assign(req.session, req.query)
const { query } = parseReq(req, devSessionSchema)
if (Object.keys(query).length > 0) {
Object.assign(req.session, query)
}
return res.json(req.session)
})
@@ -41,7 +62,8 @@ before('start main app', function (done) {
route => route.path && route.path === '/dev/csrf',
router => {
router.post('/dev/set_in_session', (req, res) => {
for (const [key, value] of Object.entries(req.body)) {
const { body } = parseReq(req, devSetInSessionSchema)
for (const [key, value] of Object.entries(body)) {
req.session[key] = value
}
return res.sendStatus(200)
@@ -53,11 +75,11 @@ before('start main app', function (done) {
route => route.path && route.path === '/dev/csrf',
router => {
router.get('/dev/split_test/get_assignment', (req, res) => {
const { splitTestName, includeReferer } = req.query
const { query } = parseReq(req, devGetAssignmentSchema)
SplitTestHandler.promises
.getAssignment(req, res, splitTestName, {
.getAssignment(req, res, query.splitTestName, {
sync: true,
includeReferer: includeReferer === 'true',
includeReferer: query.includeReferer === 'true',
})
.then(assignment => res.json(assignment))
.catch(error => {
@@ -778,7 +778,7 @@ class User {
url: `/project/${projectId}/doc`,
json: {
name,
parentFolderId,
parent_folder_id: parentFolderId,
},
},
(error, response, body) => {
@@ -1305,12 +1305,47 @@ class User {
)
})
}
/**
* Submit a real application/x-www-form-urlencoded body over the shared
* session cookie jar, the way a plain HTML form (no JS) does: the csrf
* token only ever travels as a `_csrf` field, never as a header. Bypasses
* `this.request`, whose defaults bake in an `x-csrf-token` header once
* `getCsrfToken` has run, which a genuine native-form submission never
* sends.
*/
submitNativeForm(url, fields, callback) {
request.post(
{ url, jar: this.jar, form: { _csrf: this.csrfToken, ...fields } },
callback
)
}
}
User.promises = promisifyClass(User, {
without: ['setExtraAttributes', 'sessionCookie', 'setSessionCookie'],
without: [
'setExtraAttributes',
'sessionCookie',
'setSessionCookie',
'submitNativeForm',
],
})
User.promises.prototype.submitNativeForm = async function (url, fields) {
return new Promise((resolve, reject) => {
request.post(
{ url, jar: this.jar, form: { _csrf: this.csrfToken, ...fields } },
(err, response, body) => {
if (err) {
reject(err)
} else {
resolve({ response, body })
}
}
)
})
}
User.promises.prototype.doRequest = async function (method, params) {
return new Promise((resolve, reject) => {
this.request[method.toLowerCase()](params, (err, response, body) => {
@@ -1,7 +1,10 @@
import logger from '@overleaf/logger'
import OError from '@overleaf/o-error'
import express from 'express'
import { handleValidationError } from '@overleaf/validation-tools'
import {
getRawReqInput,
handleValidationError,
} from '@overleaf/validation-tools'
/**
* Abstract class for running a mock API via Express. Handles setting up of
@@ -60,7 +63,11 @@ class AbstractMockApi {
applyDebugRoutes() {
if (!this.debug) return
this.app.use((req, res, next) => {
const { method, path, query, params, body } = req
const { method, path } = req
// Debug-only wire dump: this prints exactly what was sent on the
// wire for troubleshooting acceptance test failures (case 3: raw
// test assertions), not a functional read of a named field.
const { query, params, body } = getRawReqInput(req)
// eslint-disable-next-line no-console
console.log(`${this.constructor.name} REQUEST`, {
method,
@@ -1,18 +1,38 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { getRawReqInput } from '@overleaf/validation-tools'
class MockAnalyticsApi extends AbstractMockApi {
reset() {
this.updates = {}
this.lastGraphRequest = null
this.lastSplitTestAssignmentsRequest = null
this.lastSplitTestCalcRequest = null
this.lastSplitTestEventsRequest = null
}
getLastGraphRequest() {
return this.lastGraphRequest
}
getLastSplitTestAssignmentsRequest() {
return this.lastSplitTestAssignmentsRequest
}
getLastSplitTestCalcRequest() {
return this.lastSplitTestCalcRequest
}
getLastSplitTestEventsRequest() {
return this.lastSplitTestEventsRequest
}
applyRoutes() {
this.app.get('/graphs/:graph', (req, res) => {
this.lastGraphRequest = { path: req.path, query: req.query }
// case 3: recorded verbatim for later assertion by acceptance tests
this.lastGraphRequest = {
path: req.path,
query: getRawReqInput(req).query,
}
return res.json({})
})
@@ -33,6 +53,33 @@ class MockAnalyticsApi extends AbstractMockApi {
},
})
})
this.app.get('/split-test/get_daily_assignments', (req, res) => {
// case 3: recorded verbatim for later assertion by acceptance tests
this.lastSplitTestAssignmentsRequest = {
query: getRawReqInput(req).query,
}
res.json({ default: [0], 'variant-1': [0] })
})
this.app.post('/split-test/calc', (req, res) => {
// case 3: recorded verbatim for later assertion by acceptance tests
this.lastSplitTestCalcRequest = { body: getRawReqInput(req).body }
res.json({
durationDays: 14,
currentConversionRate: 0.1,
expectedConversionRate: 0.11,
requiredCohortSize: 1000,
totalRequiredUsers: 2000,
usersPerDay: 100,
})
})
this.app.get('/split-test/events', (req, res) => {
// case 3: recorded verbatim for later assertion by acceptance tests
this.lastSplitTestEventsRequest = { query: getRawReqInput(req).query }
res.json({ 'editor-open': { source: ['ide-open'] } })
})
}
}
@@ -1,9 +1,65 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
// Mirrors services/chat/app/js/Features/Messages/MessageHttpSchemas.js (this
// mock stands in for chat's own API in web's acceptance tests). This mock's
// own routes bind params as snake_case (project_id/thread_id/message_id)
// where chat's real Express routes bind the same URL shapes as camelCase
// (projectId/threadId/messageId) -- param names never travel over the wire,
// so the schemas below just follow this file's own route definitions.
// matches chat's MessageHttpSchemas.js MAX_MESSAGE_LENGTH (10kb, not 10,000
// bytes)
const MAX_MESSAGE_LENGTH = 10 * 1024
const messageContent = z.string().min(1).max(MAX_MESSAGE_LENGTH)
const projectParams = z.strictObject({ project_id: zz.objectId() })
const threadParams = projectParams.extend({ thread_id: zz.objectId() })
const messageParams = projectParams.extend({ message_id: zz.objectId() })
const threadMessageParams = threadParams.extend({ message_id: zz.objectId() })
// matches chat's sendMessageBody
const sendMessageBody = z.strictObject({
user_id: zz.objectId(),
content: messageContent,
})
// matches chat's editMessageBody -- web always sends both `content` and
// `userId` (camelCase, unlike send's `user_id`) even though the handlers
// below only read `content`.
const editMessageBody = z.strictObject({
content: messageContent,
userId: zz.objectId().optional(),
})
const getGlobalMessagesSchema = z.object({ params: projectParams })
const sendGlobalMessageSchema = z.object({
params: projectParams,
body: sendMessageBody,
})
const getGlobalMessageSchema = z.object({ params: messageParams })
const editGlobalMessageSchema = z.object({
params: messageParams,
body: editMessageBody,
})
const deleteGlobalMessageSchema = z.object({ params: messageParams })
const getThreadMessagesSchema = z.object({ params: threadParams })
const getThreadMessageSchema = z.object({ params: threadMessageParams })
const sendMessageSchema = z.object({
params: threadParams,
body: sendMessageBody,
})
const editMessageSchema = z.object({
params: threadMessageParams,
body: editMessageBody,
})
const destroyProjectSchema = z.object({ params: projectParams })
const getResolvedThreadIdsSchema = z.object({ params: projectParams })
class MockChatApi extends AbstractMockApi {
reset() {
this.projects = new Map()
this.resolvedThreadIds = new Map()
}
getThread(projectId, threadId) {
@@ -36,16 +92,42 @@ class MockChatApi extends AbstractMockApi {
this.projects.delete(projectId)
}
deleteMessage(projectId, threadId, messageId) {
const thread = this.getThread(projectId, threadId)
const index = thread.findIndex(message => message.id === messageId)
if (index === -1) return false
thread.splice(index, 1)
return true
}
editMessage(projectId, threadId, messageId, content) {
const thread = this.getThread(projectId, threadId)
const message = thread.find(message => message.id === messageId)
if (!message) return false
message.content = content
return true
}
setResolvedThreadIds(projectId, resolvedThreadIds) {
this.resolvedThreadIds.set(projectId, resolvedThreadIds)
}
getResolvedThreadIds(projectId) {
return this.resolvedThreadIds.get(projectId) || []
}
applyRoutes() {
this.app.get('/project/:project_id/messages', (req, res) => {
res.json(this.getThread(req.params.project_id, 'global'))
const { params } = parseReq(req, getGlobalMessagesSchema)
res.json(this.getThread(params.project_id, 'global'))
})
this.app.post('/project/:project_id/messages', (req, res) => {
res.json(this.sendMessage(req.params.project_id, 'global', req.body))
const { params, body } = parseReq(req, sendGlobalMessageSchema)
res.json(this.sendMessage(params.project_id, 'global', body))
})
this.app.get('/project/:project_id/messages/:message_id', (req, res) => {
const projectId = req.params.project_id
const messageId = req.params.message_id
const { params } = parseReq(req, getGlobalMessageSchema)
const { project_id: projectId, message_id: messageId } = params
const thread = this.getThread(projectId, 'global')
const message = thread.find(msg => msg.id === messageId)
if (!message) {
@@ -56,15 +138,19 @@ class MockChatApi extends AbstractMockApi {
this.app.get(
'/project/:project_id/thread/:thread_id/messages',
(req, res) => {
res.json(this.getThread(req.params.project_id, req.params.thread_id))
const { params } = parseReq(req, getThreadMessagesSchema)
res.json(this.getThread(params.project_id, params.thread_id))
}
)
this.app.get(
'/project/:project_id/thread/:thread_id/messages/:message_id',
(req, res) => {
const projectId = req.params.project_id
const threadId = req.params.thread_id
const messageId = req.params.message_id
const { params } = parseReq(req, getThreadMessageSchema)
const {
project_id: projectId,
thread_id: threadId,
message_id: messageId,
} = params
const thread = this.getThread(projectId, threadId)
const message = thread.find(msg => msg.id === messageId)
if (!message) {
@@ -76,24 +162,20 @@ class MockChatApi extends AbstractMockApi {
this.app.post(
'/project/:project_id/thread/:thread_id/messages',
(req, res) => {
res.json(
this.sendMessage(
req.params.project_id,
req.params.thread_id,
req.body
)
)
const { params, body } = parseReq(req, sendMessageSchema)
res.json(this.sendMessage(params.project_id, params.thread_id, body))
}
)
this.app.post(
'/project/:project_id/thread/:thread_id/messages/:message_id/edit',
(req, res) => {
const { params, body } = parseReq(req, editMessageSchema)
const {
project_id: projectId,
thread_id: threadId,
message_id: messageId,
} = req.params
const { content, userId } = req.body
} = params
const { content, userId } = body
const thread = this.getThread(projectId, threadId)
const message = thread.find(msg => msg.id === messageId)
if (!message || (userId && message.user_id !== userId)) {
@@ -105,10 +187,38 @@ class MockChatApi extends AbstractMockApi {
}
)
this.app.delete('/project/:project_id', (req, res) => {
const projectId = req.params.project_id
this.destroyProject(projectId)
const { params } = parseReq(req, destroyProjectSchema)
this.destroyProject(params.project_id)
res.sendStatus(204)
})
this.app.delete('/project/:project_id/messages/:message_id', (req, res) => {
const { params } = parseReq(req, deleteGlobalMessageSchema)
const found = this.deleteMessage(
params.project_id,
'global',
params.message_id
)
res.sendStatus(found ? 204 : 404)
})
this.app.post(
'/project/:project_id/messages/:message_id/edit',
(req, res) => {
const { params, body } = parseReq(req, editGlobalMessageSchema)
const found = this.editMessage(
params.project_id,
'global',
params.message_id,
body.content
)
res.sendStatus(found ? 204 : 404)
}
)
this.app.get('/project/:project_id/resolved-thread-ids', (req, res) => {
const { params } = parseReq(req, getResolvedThreadIdsSchema)
res.json({
resolvedThreadIds: this.getResolvedThreadIds(params.project_id),
})
})
}
}
@@ -1,20 +1,44 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
import { zipAttachment } from '../../../../app/src/infrastructure/Response.mjs'
// Mirrors services/clsi/app/js/CompileController.js's projectOrUserParamsSchema
// (this mock stands in for clsi's own API in web's acceptance tests).
const compileParamsSchema = z.object({
params: z.strictObject({
project_id: zz.objectId().or(zz.submissionId()),
user_id: zz.objectId().optional(),
}),
})
// Mirrors services/clsi/app/js/OutputController.js's createOutputZipSchema
// (this mock stands in for clsi's own output.zip archive generation, as
// opposed to clsi-nginx's static file serving handled by
// MockClsiNginxApi.mjs).
const outputZipParamsSchema = z.object({
params: z.strictObject({
project_id: zz.objectId().or(zz.submissionId()),
user_id: zz.objectId().optional(),
build_id: zz.buildId(),
}),
})
class MockClsiApi extends AbstractMockApi {
static compile(req, res) {
const { params } = parseReq(req, compileParamsSchema)
res.json({
compile: {
status: 'success',
error: null,
outputFiles: [
{
url: `http://clsi:8080/project/${req.params.project_id}/build/1234-5678/output/output.pdf`,
url: `http://clsi:8080/project/${params.project_id}/build/1234-5678/output/output.pdf`,
path: 'output.pdf',
type: 'pdf',
build: '1234-5678',
},
{
url: `http://clsi:8080/project/${req.params.project_id}/build/1234-5678/output/output.log`,
url: `http://clsi:8080/project/${params.project_id}/build/1234-5678/output/output.log`,
path: 'output.log',
type: 'log',
build: '1234-5678',
@@ -24,6 +48,11 @@ class MockClsiApi extends AbstractMockApi {
})
}
static outputZip(req, res) {
const { params } = parseReq(req, outputZipParamsSchema)
zipAttachment(res, `mock-zip: ${params.build_id}`, 'output.zip')
}
applyRoutes() {
this.app.post('/project/:project_id/compile', MockClsiApi.compile)
this.app.post(
@@ -34,6 +63,15 @@ class MockClsiApi extends AbstractMockApi {
this.app.get('/project/:project_id/status', (req, res) => {
res.sendStatus(200)
})
this.app.get(
'/project/:project_id/build/:build_id/output/output.zip',
MockClsiApi.outputZip
)
this.app.get(
'/project/:project_id/user/:user_id/build/:build_id/output/output.zip',
MockClsiApi.outputZip
)
}
}
@@ -0,0 +1,146 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
// Mirrors the shape of the real lookup paths ClsiCacheHandler builds (see
// getOutputFile/getLatestOutputFile in ClsiCacheHandler.mjs) -- validating
// them here (like MockV1HistoryApi's inline schema.safeParse) catches an
// app-side path-construction mistake as a 400 from this mock, rather than
// a confusing miss against `entries` that looks identical to "nothing
// cached yet" (a plain 404).
const lookupSchema = z.object({
params: z.strictObject({
project_id: zz.objectId(),
user_id: zz.objectId().optional(),
// matches clsi-cache's own getLastOutputFileParamsSchema /
// getLatestOutputFileParamsSchema (HTTPController.js)
editorBuildId: zz.editorBuildId().optional(),
// this is a wildcard `:filename(.*)` param forwarded to a storage
// lookup key, so it gets the same zz.filepath() traversal hardening as
// clsi-cache's own filenameSchema
filename: zz.filepath(),
// this repo's patched Express additionally populates req.params[0]
// (the filename minus its first character) for a *named* regex
// wildcard like `:filename(.*)` -- unlike a bare `*` (see
// MockClsiNginxApi.mjs's outputWildcardSchema, which only gets the
// numeric key). A strictObject that doesn't account for this key
// rejects every real (non-empty filename) request with a 404
// "Unrecognized key: 0" before it ever reaches the lookup below.
0: z.string(),
}),
})
const contentSchema = z.object({
params: z.strictObject({
contentId: z.string().nonempty(),
}),
})
/**
* Stand-in for a clsi-cache instance (services/clsi-cache).
*
* ClsiCacheHandler's getOutputFile/getLatestOutputFile each make a "search"
* or "latest" lookup request (redirect: 'manual') and expect either a 404
* (nothing cached) or a 3xx redirect carrying X-Zone/X-Shard/
* X-Last-Modified/X-Content-Length/X-All-Files headers and a Location the
* caller then fetches separately for the actual bytes -- see
* getRedirectWithFallback in ClsiCacheHandler.mjs. This mock reproduces both
* legs: register a cache entry at the exact lookup path the app will
* request (via `addEntry`), and this class serves the redirect plus the
* content it points to.
*/
class MockClsiCache extends AbstractMockApi {
reset() {
this.entries = new Map() // lookup path -> { contentId, lastModified, zone, shard, allFiles }
this.content = new Map() // contentId -> { content, contentType }
}
/**
* @param {string} path - the exact request path ClsiCacheHandler will look
* up, e.g. `/project/ID/user/UID/build/BUILD/search/output/output.pdf`
* or `/project/ID/latest/output/output.overleaf.json`
* @param {object} options
* @param {string|Buffer} options.content
* @param {string} [options.contentType]
* @param {Date} [options.lastModified]
* @param {string} [options.zone]
* @param {string} [options.shard]
* @param {string[]} [options.allFiles]
*/
addEntry(
path,
{
content,
contentType = 'text/plain',
lastModified = new Date(),
zone = 'zone1',
shard = 'shard1',
allFiles,
}
) {
const contentId = `${this.content.size}`
this.content.set(contentId, { content, contentType })
this.entries.set(path, {
contentId,
lastModified,
zone,
shard,
allFiles: allFiles || [path.split('/').pop()],
})
}
_lookup(req, res) {
parseReq(req, lookupSchema)
const entry = this.entries.get(req.path)
if (!entry) {
return res.sendStatus(404)
}
const { content } = this.content.get(entry.contentId)
res.set('X-Zone', entry.zone)
res.set('X-Shard', entry.shard)
res.set('X-Last-Modified', entry.lastModified.toISOString())
res.set('X-Content-Length', String(Buffer.byteLength(content)))
res.set('X-All-Files', JSON.stringify(entry.allFiles))
res.redirect(302, `/content/${entry.contentId}`)
}
applyRoutes() {
// getOutputFile's two routes (with/without the per-user segment)
this.app.get(
'/project/:project_id/user/:user_id/build/:editorBuildId/search/output/:filename(.*)',
(req, res) => this._lookup(req, res)
)
this.app.get(
'/project/:project_id/build/:editorBuildId/search/output/:filename(.*)',
(req, res) => this._lookup(req, res)
)
// getLatestOutputFile's two routes (with/without the per-user segment)
this.app.get(
'/project/:project_id/user/:user_id/latest/output/:filename(.*)',
(req, res) => this._lookup(req, res)
)
this.app.get(
'/project/:project_id/latest/output/:filename(.*)',
(req, res) => this._lookup(req, res)
)
this.app.get('/content/:contentId', (req, res) => {
const { params } = parseReq(req, contentSchema)
const entry = this.content.get(params.contentId)
if (!entry) {
return res.sendStatus(404)
}
res.set('Content-Type', entry.contentType)
res.end(entry.content)
})
}
}
export default MockClsiCache
// type hint for the inherited `instance` method
/**
* @function instance
* @memberOf MockClsiCache
* @static
* @returns {MockClsiCache}
*/
@@ -1,12 +1,25 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { plainTextResponse } from '../../../../app/src/infrastructure/Response.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
// Mirrors services/clsi/app/js/OutputController.js's createOutputZipSchema
// (this mock stands in for clsi-nginx's own output serving).
const outputWildcardSchema = z.object({
params: z.strictObject({
project_id: zz.objectId().or(zz.submissionId()),
build_id: zz.buildId(),
// bare `*` wildcard -- Express populates the match under numeric key 0
0: zz.filepath(),
}),
})
class MockClsiNginxApi extends AbstractMockApi {
applyRoutes() {
this.app.get(
'/project/:project_id/build/:build_id/output/*',
(req, res) => {
const filename = req.params[0]
const { params } = parseReq(req, outputWildcardSchema)
const filename = params[0]
if (filename === 'output.pdf') {
plainTextResponse(res, 'mock-pdf')
} else if (filename === 'output.log') {
@@ -1,4 +1,111 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
import editorCoreSchemas from 'overleaf-editor-core/lib/schemas.js'
const docParamsSchema = z.strictObject({
projectId: zz.objectId(),
docId: zz.objectId(),
})
const getDocSchema = z.object({
params: docParamsSchema,
query: z.object({
historyOTSupport: z.stringbool().default(false),
fromVersion: z.coerce.number().int().default(-1),
}),
})
const setDocSchema = z.object({
params: docParamsSchema,
body: z.strictObject({
lines: z.array(z.string()),
source: z.string(),
user_id: zz.objectId().nullish(),
undoing: z.boolean().optional(),
trackChanges: z.boolean().optional(),
}),
})
// Ranges data as document-updater persists it (RangesTracker format) --
// mirrors services/document-updater/app/js/schemas.js's `ranges` export
// (this mock stands in for document-updater's own API in web's acceptance
// tests).
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string(),
p: z.number().int().min(0),
t: zz.objectId().optional(),
u: z.boolean().optional(),
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string(),
ts: z.string(),
})
const comment = z.strictObject({
id: zz.objectId().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp),
metadata: rangeMetadata,
})
const rangesSchema = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
// Mirrors services/document-updater/app/js/HttpController.js's
// updateProjectSchema (renameUpdateSchema/addUpdateSchema) -- this mock's
// own routes use :projectId/:docId casing rather than document-updater's
// :project_id/:doc_id.
const renameUpdateSchema = z.strictObject({
type: z.enum(['rename-doc', 'rename-file']),
id: zz.objectId(),
pathname: zz.safePath(),
// deletes are renames to an empty newPathname
newPathname: zz.safePath().or(z.literal('')),
})
const addUpdateSchema = z.strictObject({
type: z.enum(['add-doc', 'add-file']),
id: zz.objectId(),
pathname: zz.safePath(),
docLines: z.string().optional(),
ranges: rangesSchema.optional(),
historyRangesSupport: z.boolean().optional(),
// legacy filestore url for files without a created blob
url: z.string().nullish(),
hash: z.string().optional(),
metadata: editorCoreSchemas.rawFileMetadata.optional(),
createdBlob: z.boolean().optional(),
})
const projectParamsSchema = z.object({
params: z.strictObject({ projectId: zz.objectId() }),
})
const updateProjectSchema = z.object({
params: z.strictObject({ projectId: zz.objectId() }),
body: z.strictObject({
projectHistoryId: z.union([z.number(), z.string()]).optional(),
userId: zz.objectId().nullish(),
updates: z.array(renameUpdateSchema.or(addUpdateSchema)).default([]),
version: z.union([z.number(), z.string()]),
source: editorCoreSchemas.rawOrigin.or(z.string()).nullish(),
}),
})
class MockDocUpdaterApi extends AbstractMockApi {
reset() {
@@ -47,13 +154,21 @@ class MockDocUpdaterApi extends AbstractMockApi {
}
applyRoutes() {
this.app.get('/project/:projectId/last_updated_at', (req, res) => {
// no project in this mock has ever been touched via document-updater,
// matching the real service's response for a project it has no
// knowledge of (see DocumentUpdaterHandler.getProjectLastUpdatedAt)
res.json({ lastUpdatedAt: null })
})
this.app.post('/project/:projectId/flush', (req, res) => {
res.sendStatus(204)
})
this.app.post('/project/:projectId', (req, res) => {
const { projectId } = req.params
const { userId, updates, version } = req.body
const { params, body } = parseReq(req, updateProjectSchema)
const { projectId } = params
const { userId, updates, version } = body
this.addProjectStructureUpdates(projectId, userId, updates, version)
res.sendStatus(200)
})
@@ -69,8 +184,11 @@ class MockDocUpdaterApi extends AbstractMockApi {
)
this.app.get('/project/:projectId/doc/:docId', (req, res) => {
const { projectId, docId } = req.params
this.receivedGetDocRequests.push({ projectId, docId, query: req.query })
const {
query,
params: { projectId, docId },
} = parseReq(req, getDocSchema)
this.receivedGetDocRequests.push({ projectId, docId, query })
const doc = this.docsByProject.get(projectId)?.get(docId)
if (doc == null) {
return res.sendStatus(404)
@@ -84,9 +202,12 @@ class MockDocUpdaterApi extends AbstractMockApi {
})
})
this.app.post('/project/:projectId/doc/:doc_id', (req, res) => {
const { projectId, doc_id: docId } = req.params
this.receivedSetDocRequests.push({ projectId, docId, body: req.body })
this.app.post('/project/:projectId/doc/:docId', (req, res) => {
const {
params: { projectId, docId },
body,
} = parseReq(req, setDocSchema)
this.receivedSetDocRequests.push({ projectId, docId, body })
res.sendStatus(204)
})
@@ -107,7 +228,8 @@ class MockDocUpdaterApi extends AbstractMockApi {
})
this.app.get('/project/:projectId/ranges', (req, res) => {
const docsById = this.docsByProject.get(req.params.projectId)
const { params } = parseReq(req, projectParamsSchema)
const docsById = this.docsByProject.get(params.projectId)
const docs = docsById == null ? [] : Array.from(docsById.values())
res.json({
docs: docs.map(doc => ({
@@ -1,5 +1,86 @@
import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs'
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
// Mirrors services/docstore/app/js/schemas.js (this mock stands in for
// docstore's own API in web's acceptance tests).
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string().optional(),
p: z.number().int().min(0).optional(),
t: z.string().optional(),
u: z.boolean().optional(),
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string().optional(),
ts: z.string().optional(),
})
const comment = z.strictObject({
id: z.string().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp).optional(),
metadata: rangeMetadata.optional(),
})
const rangesSchema = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
const docParamsSchema = z.strictObject({
projectId: zz.objectId(),
docId: zz.objectId(),
})
const updateDocSchema = z.object({
params: docParamsSchema,
body: z.strictObject({
lines: z.array(z.string()),
version: z.number(),
ranges: rangesSchema,
}),
})
const projectParamsSchema = z.object({
params: z.strictObject({ projectId: zz.objectId() }),
})
const getDocSchema = z.object({
params: docParamsSchema,
query: z.object({
include_deleted: z.stringbool().default(false),
}),
})
const docParamsOnlySchema = z.object({ params: docParamsSchema })
const patchDocSchema = z.object({
params: docParamsSchema,
// matches docstore's own patchDocSchema -- DocstoreManager#deleteDoc is
// the sole caller and always sends all three fields together.
body: z.strictObject({
deleted: z.literal(true),
deletedAt: z.coerce.date(),
name: z.string(),
}),
})
const destroySchema = z.object({
params: z.strictObject({ projectId: zz.objectId() }),
})
class MockDocstoreApi extends AbstractMockApi {
reset() {
@@ -42,8 +123,9 @@ class MockDocstoreApi extends AbstractMockApi {
applyRoutes() {
this.app.post('/project/:projectId/doc/:docId', (req, res) => {
const { projectId, docId } = req.params
const { lines, version, ranges } = req.body
const { params, body } = parseReq(req, updateDocSchema)
const { projectId, docId } = params
const { lines, version, ranges } = body
if (this.docs[projectId] == null) {
this.docs[projectId] = {}
}
@@ -64,11 +146,13 @@ class MockDocstoreApi extends AbstractMockApi {
})
this.app.get('/project/:projectId/doc', (req, res) => {
res.json(Object.values(this.docs[req.params.projectId] || {}))
const { params } = parseReq(req, projectParamsSchema)
res.json(Object.values(this.docs[params.projectId] || {}))
})
this.app.get('/project/:projectId/ranges', (req, res) => {
const { projectId } = req.params
const { params } = parseReq(req, projectParamsSchema)
const { projectId } = params
const docs = Object.values(this.docs[projectId] || {})
.filter(doc => !doc.deleted)
.map(doc => ({ _id: doc._id, ranges: doc.ranges }))
@@ -76,13 +160,15 @@ class MockDocstoreApi extends AbstractMockApi {
})
this.app.get('/project/:projectId/doc-deleted', (req, res) => {
res.json(this.getDeletedDocs(req.params.projectId))
const { params } = parseReq(req, projectParamsSchema)
res.json(this.getDeletedDocs(params.projectId))
})
this.app.get('/project/:projectId/doc/:docId', (req, res) => {
const { projectId, docId } = req.params
const { params, query } = parseReq(req, getDocSchema)
const { projectId, docId } = params
const doc = this.docs[projectId][docId]
if (!doc || (doc.deleted && !req.query.include_deleted)) {
if (!doc || (doc.deleted && !query.include_deleted)) {
res.sendStatus(404)
} else {
res.json(doc)
@@ -90,7 +176,8 @@ class MockDocstoreApi extends AbstractMockApi {
})
this.app.get('/project/:projectId/doc/:docId/deleted', (req, res) => {
const { projectId, docId } = req.params
const { params } = parseReq(req, docParamsOnlySchema)
const { projectId, docId } = params
if (!this.docs[projectId] || !this.docs[projectId][docId]) {
res.sendStatus(404)
} else {
@@ -99,19 +186,21 @@ class MockDocstoreApi extends AbstractMockApi {
})
this.app.patch('/project/:projectId/doc/:docId', (req, res) => {
const { projectId, docId } = req.params
const { params, body } = parseReq(req, patchDocSchema)
const { projectId, docId } = params
if (!this.docs[projectId]) {
res.sendStatus(404)
} else if (!this.docs[projectId][docId]) {
res.sendStatus(404)
} else {
Object.assign(this.docs[projectId][docId], req.body)
Object.assign(this.docs[projectId][docId], body)
res.sendStatus(204)
}
})
this.app.post('/project/:projectId/destroy', async (req, res) => {
const { projectId } = req.params
const { params } = parseReq(req, destroySchema)
const { projectId } = params
delete this.docs[projectId]
await db.docs.deleteMany({ project_id: new ObjectId(projectId) })
res.sendStatus(204)
@@ -1,4 +1,13 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, getRawReqInput } from '@overleaf/validation-tools'
const deleteProjectSchema = z.object({
params: z.strictObject({ projectId: z.string() }),
})
const postbackParamsSchema = z.object({
params: z.strictObject({ id: z.string() }),
})
class MockGitBridgeApi extends AbstractMockApi {
reset() {
@@ -16,7 +25,8 @@ class MockGitBridgeApi extends AbstractMockApi {
}
deleteProject(req, res) {
const projectId = req.params.projectId
const { params } = parseReq(req, deleteProjectSchema)
const projectId = params.projectId
delete this.projects[projectId]
res.sendStatus(204)
}
@@ -31,8 +41,10 @@ class MockGitBridgeApi extends AbstractMockApi {
}
postback(req, res) {
const { id } = req.params
const postbackData = req.body
const { params } = parseReq(req, postbackParamsSchema)
const { id } = params
// case 3: recorded verbatim for later assertion by acceptance tests
const postbackData = getRawReqInput(req).body
if (this.postbacks[id]) {
this.postbacks[id].resolve(postbackData)
}
@@ -1,4 +1,23 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z } from '@overleaf/validation-tools'
// Token exchange body as built by passport-oauth2/the `oauth` library's
// OAuth2#getOAuthAccessToken (standard authorization_code grant).
const tokenSchema = z.object({
body: z.strictObject({
code: z.string(),
client_id: z.string(),
client_secret: z.string(),
grant_type: z.literal('authorization_code'),
redirect_uri: z.string(),
}),
})
const userinfoSchema = z.object({
query: z.object({
access_token: z.string(),
}),
})
class MockGoogleOauthApi extends AbstractMockApi {
reset() {
@@ -18,19 +37,21 @@ class MockGoogleOauthApi extends AbstractMockApi {
applyRoutes() {
this.app.post('/oauth/token', (req, res, next) => {
if (!this.tokens[req.body.code]) {
const { body } = parseReq(req, tokenSchema)
if (!this.tokens[body.code]) {
return res.sendStatus(400)
}
res.json({
access_token: this.tokens[req.body.code],
access_token: this.tokens[body.code],
})
})
this.app.get('/oauth2/v3/userinfo', (req, res, next) => {
if (!this.profiles[req.query.access_token]) {
const { query } = parseReq(req, userinfoSchema)
if (!this.profiles[query.access_token]) {
return res.sendStatus(400)
}
res.json(this.profiles[req.query.access_token])
res.json(this.profiles[query.access_token])
})
}
}
@@ -1,5 +1,14 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { plainTextResponse } from '../../../../app/src/infrastructure/Response.mjs'
import { parseReq, z } from '@overleaf/validation-tools'
// HaveIBeenPwned.mjs sends the first 5 hex characters of an uppercase sha1
// digest (k-anonymity range query).
const rangeSchema = z.object({
params: z.strictObject({
prefix: z.string().regex(/^[0-9A-F]{5}$/),
}),
})
class MockHaveIBeenPwnedApi extends AbstractMockApi {
reset() {
@@ -30,7 +39,8 @@ class MockHaveIBeenPwnedApi extends AbstractMockApi {
applyRoutes() {
this.app.get('/range/:prefix', (req, res) => {
const { prefix } = req.params
const { params } = parseReq(req, rangeSchema)
const { prefix } = params
if (prefix === 'C8893') {
plainTextResponse(res, '74D74EFD7B158D2ADD283D67FF3E53B55D7:broken')
} else {
@@ -1,4 +1,9 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
const deleteProjectSchema = z.object({
params: z.strictObject({ project_id: zz.objectId() }),
})
class MockHistoryBackupDeletionApi extends AbstractMockApi {
reset() {
@@ -10,7 +15,8 @@ class MockHistoryBackupDeletionApi extends AbstractMockApi {
}
deleteProject(req, res) {
const projectId = req.params.project_id
const { params } = parseReq(req, deleteProjectSchema)
const projectId = params.project_id
const status = this.projects[projectId]
if (status === 422) {
return res.sendStatus(422)
@@ -1,4 +1,17 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z } from '@overleaf/validation-tools'
// Token exchange body as built by passport-oauth2/the `oauth` library's
// OAuth2#getOAuthAccessToken (standard authorization_code grant).
const tokenSchema = z.object({
body: z.strictObject({
code: z.string(),
client_id: z.string(),
client_secret: z.string(),
grant_type: z.literal('authorization_code'),
redirect_uri: z.string(),
}),
})
class MockOrcidApi extends AbstractMockApi {
reset() {
@@ -14,7 +27,8 @@ class MockOrcidApi extends AbstractMockApi {
applyRoutes() {
this.app.post('/oauth/token', (req, res) => {
const token = this.tokens[req.body.code]
const { body } = parseReq(req, tokenSchema)
const token = this.tokens[body.code]
if (!token) {
return res.sendStatus(400)
}
@@ -2,9 +2,67 @@ import AbstractMockApi from './AbstractMockApi.mjs'
import _ from 'lodash'
import mongodb from 'mongodb-legacy'
import { plainTextResponse } from '../../../../app/src/infrastructure/Response.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
const { ObjectId } = mongodb
// Mirrors services/project-history/app/js/HttpController.js's historyIdSchema
// (this mock stands in for project-history's own API in web's acceptance
// tests): most :projectId params accept either a Mongo ObjectId or a legacy
// v1-only numeric id.
const historyIdSchema = zz.objectId().or(z.coerce.number())
const oldFileParamsSchema = z.object({
params: z.strictObject({
projectId: historyIdSchema,
version: z.coerce.number().int(),
pathname: zz.filepath(),
}),
})
const projectSnapshotParamsSchema = z.object({
params: z.strictObject({
projectId: historyIdSchema,
version: z.coerce.number().int(),
}),
})
const projectParamsSchema = z.object({
params: z.strictObject({ projectId: historyIdSchema }),
})
// Mirrors HttpController.js's createLabelSchema body in full -- web's own
// HistoryController.createLabel is the only current caller and always sends
// just { comment, version, user_id }, but created_at/validate_exists are
// genuine fields of the real route (accepted -- with defaults -- from other,
// non-web callers), so they are modelled here too rather than narrowed to
// only what's exercised today.
const createLabelSchema = z.object({
params: z.strictObject({ projectId: historyIdSchema }),
body: z.strictObject({
comment: z.string(),
version: z.number().int(),
created_at: zz.datetime().optional(),
validate_exists: z.boolean().default(true),
user_id: zz.objectId().nullable().optional(),
}),
})
const deleteLabelForUserParamsSchema = z.object({
params: z.strictObject({
projectId: historyIdSchema,
user_id: zz.objectId(),
labelId: zz.objectId(),
}),
})
const deleteLabelParamsSchema = z.object({
params: z.strictObject({
projectId: historyIdSchema,
labelId: zz.objectId(),
}),
})
class MockProjectHistoryApi extends AbstractMockApi {
reset() {
this.docs = {}
@@ -64,7 +122,8 @@ class MockProjectHistoryApi extends AbstractMockApi {
this.app.get(
'/project/:projectId/version/:version/:pathname',
(req, res) => {
const { projectId, version, pathname } = req.params
const { params } = parseReq(req, oldFileParamsSchema)
const { projectId, version, pathname } = params
const key = `${projectId}:${version}:${pathname}`
if (this.oldFiles[key] != null) {
plainTextResponse(res, this.oldFiles[key])
@@ -75,7 +134,8 @@ class MockProjectHistoryApi extends AbstractMockApi {
)
this.app.get('/project/:projectId/version/:version', (req, res) => {
const { projectId, version } = req.params
const { params } = parseReq(req, projectSnapshotParamsSchema)
const { projectId, version } = params
const key = `${projectId}:${version}`
if (this.projectSnapshots[key] != null) {
res.json(this.projectSnapshots[key])
@@ -85,7 +145,8 @@ class MockProjectHistoryApi extends AbstractMockApi {
})
this.app.get('/project/:projectId/version', (req, res) => {
const { projectId } = req.params
const { params } = parseReq(req, projectParamsSchema)
const { projectId } = params
if (this.projectVersions[projectId] != null) {
res.json(this.projectVersions[projectId])
} else {
@@ -94,7 +155,8 @@ class MockProjectHistoryApi extends AbstractMockApi {
})
this.app.get('/project/:projectId/labels', (req, res) => {
const { projectId } = req.params
const { params } = parseReq(req, projectParamsSchema)
const { projectId } = params
const labels = this.getLabels(projectId)
if (labels != null) {
res.json(labels)
@@ -104,8 +166,9 @@ class MockProjectHistoryApi extends AbstractMockApi {
})
this.app.post('/project/:projectId/labels', (req, res) => {
const { projectId } = req.params
const { comment, version } = req.body
const { params, body } = parseReq(req, createLabelSchema)
const { projectId } = params
const { comment, version } = body
const labelId = new ObjectId().toString()
this.addLabel(projectId, { id: labelId, comment, version })
res.json({ label_id: labelId, comment, version })
@@ -114,7 +177,8 @@ class MockProjectHistoryApi extends AbstractMockApi {
this.app.delete(
'/project/:projectId/user/:user_id/labels/:labelId',
(req, res) => {
const { projectId, labelId } = req.params
const { params } = parseReq(req, deleteLabelForUserParamsSchema)
const { projectId, labelId } = params
const label =
this.labels[projectId] != null
? this.labels[projectId][labelId]
@@ -129,7 +193,8 @@ class MockProjectHistoryApi extends AbstractMockApi {
)
this.app.delete('/project/:projectId/labels/:labelId', (req, res) => {
const { projectId, labelId } = req.params
const { params } = parseReq(req, deleteLabelParamsSchema)
const { projectId, labelId } = params
const label =
this.labels[projectId] != null
? this.labels[projectId][labelId]
@@ -1,10 +1,22 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z } from '@overleaf/validation-tools'
// Body sent by CaptchaMiddleware's siteverify request: `secret` (the
// configured recaptcha.secretKey) plus the client-supplied
// `g-recaptcha-response` forwarded as `response`.
const siteVerifySchema = z.object({
body: z.strictObject({
secret: z.string(),
response: z.string(),
}),
})
class MockReCaptchaApi extends AbstractMockApi {
applyRoutes() {
this.app.post('/recaptcha/api/siteverify', (req, res) => {
const { body } = parseReq(req, siteVerifySchema)
res.json({
success: req.body.response === 'valid',
success: body.response === 'valid',
})
})
}
@@ -1,6 +1,35 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import SubscriptionController from '../../../../app/src/Features/Subscription/SubscriptionController.mjs'
import { xmlResponse } from '../../../../app/src/infrastructure/Response.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
// Recurly's own ids: subscription uuids and coupon codes are opaque
// Recurly-assigned strings, not Mongo ObjectIds -- only the account "id" is
// ours (it's always set to the Overleaf user's Mongo id, see
// RecurlyWrapper.mjs's `accounts/${userId}` calls).
const subscriptionParamsSchema = z.object({
params: z.strictObject({ id: z.string() }),
})
const accountParamsSchema = z.object({
params: z.strictObject({ id: zz.objectId() }),
})
// Mirrors RecurlyWrapper.updateAccountEmailAddress, the sole production
// caller of this route: it PUTs `<account><email>...</email></account>`,
// which recurlyNotificationParser parses into { account: { email } }.
const updateAccountSchema = z.object({
params: z.strictObject({ id: zz.objectId() }),
body: z.strictObject({
account: z.strictObject({
email: z.string(),
}),
}),
})
const couponParamsSchema = z.object({
params: z.strictObject({ code: z.string() }),
})
class MockRecurlyApi extends AbstractMockApi {
reset() {
@@ -44,7 +73,8 @@ class MockRecurlyApi extends AbstractMockApi {
next()
})
this.app.get('/subscriptions/:id', (req, res) => {
const subscription = this.getMockSubscriptionById(req.params.id)
const { params } = parseReq(req, subscriptionParamsSchema)
const subscription = this.getMockSubscriptionById(params.id)
if (!subscription) {
res.sendStatus(404)
} else {
@@ -68,7 +98,8 @@ class MockRecurlyApi extends AbstractMockApi {
})
this.app.get('/accounts/:id', (req, res) => {
const subscription = this.getMockSubscriptionByAccountId(req.params.id)
const { params } = parseReq(req, accountParamsSchema)
const subscription = this.getMockSubscriptionByAccountId(params.id)
if (!subscription) {
res.sendStatus(404)
} else {
@@ -76,7 +107,7 @@ class MockRecurlyApi extends AbstractMockApi {
res,
`\
<account>
<account_code>${req.params.id}</account_code>
<account_code>${params.id}</account_code>
<hosted_login_token>${subscription.account.hosted_login_token}</hosted_login_token>
<email>${subscription.account.email}</email>
</account>\
@@ -89,16 +120,17 @@ class MockRecurlyApi extends AbstractMockApi {
'/accounts/:id',
SubscriptionController.recurlyNotificationParser, // required to parse XML requests
(req, res) => {
const subscription = this.getMockSubscriptionByAccountId(req.params.id)
const { params, body } = parseReq(req, updateAccountSchema)
const subscription = this.getMockSubscriptionByAccountId(params.id)
if (!subscription) {
res.sendStatus(404)
} else {
Object.assign(subscription.account, req.body.account)
Object.assign(subscription.account, body.account)
xmlResponse(
res,
`\
<account>
<account_code>${req.params.id}</account_code>
<account_code>${params.id}</account_code>
<email>${subscription.account.email}</email>
</account>\
`
@@ -108,7 +140,8 @@ class MockRecurlyApi extends AbstractMockApi {
)
this.app.get('/coupons/:code', (req, res) => {
const coupon = this.coupons[req.params.code]
const { params } = parseReq(req, couponParamsSchema)
const coupon = this.coupons[params.code]
if (!coupon) {
res.sendStatus(404)
} else {
@@ -116,7 +149,7 @@ class MockRecurlyApi extends AbstractMockApi {
res,
`\
<coupon>
<coupon_code>${req.params.code}</coupon_code>
<coupon_code>${params.code}</coupon_code>
<name>${coupon.name || ''}</name>
<description>${coupon.description || ''}</description>
</coupon>\
@@ -126,7 +159,8 @@ class MockRecurlyApi extends AbstractMockApi {
})
this.app.get('/accounts/:id/redemptions', (req, res) => {
const redemptions = this.redemptions[req.params.id] || []
const { params } = parseReq(req, accountParamsSchema)
const redemptions = this.redemptions[params.id] || []
let redemptionsListXml = ''
for (const redemption of Array.from(redemptions)) {
redemptionsListXml += `\
@@ -1,4 +1,16 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
const userParamsSchema = z.object({
params: z.strictObject({ userId: zz.objectId() }),
})
const wordSchema = z.object({
params: z.strictObject({ userId: zz.objectId() }),
// learn/unlearn silently no-op on a missing word (see the `if (word)`
// checks below), so word is optional rather than required.
body: z.strictObject({ word: z.string().optional() }),
})
class MockSpellingApi extends AbstractMockApi {
reset() {
@@ -7,20 +19,21 @@ class MockSpellingApi extends AbstractMockApi {
applyRoutes() {
this.app.get('/user/:userId', (req, res) => {
const { userId } = req.params
const words = this.words[userId] || []
const { params } = parseReq(req, userParamsSchema)
const words = this.words[params.userId] || []
res.json(words)
})
this.app.delete('/user/:userId', (req, res) => {
const { userId } = req.params
this.words.delete(userId)
const { params } = parseReq(req, userParamsSchema)
this.words.delete(params.userId)
res.sendStatus(200)
})
this.app.post('/user/:userId/learn', (req, res) => {
const word = req.body.word
const { userId } = req.params
const { params, body } = parseReq(req, wordSchema)
const word = body.word
const { userId } = params
if (word) {
this.words[userId] = this.words[userId] || []
if (!this.words[userId].includes(word)) {
@@ -31,8 +44,9 @@ class MockSpellingApi extends AbstractMockApi {
})
this.app.post('/user/:userId/unlearn', (req, res) => {
const word = req.body.word
const { userId } = req.params
const { params, body } = parseReq(req, wordSchema)
const word = body.word
const { userId } = params
if (word && this.words[userId]) {
const wordIndex = this.words[userId].indexOf(word)
if (wordIndex !== -1) {
@@ -1,4 +1,240 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z } from '@overleaf/validation-tools'
// This mock stands in for Stripe's own API in web's acceptance tests. Unlike
// docstore/clsi, there is no internal production schema to mirror here --
// Stripe's real API is a third-party, open-ended surface -- so these schemas
// model the shape web's StripeClient actually sends to each route (per its
// call sites in modules/subscriptions/app/src/StripeClient.mjs) rather than
// only the handful of fields each handler destructures.
const stripeIdSchema = z.string().min(1)
// Stripe metadata values are always flat string->string maps.
const metadataSchema = z.record(z.string(), z.string())
const addressSchema = z.strictObject({
city: z.string().optional(),
country: z.string().optional(),
line1: z.string().optional(),
line2: z.string().optional(),
postal_code: z.string().optional(),
state: z.string().optional(),
})
const expandSchema = z.array(z.string())
const customerParamsSchema = z.strictObject({ id: stripeIdSchema })
const createCustomerSchema = z.object({
body: z.strictObject({
email: z.string(),
name: z.string().optional(),
address: addressSchema.optional(),
tax: z
.strictObject({
validate_location: z.string().optional(),
})
.optional(),
metadata: metadataSchema.optional(),
tax_exempt: z.string().optional(),
expand: expandSchema.optional(),
}),
})
const listCustomersSchema = z.object({
query: z.object({
email: z.string().optional(),
limit: z.coerce.number().int().optional(),
expand: expandSchema.optional(),
}),
})
const getCustomerSchema = z.object({ params: customerParamsSchema })
// Backs the generic `customers.update()` call, used with different subsets
// of these fields across StripeClient (subscription-creation flow, plain
// email/metadata updates, and customer-merge billing-details copy).
const updateCustomerSchema = z.object({
params: customerParamsSchema,
body: z.strictObject({
email: z.string().optional(),
name: z.string().optional(),
address: addressSchema.optional(),
tax: z
.strictObject({
validate_location: z.string().optional(),
})
.optional(),
metadata: metadataSchema.optional(),
tax_exempt: z.string().optional(),
expand: expandSchema.optional(),
}),
})
const createTaxIdSchema = z.object({
params: customerParamsSchema,
body: z.strictObject({
type: z.string(),
value: z.string(),
}),
})
const deleteTaxIdSchema = z.object({
params: z.strictObject({
id: stripeIdSchema,
taxId: stripeIdSchema,
}),
})
const createSetupIntentSchema = z.object({
body: z.strictObject({
customer: stripeIdSchema,
usage: z.string().optional(),
metadata: metadataSchema.optional(),
}),
})
const setupIntentParamsSchema = z.strictObject({ id: stripeIdSchema })
const getSetupIntentSchema = z.object({
params: setupIntentParamsSchema,
query: z.object({
expand: expandSchema.optional(),
}),
})
// Subscription item as sent by `subscriptions.create()` -- either a plain
// `{ price, quantity }` pair (the common case) or one carrying its own `id`
// (`subscriptionSchedules` phase items reuse the same shape upstream).
const subscriptionItemSchema = z.strictObject({
id: z.string().optional(),
price: stripeIdSchema,
quantity: z.coerce.number().int().optional(),
})
const createSubscriptionSchema = z.object({
body: z.strictObject({
customer: stripeIdSchema,
items: z.array(subscriptionItemSchema).optional(),
collection_method: z.string().optional(),
payment_behavior: z.string().optional(),
payment_settings: z
.strictObject({
save_default_payment_method: z.string().optional(),
})
.optional(),
default_payment_method: z.string().optional(),
trial_period_days: z.coerce.number().int().optional(),
metadata: metadataSchema.optional(),
automatic_tax: z
.strictObject({
enabled: z.stringbool().optional(),
})
.optional(),
discounts: z
.array(
z.strictObject({
promotion_code: z.string().optional(),
})
)
.optional(),
expand: expandSchema.optional(),
}),
})
const subscriptionParamsSchema = z.strictObject({ id: stripeIdSchema })
const getSubscriptionSchema = z.object({
params: subscriptionParamsSchema,
query: z.object({
expand: expandSchema.optional(),
}),
})
const listSubscriptionsSchema = z.object({
query: z.object({
customer: z.string().optional(),
status: z.string().optional(),
expand: expandSchema.optional(),
}),
})
const listPricesSchema = z.object({
query: z.object({
active: z.stringbool().optional(),
limit: z.coerce.number().int().optional(),
lookup_keys: expandSchema.optional(),
expand: expandSchema.optional(),
}),
})
const priceParamsSchema = z.strictObject({ id: stripeIdSchema })
const getPriceSchema = z.object({ params: priceParamsSchema })
// `invoices.createPreview()` is called from two different sites with two
// different shapes: previewing a brand-new subscription (customer_details +
// subscription_details.items) and previewing the next invoice for an
// existing subscription (customer + subscription). Model both as optional
// since the mock's response doesn't depend on which shape was sent.
const createInvoicePreviewSchema = z.object({
body: z.strictObject({
customer: z.string().optional(),
subscription: z.string().optional(),
customer_details: z
.strictObject({
address: addressSchema.optional(),
tax_ids: z
.array(
z.strictObject({
type: z.string(),
value: z.string(),
})
)
.optional(),
tax_exempt: z.string().optional(),
})
.optional(),
automatic_tax: z
.strictObject({
enabled: z.stringbool().optional(),
})
.optional(),
subscription_details: z
.strictObject({
items: z
.array(
z.strictObject({
id: z.string().optional(),
price: z.string().optional(),
quantity: z.coerce.number().int().optional(),
})
)
.optional(),
proration_behavior: z.string().optional(),
})
.optional(),
currency: z.string().optional(),
preview_mode: z.string().optional(),
discounts: z
.array(
z.strictObject({
promotion_code: z.string().optional(),
})
)
.optional(),
expand: expandSchema.optional(),
}),
})
const listPromotionCodesSchema = z.object({
query: z.object({
code: z.string().optional(),
active: z.stringbool().optional(),
expand: expandSchema.optional(),
}),
})
class MockStripeApi extends AbstractMockApi {
reset() {
this.customers = {}
@@ -156,14 +392,15 @@ class MockStripeApi extends AbstractMockApi {
applyRoutes() {
this.app.post('/v1/customers', (req, res) => {
const { body } = parseReq(req, createCustomerSchema)
const id = this._nextId('cus')
const customer = {
id,
email: req.body.email,
name: req.body.name,
address: req.body.address,
metadata: req.body.metadata || {},
tax_exempt: req.body.tax_exempt || 'none',
email: body.email,
name: body.name,
address: body.address,
metadata: body.metadata || {},
tax_exempt: body.tax_exempt || 'none',
tax_ids: [],
}
this.customers[id] = customer
@@ -171,9 +408,10 @@ class MockStripeApi extends AbstractMockApi {
})
this.app.get('/v1/customers', (req, res) => {
const { query } = parseReq(req, listCustomersSchema)
let data = Object.values(this.customers)
if (req.query.email) {
data = data.filter(c => c.email === req.query.email)
if (query.email) {
data = data.filter(c => c.email === query.email)
}
res.json({
object: 'list',
@@ -183,68 +421,73 @@ class MockStripeApi extends AbstractMockApi {
})
this.app.get('/v1/customers/:id', (req, res) => {
const customer = this.customers[req.params.id]
const { params } = parseReq(req, getCustomerSchema)
const customer = this.customers[params.id]
if (!customer) return this._notFound(res, 'customer')
res.json(this._customerResponse(customer))
})
this.app.post('/v1/customers/:id', (req, res) => {
const customer = this.customers[req.params.id]
const { params, body } = parseReq(req, updateCustomerSchema)
const customer = this.customers[params.id]
if (!customer) return this._notFound(res, 'customer')
Object.assign(customer, req.body)
Object.assign(customer, body)
res.json(this._customerResponse(customer))
})
this.app.post('/v1/customers/:id/tax_ids', (req, res) => {
const customer = this.customers[req.params.id]
const { params, body } = parseReq(req, createTaxIdSchema)
const customer = this.customers[params.id]
if (!customer) return this._notFound(res, 'customer')
const taxId = {
id: this._nextId('txi'),
object: 'tax_id',
type: req.body.type,
value: req.body.value,
type: body.type,
value: body.value,
}
customer.tax_ids.push(taxId)
res.json(taxId)
})
this.app.delete('/v1/customers/:id/tax_ids/:taxId', (req, res) => {
const customer = this.customers[req.params.id]
const { params } = parseReq(req, deleteTaxIdSchema)
const customer = this.customers[params.id]
if (customer) {
customer.tax_ids = customer.tax_ids.filter(
t => t.id !== req.params.taxId
)
customer.tax_ids = customer.tax_ids.filter(t => t.id !== params.taxId)
}
res.json({ id: req.params.taxId, object: 'tax_id', deleted: true })
res.json({ id: params.taxId, object: 'tax_id', deleted: true })
})
this.app.post('/v1/setup_intents', (req, res) => {
const { body } = parseReq(req, createSetupIntentSchema)
const id = this._nextId('seti')
const setupIntent = {
id,
object: 'setup_intent',
status: 'requires_payment_method',
client_secret: `${id}_secret_mock`,
customer: req.body.customer,
usage: req.body.usage,
customer: body.customer,
usage: body.usage,
payment_method: null,
last_setup_error: null,
metadata: req.body.metadata || {},
metadata: body.metadata || {},
}
this.setupIntents[id] = setupIntent
res.json(setupIntent)
})
this.app.get('/v1/setup_intents/:id', (req, res) => {
const setupIntent = this.setupIntents[req.params.id]
const { params, query } = parseReq(req, getSetupIntentSchema)
const setupIntent = this.setupIntents[params.id]
if (!setupIntent) return this._notFound(res, 'setup_intent')
const expand = [].concat(req.query.expand || [])
const expand = [].concat(query.expand || [])
res.json(this._expandSetupIntent(setupIntent, expand))
})
this.app.post('/v1/subscriptions', (req, res) => {
const { body } = parseReq(req, createSubscriptionSchema)
const id = this._nextId('sub')
const items = [].concat(req.body.items || []).map(item => ({
const items = [].concat(body.items || []).map(item => ({
id: this._nextId('si'),
priceId: item.price,
quantity: parseInt(item.quantity || '1', 10),
@@ -254,12 +497,12 @@ class MockStripeApi extends AbstractMockApi {
// with an open invoice carrying a confirmation secret.
const subscription = {
id,
customerId: req.body.customer,
status: req.body.trial_period_days ? 'trialing' : 'incomplete',
customerId: body.customer,
status: body.trial_period_days ? 'trialing' : 'incomplete',
currency: 'usd',
collection_method: req.body.collection_method || 'charge_automatically',
collection_method: body.collection_method || 'charge_automatically',
items,
metadata: req.body.metadata || {},
metadata: body.metadata || {},
discounts: [],
current_period_start: 1700000000,
current_period_end: 1702592000,
@@ -283,15 +526,17 @@ class MockStripeApi extends AbstractMockApi {
})
this.app.get('/v1/subscriptions/:id', (req, res) => {
const subscription = this.subscriptions[req.params.id]
const { params } = parseReq(req, getSubscriptionSchema)
const subscription = this.subscriptions[params.id]
if (!subscription) return this._notFound(res, 'subscription')
res.json(this._subscriptionResponse(subscription))
})
this.app.get('/v1/subscriptions', (req, res) => {
const { query } = parseReq(req, listSubscriptionsSchema)
let data = Object.values(this.subscriptions)
if (req.query.customer) {
data = data.filter(s => s.customerId === req.query.customer)
if (query.customer) {
data = data.filter(s => s.customerId === query.customer)
}
res.json({
object: 'list',
@@ -301,9 +546,10 @@ class MockStripeApi extends AbstractMockApi {
})
this.app.get('/v1/prices', (req, res) => {
const { query } = parseReq(req, listPricesSchema)
let data = Object.values(this.prices)
if (req.query.lookup_keys) {
const keys = [].concat(req.query.lookup_keys)
if (query.lookup_keys) {
const keys = [].concat(query.lookup_keys)
data = data.filter(p => keys.includes(p.lookup_key))
for (const key of keys) {
if (!data.some(p => p.lookup_key === key)) {
@@ -315,7 +561,8 @@ class MockStripeApi extends AbstractMockApi {
})
this.app.get('/v1/prices/:id', (req, res) => {
const price = this.prices[req.params.id]
const { params } = parseReq(req, getPriceSchema)
const price = this.prices[params.id]
if (!price) return this._notFound(res, 'price')
res.json(price)
})
@@ -329,9 +576,10 @@ class MockStripeApi extends AbstractMockApi {
})
this.app.post('/v1/invoices/create_preview', (req, res) => {
const { body } = parseReq(req, createInvoicePreviewSchema)
res.json({
object: 'invoice',
currency: req.body.currency || 'usd',
currency: body.currency || 'usd',
subtotal: 1500,
total: 1500,
total_excluding_tax: 1500,
@@ -343,6 +591,7 @@ class MockStripeApi extends AbstractMockApi {
})
this.app.get('/v1/promotion_codes', (req, res) => {
parseReq(req, listPromotionCodesSchema)
res.json({
object: 'list',
has_more: false,
@@ -1,6 +1,121 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import moment from 'moment'
import sinon from 'sinon'
import { parseReq, z, zz, getRawReqInput } from '@overleaf/validation-tools'
const v1UserIdParamsSchema = z.object({
params: z.strictObject({ v1_user_id: z.string() }),
})
const exportByIdSchema = z.object({
params: z.strictObject({ id: z.string() }),
query: z.object({ token: z.string().optional() }),
})
// zip_url only reads req.query.token -- unlike exportByIdSchema above, the
// route's own :id param is never read by the handler, so it is left out.
const exportZipUrlSchema = z.object({
query: z.object({ token: z.string().optional() }),
})
const userIdParamsSchema = z.object({
params: z.strictObject({ userId: zz.objectId() }),
})
// Institution id sent from web varies by caller: UserEmailsController.mjs
// forwards a client-supplied number, while the SAML/group-domain-capture
// call sites always .toString() it first -- see
// app/src/Features/User/SAMLIdentityManager.mjs and
// modules/saas-authentication/app/src/SAML/SAMLController.mjs.
const affiliationUniversitySchema = z
.strictObject({
id: z.union([z.number(), z.string()]).optional(),
name: z.string().optional(),
country_code: z.string().optional(),
})
.optional()
// Mirrors every field InstitutionsAPI.mjs#addAffiliation actually puts on
// the wire (app/src/Features/Institutions/InstitutionsAPI.mjs) -- the mock
// handler itself only reads email/entitlement/confirmedAt, but the other
// fields are genuinely sent by real callers (UserEmailsController,
// SAMLIdentityManager, SAMLController, GroupDomainCaptureHandler) and must
// still be accepted by this strict schema.
const addAffiliationSchema = z.object({
params: z.strictObject({ userId: zz.objectId() }),
body: z.strictObject({
email: z.string(),
university: affiliationUniversitySchema,
department: z.string().optional(),
role: z.string().optional(),
entitlement: z.boolean().optional(),
confirmedAt: z.coerce.date().optional(),
rejectIfBlocklisted: z.boolean().optional(),
}),
})
const brandSlugSchema = z.object({
params: z.strictObject({ slug: z.string() }),
})
const universitiesListSchema = z.object({
query: z.object({
country_code: z.string().optional(),
search: z.string().optional(),
max_results: z.string().optional(),
}),
})
const universityByIdSchema = z.object({
params: z.strictObject({ id: z.string() }),
})
const universityDomainsSchema = z.object({
query: z.object({ hostname: z.string().optional() }),
})
const updateEmailSchema = z.object({
params: z.strictObject({ id: z.string() }),
body: z.strictObject({
user: z.strictObject({ email: z.string() }),
}),
})
const loginSchema = z.object({
body: z.strictObject({
email: z.string(),
password: z.string(),
}),
})
const partnerConversionSchema = z.object({
params: z.strictObject({
partner: z.string(),
id: z.string(),
}),
})
const brandVariationSchema = z.object({
params: z.strictObject({ id: z.string() }),
})
const tokenParamsSchema = z.object({
params: z.strictObject({
user_id: z.string().optional(),
token: z.string(),
}),
})
const templateSchema = z.object({
params: z.strictObject({ templateId: z.string() }),
})
const fakeRouteApiHandlerTestsSchema = z.object({
query: z.object({
expectedStatus: z.string(),
expectedBody: z.string(),
}),
})
class MockV1Api extends AbstractMockApi {
reset() {
@@ -163,7 +278,8 @@ class MockV1Api extends AbstractMockApi {
applyRoutes() {
this.app.get('/api/v1/overleaf/users/:v1_user_id/plan_code', (req, res) => {
const user = this.users[req.params.v1_user_id]
const { params } = parseReq(req, v1UserIdParamsSchema)
const user = this.users[params.v1_user_id]
if (user) {
res.json(user)
} else {
@@ -174,7 +290,8 @@ class MockV1Api extends AbstractMockApi {
this.app.get(
'/api/v1/overleaf/users/:v1_user_id/subscriptions',
(req, res) => {
const user = this.users[req.params.v1_user_id]
const { params } = parseReq(req, v1UserIdParamsSchema)
const user = this.users[params.v1_user_id]
if (user && user.subscription) {
res.json(user.subscription)
} else {
@@ -186,7 +303,8 @@ class MockV1Api extends AbstractMockApi {
this.app.get(
'/api/v1/overleaf/users/:v1_user_id/subscription_status',
(req, res) => {
const user = this.users[req.params.v1_user_id]
const { params } = parseReq(req, v1UserIdParamsSchema)
const user = this.users[params.v1_user_id]
if (user && user.subscription_status) {
res.json(user.subscription_status)
} else {
@@ -198,7 +316,8 @@ class MockV1Api extends AbstractMockApi {
this.app.delete(
'/api/v1/overleaf/users/:v1_user_id/subscription',
(req, res) => {
const user = this.users[req.params.v1_user_id]
const { params } = parseReq(req, v1UserIdParamsSchema)
const user = this.users[params.v1_user_id]
if (user) {
user.canceled = true
res.sendStatus(200)
@@ -209,29 +328,37 @@ class MockV1Api extends AbstractMockApi {
)
this.app.post('/api/v1/overleaf/users/:v1_user_id/sync', (req, res) => {
this.syncUserFeatures(req.params.v1_user_id)
const { params } = parseReq(req, v1UserIdParamsSchema)
this.syncUserFeatures(params.v1_user_id)
res.sendStatus(200)
})
this.app.post('/api/v1/overleaf/exports', (req, res) => {
this.exportParams = Object.assign({}, req.body)
// case 3: acceptance tests assert on the exact raw body sent to v1's
// export endpoint via getLastExportParams() (see
// modules/saas-authentication/test/acceptance/src/ExportsTests.mjs) --
// this records the wire payload verbatim for later inspection rather
// than reading any field of it itself.
this.exportParams = Object.assign({}, getRawReqInput(req).body)
res.json({ exportId: this.exportId, token: this.exportToken })
})
this.app.get('/api/v1/overleaf/exports/:id', (req, res) => {
const { token } = req.query
const { params, query } = parseReq(req, exportByIdSchema)
const { token } = query
if (token && token !== this.exportToken) {
return res.sendStatus(404)
}
res.json({
id: parseInt(req.params.id, 10),
id: parseInt(params.id, 10),
status_summary: 'succeeded',
token: this.exportToken,
})
})
this.app.get('/api/v1/overleaf/exports/:id/zip_url', (req, res) => {
const { token } = req.query
const { query } = parseReq(req, exportZipUrlSchema)
const { token } = query
if (token && token !== this.exportToken) {
return res.sendStatus(404)
}
@@ -240,65 +367,62 @@ class MockV1Api extends AbstractMockApi {
})
this.app.get('/api/v2/users/:userId/affiliations', (req, res) => {
if (!this.affiliations[req.params.userId]) return res.json([])
const affiliations = this.affiliations[req.params.userId].map(
affiliation => {
const institutionId = affiliation.institution.id
const domain = affiliation.email.split('@').pop()
const domainData =
this.institutionDomains[institutionId][domain] || {}
const institutionData = this.institutions[institutionId] || {}
const { params } = parseReq(req, userIdParamsSchema)
if (!this.affiliations[params.userId]) return res.json([])
const affiliations = this.affiliations[params.userId].map(affiliation => {
const institutionId = affiliation.institution.id
const domain = affiliation.email.split('@').pop()
const domainData = this.institutionDomains[institutionId][domain] || {}
const institutionData = this.institutions[institutionId] || {}
affiliation.institution = {
id: institutionId,
name: institutionData.name,
commonsAccount: institutionData.commonsAccount,
writefullCommonsAccount:
institutionData.writefullCommonsAccount || false,
isUniversity: !institutionData.institution,
ssoBeta: institutionData.sso_beta || false,
ssoEnabled: institutionData.sso_enabled || false,
maxConfirmationMonths:
institutionData.maxConfirmationMonths || null,
}
affiliation.institution.confirmed = !!domainData.confirmed
affiliation.licence = 'free'
if (
institutionData.commonsAccount &&
(!institutionData.sso_enabled ||
(institutionData.sso_enabled &&
affiliation.cached_entitlement === true))
) {
affiliation.licence = 'pro_plus'
}
if (
institutionData.maxConfirmationMonths &&
affiliation.cached_reconfirmed_at
) {
const lastDayToReconfirm = moment(
affiliation.cached_reconfirmed_at
).add(institutionData.maxConfirmationMonths, 'months')
affiliation.last_day_to_reconfirm = lastDayToReconfirm.toDate()
affiliation.past_reconfirm_date = lastDayToReconfirm.isBefore()
}
affiliation.domainCapturedByGroup = domainData.captured_by_group
return affiliation
affiliation.institution = {
id: institutionId,
name: institutionData.name,
commonsAccount: institutionData.commonsAccount,
writefullCommonsAccount:
institutionData.writefullCommonsAccount || false,
isUniversity: !institutionData.institution,
ssoBeta: institutionData.sso_beta || false,
ssoEnabled: institutionData.sso_enabled || false,
maxConfirmationMonths: institutionData.maxConfirmationMonths || null,
}
)
affiliation.institution.confirmed = !!domainData.confirmed
affiliation.licence = 'free'
if (
institutionData.commonsAccount &&
(!institutionData.sso_enabled ||
(institutionData.sso_enabled &&
affiliation.cached_entitlement === true))
) {
affiliation.licence = 'pro_plus'
}
if (
institutionData.maxConfirmationMonths &&
affiliation.cached_reconfirmed_at
) {
const lastDayToReconfirm = moment(
affiliation.cached_reconfirmed_at
).add(institutionData.maxConfirmationMonths, 'months')
affiliation.last_day_to_reconfirm = lastDayToReconfirm.toDate()
affiliation.past_reconfirm_date = lastDayToReconfirm.isBefore()
}
affiliation.domainCapturedByGroup = domainData.captured_by_group
return affiliation
})
res.json(affiliations)
})
this.app.post('/api/v2/users/:userId/affiliations', (req, res) => {
const { params, body } = parseReq(req, addAffiliationSchema)
this.addAffiliation(
req.params.userId,
req.body.email,
req.body.entitlement,
req.body.confirmedAt
params.userId,
body.email,
body.entitlement,
body.confirmedAt
)
res.sendStatus(201)
})
@@ -332,8 +456,9 @@ class MockV1Api extends AbstractMockApi {
)
this.app.get('/api/v2/brands/:slug', (req, res) => {
const { params } = parseReq(req, brandSlugSchema)
let brand
if ((brand = this.brands[req.params.slug])) {
if ((brand = this.brands[params.slug])) {
res.json(brand)
} else {
res.sendStatus(404)
@@ -341,6 +466,7 @@ class MockV1Api extends AbstractMockApi {
})
this.app.get('/universities/list', (req, res) => {
const { query } = parseReq(req, universitiesListSchema)
const response = []
const university1 = {
@@ -357,27 +483,29 @@ class MockV1Api extends AbstractMockApi {
departments: [],
}
if (req.query.country_code === 'en') {
if (query.country_code === 'en') {
response.push(university1)
}
if (req.query.search === 'Institution') {
if (query.search === 'Institution') {
response.push(university1)
if (req.query.max_results !== '1') {
if (query.max_results !== '1') {
response.push(university2)
}
}
res.json(response)
})
this.app.get('/universities/list/:id', (req, res) =>
this.app.get('/universities/list/:id', (req, res) => {
const { params } = parseReq(req, universityByIdSchema)
res.json({
id: parseInt(req.params.id),
name: `Institution ${req.params.id}`,
id: parseInt(params.id),
name: `Institution ${params.id}`,
})
)
})
this.app.get('/university/domains', (req, res) => {
if (req.query.hostname === 'overleaf.com') {
const { query } = parseReq(req, universityDomainsSchema)
if (query.hostname === 'overleaf.com') {
res.json([
{
id: 42,
@@ -393,7 +521,7 @@ class MockV1Api extends AbstractMockApi {
},
},
])
} else if (req.query.hostname === 'sharelatex.com') {
} else if (query.hostname === 'sharelatex.com') {
res.json([
{
id: 44,
@@ -416,22 +544,24 @@ class MockV1Api extends AbstractMockApi {
})
this.app.put('/api/v1/overleaf/users/:id/email', (req, res) => {
const { email } = req.body && req.body.user
const { params, body } = parseReq(req, updateEmailSchema)
const { email } = body.user
if (this.existingEmails.includes(email)) {
res.sendStatus(409)
} else {
this.updateEmail(parseInt(req.params.id), email)
this.updateEmail(parseInt(params.id), email)
res.sendStatus(200)
}
})
this.app.post('/api/v1/overleaf/login', (req, res) => {
const { body } = parseReq(req, loginSchema)
for (const id in this.users) {
const user = this.users[id]
if (
user &&
user.email === req.body.email &&
user.password === req.body.password
user.email === body.email &&
user.password === body.password
) {
return res.json({
email: user.email,
@@ -441,15 +571,16 @@ class MockV1Api extends AbstractMockApi {
}
}
res.status(403).json({
email: req.body.email,
email: body.email,
valid: false,
})
})
this.app.get('/api/v2/partners/:partner/conversions/:id', (req, res) => {
const partner = this.validation_clients[req.params.partner]
const { params } = parseReq(req, partnerConversionSchema)
const partner = this.validation_clients[params.partner]
const conversion =
partner && partner.conversions && partner.conversions[req.params.id]
partner && partner.conversions && partner.conversions[params.id]
if (conversion) {
res.status(200).json({
@@ -462,7 +593,8 @@ class MockV1Api extends AbstractMockApi {
})
this.app.get('/api/v2/brand_variations/:id', (req, res) => {
const variation = this.brand_variations[req.params.id]
const { params } = parseReq(req, brandVariationSchema)
const variation = this.brand_variations[params.id]
if (variation) {
res.status(200).json(variation)
} else {
@@ -477,7 +609,8 @@ class MockV1Api extends AbstractMockApi {
this.app.get(
'/api/v1/overleaf/users/:user_id/docs/:token/info',
(req, res) => {
const info = this.getDocInfo(req.params.token) || {
const { params } = parseReq(req, tokenParamsSchema)
const info = this.getDocInfo(params.token) || {
exists: false,
exported: false,
}
@@ -486,7 +619,8 @@ class MockV1Api extends AbstractMockApi {
)
this.app.get('/api/v1/overleaf/docs/:token/info', (req, res) => {
const info = this.getDocInfo(req.params.token) || {
const { params } = parseReq(req, tokenParamsSchema)
const info = this.getDocInfo(params.token) || {
exists: false,
exported: false,
}
@@ -501,7 +635,8 @@ class MockV1Api extends AbstractMockApi {
)
this.app.get('/api/v2/templates/:templateId', (req, res) => {
const template = this.templates[req.params.templateId]
const { params } = parseReq(req, templateSchema)
const template = this.templates[params.templateId]
if (!template) {
return res.sendStatus(404)
}
@@ -511,8 +646,9 @@ class MockV1Api extends AbstractMockApi {
this.app.get(
'/api/v1/overleaf/fake_route_api_handler_tests',
(req, res) => {
const expectedStatus = req.query.expectedStatus
const expectedBody = req.query.expectedBody
const { query } = parseReq(req, fakeRouteApiHandlerTestsSchema)
const expectedStatus = query.expectedStatus
const expectedBody = query.expectedBody
const statusCode = Number(expectedStatus)
if (
!Number.isInteger(statusCode) ||
@@ -4,7 +4,66 @@ import {
zipAttachment,
prepareZipAttachment,
} from '../../../../app/src/infrastructure/Response.mjs'
import { z } from 'zod'
import { Blob } from 'overleaf-editor-core'
import { parseReq, z, zz } from '@overleaf/validation-tools'
// Mirrors services/history-v1/api/schema.js (the canonical template for this
// whole epic) for routes that correspond to a real history-v1 endpoint.
// Routes that only exist as internal mock plumbing (fake-zip-download,
// manufactured by this mock's own createZip handler, not a real history-v1
// route) get their own narrow schema instead.
const hexHashPattern = new RegExp(Blob.HEX_HASH_RX_STRING)
// matches history-v1's getProjectBlobsStats
const blobStatsSchema = z.object({
body: z.strictObject({
projectIds: z.array(zz.projectHistoryId()),
}),
})
// matches history-v1's getBlobStats -- this mock binds the project id param
// as `historyId` rather than `project_id`.
const projectBlobStatsSchema = z.object({
params: z.strictObject({ historyId: zz.projectHistoryId() }),
body: z.strictObject({ blobHashes: z.array(z.string()) }),
})
// matches history-v1's getZip/createZip
const versionZipParamsSchema = z.object({
params: z.strictObject({
project_id: zz.projectHistoryId(),
version: z.coerce.number(),
}),
})
// Not a real history-v1 route -- this is the URL this mock's own createZip
// handler manufactures for the client to poll/download from. `version` is
// compared against literal strings below, so it stays an un-coerced string
// here rather than mirroring versionZipParamsSchema's z.coerce.number().
const fakeZipDownloadParamsSchema = z.object({
params: z.strictObject({
project_id: zz.projectHistoryId(),
version: z.string(),
}),
})
// matches history-v1's createProjectBlob/headProjectBlob/getProjectBlob
const blobParamsSchema = z.object({
params: z.strictObject({
projectId: zz.projectHistoryId(),
hash: z.string().regex(hexHashPattern),
}),
})
// matches history-v1's copyProjectBlob query (params aren't read by this
// mock's handler, so they're left out of the schema)
const copyProjectBlobSchema = z.object({
query: z.object({
copyFrom: zz.projectHistoryId(),
sizeLimit: z.coerce.number().optional(),
}),
})
class MockV1HistoryApi extends AbstractMockApi {
reset() {
@@ -65,17 +124,19 @@ class MockV1HistoryApi extends AbstractMockApi {
applyRoutes() {
this.app.post('/api/projects/blob-stats', (req, res, next) => {
const { body } = parseReq(req, blobStatsSchema)
res.json(
// Calculate actual sizes from uploaded blobs
req.body.projectIds.map(projectId => {
body.projectIds.map(projectId => {
return this.computeBlobStats(projectId)
})
)
})
this.app.post('/api/projects/:historyId/blob-stats', (req, res, next) => {
const { historyId } = req.params
const { blobHashes } = req.body
const { params, body } = parseReq(req, projectBlobStatsSchema)
const { historyId } = params
const { blobHashes } = body
// Calculate actual sizes from uploaded blobs
const result = this.computeBlobStats(historyId, blobHashes)
res.json(result)
@@ -84,10 +145,11 @@ class MockV1HistoryApi extends AbstractMockApi {
this.app.get(
'/api/projects/:project_id/version/:version/zip',
(req, res, next) => {
const { params } = parseReq(req, versionZipParamsSchema)
this.sentChunks++
zipAttachment(
res,
`Mock zip for ${req.params.project_id} at version ${req.params.version}`,
`Mock zip for ${params.project_id} at version ${params.version}`,
'project.zip'
)
}
@@ -96,13 +158,14 @@ class MockV1HistoryApi extends AbstractMockApi {
this.app.get(
'/fake-zip-download/:project_id/version/:version',
(req, res, next) => {
const { params } = parseReq(req, fakeZipDownloadParamsSchema)
if (!(this.fakeZipCall++ > 0)) {
return res.sendStatus(404)
}
if (req.params.version === '42') {
if (params.version === '42') {
return zipAttachment(
res,
`Mock zip for ${req.params.project_id} at version ${req.params.version}`,
`Mock zip for ${params.project_id} at version ${params.version}`,
'project.zip'
)
}
@@ -124,7 +187,7 @@ class MockV1HistoryApi extends AbstractMockApi {
}, 10 * 1000)
res.on('end', () => clearTimeout(deadLine))
}
if (req.params.version === '100') {
if (params.version === '100') {
return writeEvery(100)
}
res.sendStatus(400)
@@ -134,10 +197,11 @@ class MockV1HistoryApi extends AbstractMockApi {
this.app.post(
'/api/projects/:project_id/version/:version/zip',
(req, res, next) => {
const { params } = parseReq(req, versionZipParamsSchema)
this.requestedZipPacks++
this.events.emit('v1-history-pack-zip')
res.json({
zipUrl: `http://127.0.0.1:23100/fake-zip-download/${req.params.project_id}/version/${req.params.version}`,
zipUrl: `http://127.0.0.1:23100/fake-zip-download/${params.project_id}/version/${params.version}`,
})
}
)
@@ -147,10 +211,11 @@ class MockV1HistoryApi extends AbstractMockApi {
})
this.app.put('/api/projects/:projectId/blobs/:hash', (req, res, next) => {
const { params } = parseReq(req, blobParamsSchema)
const { projectId, hash } = params
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => {
const { projectId, hash } = req.params
if (!this.blobs[projectId]) {
this.blobs[projectId] = {}
}
@@ -159,27 +224,23 @@ class MockV1HistoryApi extends AbstractMockApi {
})
})
this.app.head('/api/projects/:projectId/blobs/:hash', (req, res, next) => {
const { projectId, hash } = req.params
const { params } = parseReq(req, blobParamsSchema)
const { projectId, hash } = params
const buf = this.blobs[projectId]?.[hash]
if (!buf) return res.status(404).end()
res.set('Content-Length', buf.byteLength)
res.status(200).end()
})
this.app.get('/api/projects/:projectId/blobs/:hash', (req, res, next) => {
const { projectId, hash } = req.params
const { params } = parseReq(req, blobParamsSchema)
const { projectId, hash } = params
const buf = this.blobs[projectId]?.[hash]
if (!buf) return res.status(404).end()
res.status(200).end(buf)
})
this.app.post('/api/projects/:project_id/blobs/:hash', (req, res, next) => {
const schema = z.object({
copyFrom: z.coerce.number(),
})
const { error } = schema.safeParse(req.query)
if (error) {
return res.sendStatus(400)
}
parseReq(req, copyProjectBlobSchema)
res.sendStatus(204)
})
}
@@ -5,6 +5,7 @@ import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
import mongodb from 'mongodb-legacy'
import AuthenticationErrors from '../../../../app/src/Features/Authentication/AuthenticationErrors.mjs'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
const modulePath =
'../../../../app/src/Features/Authentication/AuthenticationController.mjs'
@@ -1097,6 +1098,24 @@ describe('AuthenticationController', function () {
.should.equal(false)
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
afterEach(function () {
setReqValidationModeForTests(null)
})
it('rejects a non-string zipUrl query param', function (ctx) {
ctx.req.query.zipUrl = ['a', 'b']
ctx.SessionManager.isUserLoggedIn = sinon.stub().returns(false)
expect(() => ctx.middleware(ctx.req, ctx.res, ctx.next)).toThrowError(
expect.objectContaining({ name: 'InvalidRequestError' })
)
})
})
})
describe('_redirectToRegisterPage', function () {
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
const { ObjectId } = mongodb
@@ -15,7 +15,9 @@ describe('AuthorizationMiddleware', function () {
ctx.doc_id = new ObjectId().toString()
ctx.thread_id = new ObjectId().toString()
ctx.token = 'some-token'
ctx.AuthenticationController = {}
ctx.AuthenticationController = {
setRedirectInSession: sinon.stub(),
}
ctx.SessionManager = {
getSessionUser: sinon.stub().returns(null),
getLoggedInUserId: sinon.stub().returns(ctx.userId),
@@ -46,10 +48,6 @@ describe('AuthorizationMiddleware', function () {
},
}
vi.doMock('../../../../app/src/Features/Errors/Errors.js', () => ({
default: Errors,
}))
vi.doMock(
'../../../../app/src/Features/Authorization/AuthorizationManager',
() => ({
@@ -118,6 +116,10 @@ describe('AuthorizationMiddleware', function () {
ctx.next = sinon.stub()
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('ensureCanReadProject', function () {
testMiddleware('ensureUserCanReadProject', 'canUserReadProject')
})
@@ -185,6 +187,23 @@ describe('AuthorizationMiddleware', function () {
'canUserWriteProjectSettings'
)
})
describe('request validation', function () {
beforeEach(function (ctx) {
setReqValidationModeForTests('enforce')
ctx.req.body.name = 42
})
invokeMiddleware('ensureUserCanWriteProjectSettings')
expectBadRequest()
it('does not check any write permissions', function (ctx) {
expect(ctx.AuthorizationManager.promises.canUserRenameProject).to.not
.have.been.called
expect(ctx.AuthorizationManager.promises.canUserWriteProjectSettings).to
.not.have.been.called
})
})
})
describe('ensureUserCanAdminProject', function () {
@@ -327,6 +346,73 @@ describe('AuthorizationMiddleware', function () {
})
})
})
describe('request validation', function () {
beforeEach(function (ctx) {
setReqValidationModeForTests('enforce')
ctx.req.query = { project_ids: ['project1', 'project2'] }
})
invokeMiddleware('ensureUserCanReadMultipleProjects')
expectBadRequest()
it('does not check any project permissions', function (ctx) {
expect(ctx.AuthorizationManager.promises.canUserReadProject).to.not.have
.been.called
})
})
})
describe('restricted', function () {
setupAnonymousUser()
describe('with a redirect target', function () {
beforeEach(function (ctx) {
ctx.req.query = { from: '/project/123' }
})
it('stores the redirect target and sends the user to login', function (ctx) {
ctx.AuthorizationMiddleware.restricted(ctx.req, ctx.res, ctx.next)
expect(
ctx.AuthenticationController.setRedirectInSession
).to.have.been.calledWith(ctx.req, '/project/123')
expect(ctx.res.redirect).to.have.been.calledWith('/login')
})
})
describe('without a redirect target', function () {
beforeEach(function (ctx) {
ctx.req.query = {}
})
it('sends the user to login', function (ctx) {
ctx.AuthorizationMiddleware.restricted(ctx.req, ctx.res, ctx.next)
expect(ctx.AuthenticationController.setRedirectInSession).to.not.have
.been.called
expect(ctx.res.redirect).to.have.been.calledWith('/login')
})
})
describe('request validation', function () {
beforeEach(function (ctx) {
setReqValidationModeForTests('enforce')
ctx.req.query = { from: ['not-a-string'] }
})
it('rejects a non-string from parameter', function (ctx) {
let error
try {
ctx.AuthorizationMiddleware.restricted(ctx.req, ctx.res, ctx.next)
} catch (err) {
error = err
}
expect(error).to.exist
expect(error.name).to.equal('InvalidRequestError')
expect(ctx.AuthenticationController.setRedirectInSession).to.not.have
.been.called
expect(ctx.res.redirect).to.not.have.been.called
})
})
})
})
@@ -467,8 +553,19 @@ function expectError() {
function expectNotFound() {
it('raises a 404', function (ctx) {
// a malformed id fails zz.objectId() validation in parseReq(), which
// throws InvalidParamsError (@overleaf/validation-tools) -- handled by
// handleValidationError into a 404, same as the old manual check
expect(ctx.next).to.have.been.calledWith(
sinon.match.instanceOf(Errors.NotFoundError)
sinon.match.has('name', 'InvalidParamsError')
)
})
}
function expectBadRequest() {
it('raises a 400', function (ctx) {
expect(ctx.next).to.have.been.calledWith(
sinon.match.has('name', 'InvalidRequestError')
)
})
}
@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
const MODULE_PATH = '../../../../app/src/Features/Captcha/CaptchaMiddleware.mjs'
describe('CaptchaMiddleware', function () {
beforeEach(async function (ctx) {
// No siteKey configured -> validateCaptcha takes its "disabled" branch
// and calls next() right after stripRecaptchaField, without needing to
// stub the recaptcha siteverify network call or DeviceHistory.
ctx.Settings = {
recaptcha: {
siteKey: undefined,
disabled: {},
trustedUsers: [],
},
deviceHistory: {
cookieName: 'deviceHistory',
entryExpiry: 90 * 24 * 60 * 60 * 1000,
maxEntries: 10,
secret: undefined,
},
}
ctx.Metrics = {
inc: sinon.stub(),
}
ctx.AuthenticationController = {
setAuditInfo: sinon.stub(),
}
ctx.fetchJson = sinon
.stub()
.rejects(new Error('fetchJson should not be called in this test'))
vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings }))
vi.doMock('@overleaf/metrics', () => ({ default: ctx.Metrics }))
vi.doMock('@overleaf/fetch-utils', () => ({ fetchJson: ctx.fetchJson }))
vi.doMock(
'../../../../app/src/Features/Authentication/AuthenticationController',
() => ({ default: ctx.AuthenticationController })
)
ctx.CaptchaMiddleware = (await import(MODULE_PATH)).default
ctx.res = {
status: sinon.stub().returnsThis(),
json: sinon.stub(),
}
ctx.next = sinon.stub()
})
describe('stripRecaptchaField (via validateCaptcha)', function () {
async function runValidateCaptcha(ctx, body) {
ctx.req = { body }
await ctx.CaptchaMiddleware.validateCaptcha('login')(
ctx.req,
ctx.res,
ctx.next
)
}
it('strips a captcha-token string', async function (ctx) {
await runValidateCaptcha(ctx, {
email: 'user@example.com',
'g-recaptcha-response': 'some-captcha-token',
})
expect(ctx.next).to.have.been.calledWithExactly()
expect(ctx.req.body).to.not.have.property('g-recaptcha-response')
expect(ctx.req.body).to.deep.equal({ email: 'user@example.com' })
})
it('strips an explicit null', async function (ctx) {
await runValidateCaptcha(ctx, {
email: 'user@example.com',
'g-recaptcha-response': null,
})
expect(ctx.next).to.have.been.calledWithExactly()
expect(ctx.req.body).to.not.have.property('g-recaptcha-response')
expect(ctx.req.body).to.deep.equal({ email: 'user@example.com' })
})
it('strips an empty string', async function (ctx) {
await runValidateCaptcha(ctx, {
email: 'user@example.com',
'g-recaptcha-response': '',
})
expect(ctx.next).to.have.been.calledWithExactly()
expect(ctx.req.body).to.not.have.property('g-recaptcha-response')
expect(ctx.req.body).to.deep.equal({ email: 'user@example.com' })
})
it('leaves a body without the field unchanged', async function (ctx) {
const body = { email: 'user@example.com', otherField: 'value' }
await runValidateCaptcha(ctx, body)
expect(ctx.next).to.have.been.calledWithExactly()
// stripRecaptchaField only reassigns req.body when the key is
// present, so the original object reference is preserved here.
expect(ctx.req.body).to.equal(body)
expect(ctx.req.body).to.deep.equal({
email: 'user@example.com',
otherField: 'value',
})
})
})
})
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import { getRawReqInput } from '@overleaf/validation-tools'
const modulePath = '../../../../app/src/infrastructure/Csrf.mjs'
describe('Csrf', function () {
@@ -96,6 +97,70 @@ describe('Csrf', function () {
expect(ctx.next.calledWith(ctx.err)).to.equal(false)
})
})
describe('stripping _csrf after a successful check', function () {
beforeEach(async function (ctx) {
vi.resetModules()
vi.doMock('csurf', () => ({
default: sinon.stub().returns(sinon.stub().callsArg(2)),
}))
const module = await import(modulePath)
ctx.Csrf = module.default
ctx.CsrfClass = module.Csrf
ctx.csrf = new ctx.CsrfClass()
})
it('removes _csrf from req.body, leaving other fields untouched', function (ctx) {
const req = {
path: ctx.path,
method: 'POST',
body: { _csrf: 'a-token', other: 'field' },
}
ctx.csrf.middleware(req, ctx.res, ctx.next)
// test assertion on raw wire data (case 3 of getRawReqInput's
// allowlist): inspecting exactly what the middleware left on req
expect(getRawReqInput(req).body).to.deep.equal({ other: 'field' })
expect(ctx.next.calledWith()).to.equal(true)
})
it('removes _csrf from req.query, leaving other fields untouched', function (ctx) {
const req = {
path: ctx.path,
method: 'GET',
query: { _csrf: 'a-token', other: 'field' },
}
ctx.csrf.middleware(req, ctx.res, ctx.next)
expect(getRawReqInput(req).query).to.deep.equal({ other: 'field' })
})
it('is a no-op when there is no _csrf field', function (ctx) {
const req = {
path: ctx.path,
method: 'POST',
body: { other: 'field' },
}
ctx.csrf.middleware(req, ctx.res, ctx.next)
expect(getRawReqInput(req).body).to.deep.equal({ other: 'field' })
})
it('does not strip _csrf on an excluded route', function (ctx) {
// Excluded routes (e.g. /docs) still run their own second,
// independent csrf check further down their middleware stack (see
// OpenInOverleafMiddleware.postMiddleware), which needs the field
// to still be there.
ctx.csrf.disableDefaultCsrfProtection(ctx.path, 'POST')
const req = {
path: ctx.path,
method: 'POST',
body: { _csrf: 'a-token', other: 'field' },
}
ctx.csrf.middleware(req, ctx.res, ctx.next)
expect(getRawReqInput(req).body).to.deep.equal({
_csrf: 'a-token',
other: 'field',
})
})
})
})
describe('validateRequest', function () {
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import {
InvalidRequestError,
setReqValidationModeForTests,
} from '@overleaf/validation-tools'
import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
const MODULE_PATH =
'../../../../app/src/infrastructure/UnsupportedBrowserMiddleware.mjs'
describe('UnsupportedBrowserMiddleware', function () {
beforeEach(async function (ctx) {
vi.doMock('@overleaf/settings', () => ({
default: (ctx.settings = {
unsupportedBrowsers: { chrome: '<50' },
siteUrl: 'https://www.overleaf.com',
}),
}))
ctx.UnsupportedBrowserMiddleware = (await import(MODULE_PATH)).default
ctx.req = new MockRequest(vi)
ctx.req.path = '/project'
ctx.res = new MockResponse(vi)
ctx.next = sinon.stub()
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('unsupportedBrowserMiddleware', function () {
describe('request validation', function () {
beforeEach(function (ctx) {
setReqValidationModeForTests('enforce')
ctx.req.headers = { 'user-agent': ['a', 'b'] }
})
it('rejects a non-string user-agent header', function (ctx) {
let error
try {
ctx.UnsupportedBrowserMiddleware.unsupportedBrowserMiddleware(
ctx.req,
ctx.res,
ctx.next
)
} catch (err) {
error = err
}
expect(error).to.be.instanceOf(InvalidRequestError)
sinon.assert.notCalled(ctx.next)
})
})
})
})
+58 -11
View File
@@ -5497,7 +5497,7 @@ __metadata:
languageName: node
linkType: hard
"@node-oauth/oauth2-server@npm:^5.3.0":
"@node-oauth/oauth2-server@npm:5.3.0":
version: 5.3.0
resolution: "@node-oauth/oauth2-server@npm:5.3.0"
dependencies:
@@ -5508,6 +5508,17 @@ __metadata:
languageName: node
linkType: hard
"@node-oauth/oauth2-server@patch:@node-oauth/oauth2-server@npm%3A5.3.0#~/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch":
version: 5.3.0
resolution: "@node-oauth/oauth2-server@patch:@node-oauth/oauth2-server@npm%3A5.3.0#~/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch::version=5.3.0&hash=acb1f1"
dependencies:
"@node-oauth/formats": "npm:1.0.0"
basic-auth: "npm:2.0.1"
type-is: "npm:2.0.1"
checksum: 10c0/e3b5c0303b9c13049ae65c93779b8e06bc63fae9ef554e37d2bd8cb0d1afb7fab279fe6d007267b50ab36570c1f692a61c8791cd148196623f276b9b0804a65e
languageName: node
linkType: hard
"@node-rs/crc32@npm:^0.1.7":
version: 0.1.8
resolution: "@node-rs/crc32@npm:0.1.8"
@@ -5546,7 +5557,7 @@ __metadata:
languageName: node
linkType: hard
"@node-saml/passport-saml@npm:^5.1.0":
"@node-saml/passport-saml@npm:5.1.0":
version: 5.1.0
resolution: "@node-saml/passport-saml@npm:5.1.0"
dependencies:
@@ -5560,6 +5571,20 @@ __metadata:
languageName: node
linkType: hard
"@node-saml/passport-saml@patch:@node-saml/passport-saml@npm%3A5.1.0#~/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch":
version: 5.1.0
resolution: "@node-saml/passport-saml@patch:@node-saml/passport-saml@npm%3A5.1.0#~/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch::version=5.1.0&hash=efcef3"
dependencies:
"@node-saml/node-saml": "npm:^5.1.0"
"@types/express": "npm:^4.17.23"
"@types/passport": "npm:^1.0.17"
"@types/passport-strategy": "npm:^0.2.38"
passport: "npm:^0.7.0"
passport-strategy: "npm:^1.0.0"
checksum: 10c0/0c26d6e048386dcd7edc5d9d8031246b2a2600bd9d3d03749973247c70d8cd261a8d29364ec26c75dbded32922cf4a2f192c2fc17d7611cb71a890435aa0e905
languageName: node
linkType: hard
"@nodelib/fs.scandir@npm:2.1.5":
version: 2.1.5
resolution: "@nodelib/fs.scandir@npm:2.1.5"
@@ -7924,8 +7949,8 @@ __metadata:
"@lezer/highlight": "npm:1.2.3"
"@lezer/lr": "npm:1.4.7"
"@lezer/markdown": "npm:1.6.3"
"@node-oauth/oauth2-server": "npm:^5.3.0"
"@node-saml/passport-saml": "npm:^5.1.0"
"@node-oauth/oauth2-server": "patch:@node-oauth/oauth2-server@npm%3A5.3.0#~/.yarn/patches/@node-oauth-oauth2-server-npm-5.3.0-9d66d9183c.patch"
"@node-saml/passport-saml": "patch:@node-saml/passport-saml@npm%3A5.1.0#~/.yarn/patches/@node-saml-passport-saml-npm-5.1.0-5d7df575fa.patch"
"@overleaf/access-token-encryptor": "workspace:*"
"@overleaf/codemirror-tree-view": "npm:^0.1.3"
"@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.3.tar.gz"
@@ -8065,7 +8090,7 @@ __metadata:
crc-32: "npm:^1.2.2"
css-loader: "npm:^7.1.4"
css-minimizer-webpack-plugin: "npm:^8.0.0"
csurf: "npm:^1.11.0"
csurf: "patch:csurf@npm%3A1.11.0#~/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch"
csv: "npm:^6.2.5"
csv-parse: "npm:^6.2.1"
csv-stringify: "npm:^6.7.0"
@@ -8101,7 +8126,7 @@ __metadata:
eventsource-client: "npm:^1.1.4"
eventsource-parser: "npm:^1.1.2"
express: "npm:4.22.1"
express-bearer-token: "npm:^2.4.0"
express-bearer-token: "patch:express-bearer-token@npm%3A2.4.0#~/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch"
express-http-proxy: "npm:^1.6.0"
express-session: "npm:^1.17.1"
fake-indexeddb: "npm:^6.0.0"
@@ -8175,7 +8200,7 @@ __metadata:
passport-google-oauth20: "npm:^2.0.0"
passport-ldapauth: "npm:^2.1.4"
passport-local: "npm:^1.0.0"
passport-oauth2: "npm:^1.5.0"
passport-oauth2: "patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch"
passport-orcid: "npm:0.0.4"
path-browserify: "npm:^1.0.1"
pdfjs-dist: "npm:5.1.91"
@@ -16123,7 +16148,7 @@ __metadata:
languageName: node
linkType: hard
"csurf@npm:^1.11.0":
"csurf@npm:1.11.0":
version: 1.11.0
resolution: "csurf@npm:1.11.0"
dependencies:
@@ -16135,6 +16160,18 @@ __metadata:
languageName: node
linkType: hard
"csurf@patch:csurf@npm%3A1.11.0#~/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch":
version: 1.11.0
resolution: "csurf@patch:csurf@npm%3A1.11.0#~/.yarn/patches/csurf-npm-1.11.0-c1b9cbb35b.patch::version=1.11.0&hash=21d28e"
dependencies:
cookie: "npm:0.4.0"
cookie-signature: "npm:1.0.6"
csrf: "npm:3.1.0"
http-errors: "npm:~1.7.3"
checksum: 10c0/4485c22cea896eb41398c8a1b5b00904c5e1bb817737dbd3af1f3c177ddaa86787379a93d4c5af224937a277dc63afbb7f362b61677d21c58432671e2256bcf6
languageName: node
linkType: hard
"csv-generate@npm:^3.4.3":
version: 3.4.3
resolution: "csv-generate@npm:3.4.3"
@@ -18393,7 +18430,7 @@ __metadata:
languageName: node
linkType: hard
"express-bearer-token@npm:^2.4.0":
"express-bearer-token@npm:2.4.0":
version: 2.4.0
resolution: "express-bearer-token@npm:2.4.0"
dependencies:
@@ -18403,6 +18440,16 @@ __metadata:
languageName: node
linkType: hard
"express-bearer-token@patch:express-bearer-token@npm%3A2.4.0#~/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch":
version: 2.4.0
resolution: "express-bearer-token@patch:express-bearer-token@npm%3A2.4.0#~/.yarn/patches/express-bearer-token-npm-2.4.0-731c8c5c99.patch::version=2.4.0&hash=77675f"
dependencies:
cookie: "npm:^0.3.1"
cookie-parser: "npm:^1.4.4"
checksum: 10c0/340caa2940aa6c3b38923995c1d3e77210c0c4284bba6e4a67b9df77693184603eed4ed0f2df17584b2315f3e0f1da00c2f2e1c48cc4465b3330d5149e0703b8
languageName: node
linkType: hard
"express-flash@npm:0.0.2":
version: 0.0.2
resolution: "express-flash@npm:0.0.2"
@@ -26569,14 +26616,14 @@ __metadata:
"passport-oauth2@patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch":
version: 1.6.1
resolution: "passport-oauth2@patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch::version=1.6.1&hash=94a003"
resolution: "passport-oauth2@patch:passport-oauth2@npm%3A1.6.1#~/.yarn/patches/passport-oauth2-npm-1.6.1.patch::version=1.6.1&hash=fc4394"
dependencies:
base64url: "npm:3.x.x"
oauth: "npm:0.9.x"
passport-strategy: "npm:1.x.x"
uid2: "npm:0.0.x"
utils-merge: "npm:1.x.x"
checksum: 10c0/f35a6c83ace4ad9543ebaf2b3681720305679cb794ac44e7623cd88c9a19cecdcdb66cb17a4ea3092de48e6bc8d3869176d56cfc3a57a14cacc5d376f2300892
checksum: 10c0/37aed993f924369a29bac3b40c748e8c4094f97acd8b102a78bd1ee6d7c8f5004a9cfac13e424d7355878382cf7f86d0f5f2b18c9150af387c0718a633b8e428
languageName: node
linkType: hard