mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[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:
@@ -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);
|
||||
@@ -1,3 +1,75 @@
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,13 +367,12 @@ 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 { 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 domainData = this.institutionDomains[institutionId][domain] || {}
|
||||
const institutionData = this.institutions[institutionId] || {}
|
||||
|
||||
affiliation.institution = {
|
||||
@@ -258,8 +384,7 @@ class MockV1Api extends AbstractMockApi {
|
||||
isUniversity: !institutionData.institution,
|
||||
ssoBeta: institutionData.sso_beta || false,
|
||||
ssoEnabled: institutionData.sso_enabled || false,
|
||||
maxConfirmationMonths:
|
||||
institutionData.maxConfirmationMonths || null,
|
||||
maxConfirmationMonths: institutionData.maxConfirmationMonths || null,
|
||||
}
|
||||
|
||||
affiliation.institution.confirmed = !!domainData.confirmed
|
||||
@@ -286,19 +411,18 @@ class MockV1Api extends AbstractMockApi {
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user