first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
# SmokeTests
For the SmokeTests we implemented a Mini-Framework that is tailored for our
tooling, specifically OError, and does not need a large runner, such as mocha.
The SmokeTests are separated into individual `steps`.
Each `step` can have a `run` function and a `cleanup` function.
The former will run in sequence with the other steps, the later in reverse
order from the finish, or the last failure.
```js
async function run(ctx) {
// do something
}
async function cleanup(ctx) {
// cleanup something
}
module.exports = { cleanup, run }
```
Steps will get called with a context object with common helpers and details:
- `request` a promisified `request` module with defaults for `baseUrl`,
`timeout` and internals for cookie handling.
- `assertHasStatusCode` a helper for asserting response status codes, pass
a response and desired status code. It will throw with OError context set.
- `getCsrfTokenFor` a helper for retrieving CSRF tokens, pass an endpoint.
- `processWithTimeout` a helper for awaiting Promises with a timeout, pass
`{ work: Promise.resolve(), timeout: 42, message: 'foo timedout' }`
- `stats` an object for performance tracking.
- `timeout` the step timeout
Steps should handle timeouts locally to ensure appropriate cleanup of timed out
actions.
Steps may pass values along to the next steps in returning an object with the
desired fields from the `run` or `cleanup` function.
The returned values will overwrite existing details in the `ctx`.
Alpha-numeric sorting of step filenames determines the processing sequence.

View File

@@ -0,0 +1,95 @@
const fs = require('fs')
const Path = require('path')
const Settings = require('@overleaf/settings')
const { getCsrfTokenForFactory } = require('./support/Csrf')
const { SmokeTestFailure } = require('./support/Errors')
const {
requestFactory,
assertHasStatusCode,
} = require('./support/requestHelper')
const { processWithTimeout } = require('./support/timeoutHelper')
const STEP_TIMEOUT = Settings.smokeTest.stepTimeout
const PATH_STEPS = Path.join(__dirname, './steps')
const STEPS = fs
.readdirSync(PATH_STEPS)
.sort()
.map(name => {
const step = require(Path.join(PATH_STEPS, name))
step.name = Path.basename(name, '.js')
return step
})
async function runSmokeTests({ isAborted, stats }) {
let lastStep = stats.start
function completeStep(key) {
const step = Date.now()
stats.steps.push({ [key]: step - lastStep })
lastStep = step
}
const request = requestFactory({ timeout: STEP_TIMEOUT })
const getCsrfTokenFor = getCsrfTokenForFactory({ request })
const ctx = {
assertHasStatusCode,
getCsrfTokenFor,
processWithTimeout,
request,
stats,
timeout: STEP_TIMEOUT,
}
const cleanupSteps = []
async function runAndTrack(id, fn) {
let result
try {
result = await fn(ctx)
} catch (e) {
throw new SmokeTestFailure(`${id} failed`, {}, e)
} finally {
completeStep(id)
}
Object.assign(ctx, result)
}
completeStep('init')
let err
try {
for (const step of STEPS) {
if (isAborted()) break
const { name, run, cleanup } = step
if (cleanup) cleanupSteps.unshift({ name, cleanup })
await runAndTrack(`run.${name}`, run)
}
} catch (e) {
err = e
}
const cleanupErrors = []
for (const step of cleanupSteps) {
const { name, cleanup } = step
try {
await runAndTrack(`cleanup.${name}`, cleanup)
} catch (e) {
// keep going with cleanup
cleanupErrors.push(e)
}
}
if (err) throw err
if (cleanupErrors.length) {
if (cleanupErrors.length === 1) throw cleanupErrors[0]
throw new SmokeTestFailure('multiple cleanup steps failed', {
stats,
cleanupErrors,
})
}
}
module.exports = { runSmokeTests, SmokeTestFailure }

View File

@@ -0,0 +1,7 @@
async function run({ getCsrfTokenFor }) {
const loginCsrfToken = await getCsrfTokenFor('/login')
return { loginCsrfToken }
}
module.exports = { run }

View File

@@ -0,0 +1,35 @@
const Settings = require('@overleaf/settings')
const {
overleafLoginRateLimiter,
openProjectRateLimiter,
} = require('../../../../app/src/infrastructure/RateLimiter')
const LoginRateLimiter = require('../../../../app/src/Features/Security/LoginRateLimiter')
async function clearLoginRateLimit() {
await LoginRateLimiter.promises.recordSuccessfulLogin(Settings.smokeTest.user)
}
async function clearOverleafLoginRateLimit() {
if (!Settings.overleaf) return
await overleafLoginRateLimiter.delete(Settings.smokeTest.rateLimitSubject)
}
async function clearOpenProjectRateLimit() {
await openProjectRateLimiter.delete(
`${Settings.smokeTest.projectId}:${Settings.smokeTest.userId}`
)
}
async function run({ processWithTimeout, timeout }) {
await processWithTimeout({
work: Promise.all([
clearLoginRateLimit(),
clearOverleafLoginRateLimit(),
clearOpenProjectRateLimit(),
]),
timeout,
message: 'cleanupRateLimits timed out',
})
}
module.exports = { run }

