Merge pull request #31889 from overleaf/ar-remove-web-smoke-test

[web] remove smoke test

GitOrigin-RevId: 7911b5e800ef466c59131fd739f95b11a587359f
This commit is contained in:
Andrew Rumble
2026-03-02 14:53:56 +00:00
committed by Copybot
parent 57b380abed
commit a92bf982b0
17 changed files with 0 additions and 496 deletions
@@ -2,23 +2,10 @@ import RedisWrapper from '../../infrastructure/RedisWrapper.mjs'
import settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import UserGetter from '../User/UserGetter.mjs'
import SmokeTests from './../../../../test/smoke/src/SmokeTests.mjs'
const { SmokeTestFailure, runSmokeTests } = SmokeTests
const rclient = RedisWrapper.client('health_check')
export default {
check(req, res, next) {
if (!settings.siteIsOpen || !settings.editorIsOpen) {
// always return successful health checks when site is closed
res.sendStatus(200)
} else {
// detach from express for cleaner stack traces
setTimeout(() => runSmokeTestsDetached(req, res).catch(next))
}
},
checkActiveHandles(req, res, next) {
if (!(settings.maxActiveHandles > 0) || !process._getActiveHandles) {
return next()
@@ -94,31 +81,3 @@ export default {
)
},
}
async function runSmokeTestsDetached(req, res) {
function isAborted() {
return req.destroyed
}
const stats = { start: new Date(), steps: [] }
let status, response
try {
try {
await runSmokeTests({ isAborted, stats })
} finally {
stats.end = new Date()
stats.duration = stats.end - stats.start
}
status = 200
response = { stats }
} catch (e) {
let err = e
if (!(e instanceof SmokeTestFailure)) {
err = new SmokeTestFailure('low level error', {}, e)
}
logger.err({ err, stats }, 'health check failed')
status = 500
response = { stats, error: err.message }
}
if (isAborted()) return
res.status(status).json(response)
}
-15
View File
@@ -1128,11 +1128,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
plainTextResponse(res, res.locals.csrfToken)
})
publicApiRouter.get(
'/health_check',
HealthCheckController.checkActiveHandles,
HealthCheckController.check
)
privateApiRouter.get(
'/health_check',
HealthCheckController.checkActiveHandles,
@@ -1148,16 +1143,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
HealthCheckController.checkActiveHandles,
HealthCheckController.checkApi
)
publicApiRouter.get(
'/health_check/full',
HealthCheckController.checkActiveHandles,
HealthCheckController.check
)
privateApiRouter.get(
'/health_check/full',
HealthCheckController.checkActiveHandles,
HealthCheckController.check
)
publicApiRouter.get('/health_check/redis', HealthCheckController.checkRedis)
privateApiRouter.get('/health_check/redis', HealthCheckController.checkRedis)
-5
View File
@@ -773,12 +773,7 @@ module.exports = {
// some basic smoke tests to check the core functionality.
//
smokeTest: {
user: process.env.SMOKE_TEST_USER,
userId: process.env.SMOKE_TEST_USER_ID,
password: process.env.SMOKE_TEST_PASSWORD,
projectId: process.env.SMOKE_TEST_PROJECT_ID,
rateLimitSubject: process.env.SMOKE_TEST_RATE_LIMIT_SUBJECT || '127.0.0.1',
stepTimeout: parseInt(process.env.SMOKE_TEST_STEP_TIMEOUT || '10000', 10),
},
appName: process.env.APP_NAME || 'Overleaf (Community Edition)',
@@ -1,96 +0,0 @@
import { expect } from 'chai'
import Settings from '@overleaf/settings'
import UserHelper from './helpers/User.mjs'
const User = UserHelper.promises
describe('HealthCheckController', function () {
describe('SmokeTests', function () {
let user, projectId
const captchaDisabledBefore = Settings.recaptcha.disabled.login
beforeEach(async function () {
user = new User()
await user.login()
projectId = await user.createProject('SmokeTest')
// HACK: Inject the details into the app
Settings.smokeTest.userId = user.id
Settings.smokeTest.user = user.email
Settings.smokeTest.password = user.password
Settings.smokeTest.projectId = projectId
Settings.recaptcha.disabled.login = true
})
afterEach(function () {
Settings.recaptcha.disabled.login = captchaDisabledBefore
})
async function performSmokeTestRequest() {
const start = Date.now()
const { response, body } = await user.doRequest('GET', {
url: '/health_check/full',
json: true,
})
const end = Date.now()
expect(body).to.exist
expect(body.stats).to.exist
expect(Date.parse(body.stats.start)).to.be.within(start, start + 1000)
expect(Date.parse(body.stats.end)).to.be.within(end - 1000, end)
expect(body.stats.duration).to.be.within(0, 10000)
expect(body.stats.steps).to.be.instanceof(Array)
return { response, body }
}
describe('happy path', function () {
it('should respond with a 200 and stats', async function () {
const { response, body } = await performSmokeTestRequest()
expect(body.error).to.not.exist
expect(response.statusCode).to.equal(200)
})
})
describe('when the request is aborted', function () {
it('should not crash', async function () {
try {
await user.doRequest('GET', {
timeout: 1,
url: '/health_check/full',
json: true,
})
} catch (err) {
expect(err.code).to.be.oneOf(['ETIMEDOUT', 'ESOCKETTIMEDOUT'])
return
}
expect.fail('expected request to fail with timeout error')
})
})
describe('when the project does not exist', function () {
beforeEach(function () {
Settings.smokeTest.projectId = '404'
})
it('should respond with a 500 ', async function () {
const { response, body } = await performSmokeTestRequest()
expect(body.error).to.equal('run.101_loadEditor failed')
expect(response.statusCode).to.equal(500)
})
})
describe('when the password mismatches', function () {
beforeEach(function () {
Settings.smokeTest.password = 'foo-bar'
})
it('should respond with a 500 with mismatching password', async function () {
const { response, body } = await performSmokeTestRequest()
expect(body.error).to.equal('run.002_login failed')
expect(response.statusCode).to.equal(500)
})
})
})
})
-40
View File
@@ -1,40 +0,0 @@
# 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.
@@ -1,95 +0,0 @@
import fs from 'fs'
import Path from 'path'
import Settings from '@overleaf/settings'
import { getCsrfTokenForFactory } from './support/Csrf.mjs'
import { SmokeTestFailure } from './support/Errors.mjs'
import {
requestFactory,
assertHasStatusCode,
} from './support/requestHelper.mjs'
import { processWithTimeout } from './support/timeoutHelper.mjs'
const STEP_TIMEOUT = Settings.smokeTest.stepTimeout
const PATH_STEPS = Path.join(import.meta.dirname, './steps')
const sortedSteps = fs.readdirSync(PATH_STEPS).sort()
const STEPS = []
for (const name of sortedSteps) {
const step = (await import(Path.join(PATH_STEPS, name))).default
step.name = Path.basename(name, '.mjs')
STEPS.push(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,
})
}
}
export default { runSmokeTests, SmokeTestFailure }
@@ -1,7 +0,0 @@
async function run({ getCsrfTokenFor }) {
const loginCsrfToken = await getCsrfTokenFor('/login')
return { loginCsrfToken }
}
export default { run }
@@ -1,35 +0,0 @@
import Settings from '@overleaf/settings'
import {
overleafLoginRateLimiter,
openProjectRateLimiter,
} from '../../../../app/src/infrastructure/RateLimiter.mjs'
import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.mjs'
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',
})
}
export default { run }
@@ -1,35 +0,0 @@
import Settings from '@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)
}
export default { cleanup, run }
@@ -1,14 +0,0 @@
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')
}
}
export default { run }
@@ -1,16 +0,0 @@
import Settings from '@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')
}
}
export default { run }
@@ -1,23 +0,0 @@
import OError from '@overleaf/o-error'
import { assertHasStatusCode } from './requestHelper.mjs'
const CSRF_REGEX = /<meta name="ol-csrfToken" content="(.+?)">/
export function _parseCsrf(body) {
const match = CSRF_REGEX.exec(body)
if (!match) {
throw new Error('Cannot find csrfToken in HTML')
}
return match[1]
}
export 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', { endpoint }, err)
}
}
}
@@ -1,3 +0,0 @@
import OError from '@overleaf/o-error'
export class SmokeTestFailure extends OError {}
@@ -1,54 +0,0 @@
import { Agent } from 'node:http'
import { createConnection } from 'node:net'
import { promisify } from 'node:util'
import OError from '@overleaf/o-error'
import request from 'request'
import Settings from '@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
}
}
export 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,
})
)
}
export function assertHasStatusCode(response, expected) {
const { statusCode: actual } = response
if (actual !== expected) {
throw new OError('unexpected response code', {
url: response.request.uri.href,
actual,
expected,
})
}
}
@@ -1,14 +0,0 @@
export 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),
])
}
-1
View File
@@ -1 +0,0 @@
{ "extends": "../../tsconfig.backend.json" }
-2
View File
@@ -4,11 +4,9 @@
"app/src/**/*",
"modules/*/app/src/**/*",
"modules/*/test/acceptance/**/*",
"modules/*/test/smoke/**/*",
"modules/*/test/unit/**/*",
"scripts/**/*",
"test/acceptance/**/*",
"test/smoke/**/*",
"test/unit/**/*",
"types/backend/**/*"
]