View File

@@ -0,0 +1,35 @@
const Settings = require('@overleaf/settings')
async function run({ assertHasStatusCode, loginCsrfToken, request }) {
const response = await request('/login', {
method: 'POST',
json: {
_csrf: loginCsrfToken,
email: Settings.smokeTest.user,
password: Settings.smokeTest.password,
},
})
const body = response.body
// login success and login failure both receive a status code of 200
// see the frontend logic on how to handle the response:
// frontend/js/directives/asyncForm.js -> submitRequest
if (body && body.message && body.message.type === 'error') {
throw new Error(`login failed: ${body.message.text}`)
}
assertHasStatusCode(response, 200)
}
async function cleanup({ assertHasStatusCode, getCsrfTokenFor, request }) {
const logoutCsrfToken = await getCsrfTokenFor('/project')
const response = await request('/logout', {
method: 'POST',
headers: {
'X-CSRF-Token': logoutCsrfToken,
},
})
assertHasStatusCode(response, 302)
}
module.exports = { cleanup, run }

View File

@@ -0,0 +1,13 @@
const TITLE_REGEX = /<title>Your Projects - .*, Online LaTeX Editor<\/title>/
async function run({ request, assertHasStatusCode }) {
const response = await request('/project')
assertHasStatusCode(response, 200)
if (!TITLE_REGEX.test(response.body)) {
throw new Error('body does not have correct title')
}
}
module.exports = { run }

View File

@@ -0,0 +1,16 @@
const Settings = require('@overleaf/settings')
async function run({ assertHasStatusCode, request }) {
const response = await request(`/project/${Settings.smokeTest.projectId}`)
assertHasStatusCode(response, 200)
const PROJECT_ID_REGEX = new RegExp(
`<meta name="ol-project_id" content="${Settings.smokeTest.projectId}">`
)
if (!PROJECT_ID_REGEX.test(response.body)) {
throw new Error('project page html does not have project_id')
}
}
module.exports = { run }

View File

@@ -0,0 +1,27 @@
const OError = require('@overleaf/o-error')
const { assertHasStatusCode } = require('./requestHelper')
const CSRF_REGEX = /<meta name="ol-csrfToken" content="(.+?)">/
function _parseCsrf(body) {
const match = CSRF_REGEX.exec(body)
if (!match) {
throw new Error('Cannot find csrfToken in HTML')
}
return match[1]
}
function getCsrfTokenForFactory({ request }) {
return async function getCsrfTokenFor(endpoint) {
try {
const response = await request(endpoint)
assertHasStatusCode(response, 200)
return _parseCsrf(response.body)
} catch (err) {
throw new OError(`error fetching csrf token on ${endpoint}`, {}, err)
}
}
}
module.exports = {
getCsrfTokenForFactory,
}

View File

@@ -0,0 +1,7 @@
const OError = require('@overleaf/o-error')
class SmokeTestFailure extends OError {}
module.exports = {
SmokeTestFailure,
}

View File

@@ -0,0 +1,60 @@
const { Agent } = require('http')
const { createConnection } = require('net')
const { promisify } = require('util')
const OError = require('@overleaf/o-error')
const request = require('request')
const Settings = require('@overleaf/settings')
// send requests to web router if this is the api process
const OWN_PORT = Settings.port || Settings.internal.web.port || 3000
const PORT = (Settings.web && Settings.web.web_router_port) || OWN_PORT
// like the curl option `--resolve DOMAIN:PORT:127.0.0.1`
class LocalhostAgent extends Agent {
createConnection(options, callback) {
return createConnection(PORT, '127.0.0.1', callback)
}
}
// degrade the 'HttpOnly; Secure;' flags of the cookie
class InsecureCookieJar extends request.jar().constructor {
setCookie(...args) {
const cookie = super.setCookie(...args)
cookie.secure = false
cookie.httpOnly = false
return cookie
}
}
function requestFactory({ timeout }) {
return promisify(
request.defaults({
agent: new LocalhostAgent(),
baseUrl: `http://smoke${Settings.cookieDomain}`,
headers: {
// emulate the header of a https proxy
// express wont emit a 'Secure;' cookie on a plain-text connection.
'X-Forwarded-Proto': 'https',
},
jar: new InsecureCookieJar(),
timeout,
})
)
}
function assertHasStatusCode(response, expected) {
const { statusCode: actual } = response
if (actual !== expected) {
throw new OError('unexpected response code', {
url: response.request.uri.href,
actual,
expected,
})
}
}
module.exports = {
assertHasStatusCode,
requestFactory,
}

View File

@@ -0,0 +1,18 @@
async function processWithTimeout({ work, timeout, message }) {
let workDeadLine
function checkInResults() {
clearTimeout(workDeadLine)
}
await Promise.race([
new Promise((resolve, reject) => {
workDeadLine = setTimeout(() => {
reject(new Error(message))
}, timeout)
}),
work.finally(checkInResults),
])
}
module.exports = {
processWithTimeout,
}