Просмотр исходного кода

Merge pull request #18316 from overleaf/jpa-host-admin

[server-ce] e2e tests: add capability for reconfiguring the instance

GitOrigin-RevId: 44ec800b5b37497b3122310a94f637c24ff2667a
Jakob Ackermann 2 лет назад
Родитель
Сommit
8eb8b233c0

+ 1 - 0
server-ce/.dockerignore

@@ -1,3 +1,4 @@
 .DS_Store
 .git/
 services/git-bridge
+server-ce/test

+ 6 - 5
server-ce/Dockerfile

@@ -15,11 +15,6 @@ ADD package.json package-lock.json /overleaf/
 ADD libraries/ /overleaf/libraries/
 ADD services/ /overleaf/services/
 
-# Store the revision
-# ------------------
-ARG MONOREPO_REVISION
-RUN echo "monorepo-server-ce,$MONOREPO_REVISION" > /var/www/revisions.txt
-
 # Add npm patches
 # -----------------------
 ADD patches/ /overleaf/patches
@@ -115,3 +110,9 @@ ENV LOG_LEVEL "info"
 EXPOSE 80
 
 ENTRYPOINT ["/sbin/my_init"]
+
+# Store the revision
+# ------------------
+# This should be the last step to optimize docker image caching.
+ARG MONOREPO_REVISION
+RUN echo "monorepo-server-ce,$MONOREPO_REVISION" > /var/www/revisions.txt

+ 8 - 0
server-ce/test/Dockerfile

@@ -0,0 +1,8 @@
+FROM node:18.20.2
+RUN curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \
+&&  echo \
+      "deb [arch=$(dpkg --print-architecture)] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
+    > /etc/apt/sources.list.d/docker.list \
+&&  apt-get update \
+&&  apt-get install -y docker-ce-cli docker-compose-plugin  \
+&&  rm -rf /var/lib/apt/lists/*

+ 15 - 6
server-ce/test/Makefile

@@ -1,13 +1,22 @@
 all: test-e2e
 
+# We are updating the docker compose config via the host-admin service.
+# The host-admin service is running inside docker and has its own file-system layout.
+# We need to have both file-system layouts agree on the path for the docker compose project.
+# Notable the container labels com.docker.compose.project.working_dir and com.docker.compose.project.config_files need to match when creating containers from the docker host (how you started things) and from host-admin (how tests reconfigure the instance).
+export PWD = $(shell pwd)
+
 test-e2e:
-	docker compose down -v -t 0
-	docker compose -f docker-compose.yml run --rm e2e
-	docker compose down -v -t 0
+	docker compose up --build --no-log-prefix --exit-code-from=e2e e2e
 
 test-e2e-open:
-	docker compose down -v -t 0
-	docker compose -f docker-compose.yml run --rm e2e-open
-	docker compose down -v -t 0
+	docker compose up --build --no-log-prefix --exit-code-from=e2e-open e2e-open
+
+clean:
+	docker compose down --volumes --timeout 0
+
+prefetch:
+	docker compose pull e2e mongo redis
+	docker compose build
 
 .PHONY: test-e2e test-e2e-open

+ 5 - 1
server-ce/test/accounts.spec.ts

@@ -1,6 +1,10 @@
-import { login } from './helpers/login'
+import { ensureUserExists, login } from './helpers/login'
+import { startWith } from './helpers/config'
 
 describe('Accounts', function () {
+  startWith({})
+  ensureUserExists({ email: 'user@example.com' })
+
   it('can log in and out', function () {
     login('user@example.com')
     cy.visit('/project')

+ 7 - 2
server-ce/test/create-and-compile-project.spec.ts

@@ -1,12 +1,17 @@
-import { login } from './helpers/login'
+import { ensureUserExists, login } from './helpers/login'
 import { createProject } from './helpers/project'
+import { startWith } from './helpers/config'
 
 describe('Project creation and compilation', function () {
+  startWith({})
+  ensureUserExists({ email: 'user@example.com' })
+  ensureUserExists({ email: 'collaborator@example.com' })
+
   it('users can create project and compile it', function () {
     login('user@example.com')
     cy.visit('/project')
     // this is the first project created, the welcome screen is displayed instead of the project list
-    createProject('test-project', { isFirstProject: true })
+    createProject('test-project')
     cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
     cy.findByText('\\maketitle').parent().click()
     cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}')

+ 25 - 0
server-ce/test/customization.spec.ts

@@ -0,0 +1,25 @@
+import { startWith } from './helpers/config'
+
+describe('Customization', () => {
+  startWith({
+    vars: {
+      OVERLEAF_APP_NAME: 'CUSTOM APP NAME',
+      OVERLEAF_LEFT_FOOTER: JSON.stringify([{ text: 'CUSTOM LEFT FOOTER' }]),
+      OVERLEAF_RIGHT_FOOTER: JSON.stringify([{ text: 'CUSTOM RIGHT FOOTER' }]),
+    },
+  })
+
+  it('should display custom name', () => {
+    cy.visit('/')
+    cy.get('nav').findByText('CUSTOM APP NAME')
+  })
+
+  it('should display custom left footer', () => {
+    cy.visit('/')
+    cy.get('footer').findByText('CUSTOM LEFT FOOTER')
+  })
+  it('should display custom right footer', () => {
+    cy.visit('/')
+    cy.get('footer').findByText('CUSTOM RIGHT FOOTER')
+  })
+})

+ 2 - 1
server-ce/test/cypress.config.js

@@ -3,8 +3,9 @@ const { defineConfig } = require('cypress')
 const specPattern = process.env.SPEC_PATTERN || './**/*.spec.{js,ts,tsx}'
 
 module.exports = defineConfig({
+  defaultCommandTimeout: 10_000,
   fixturesFolder: 'cypress/fixtures',
-  video: !!process.env.CI,
+  video: process.env.CYPRESS_VIDEO === 'true',
   screenshotsFolder: 'cypress/results',
   videosFolder: 'cypress/results',
   videoUploadOnPasses: false,

+ 2 - 1
server-ce/test/cypress/.gitignore

@@ -1 +1,2 @@
-results/
+downloads/
+results/

+ 39 - 23
server-ce/test/docker-compose.yml

@@ -1,18 +1,18 @@
 version: '2.2'
 services:
   sharelatex:
-    image: ${IMAGE_TAG:-sharelatex/sharelatex:latest}
-    container_name: sharelatex
+    image: ${IMAGE_TAG_CE:-sharelatex/sharelatex:latest}
+    # TODO(das7pad): increase timeout after speeding up the graceful shutdown procedure
+    # stop_grace_period: 60s
+    stop_grace_period: 0s
     depends_on:
       mongo:
         condition: service_healthy
       redis:
         condition: service_started
-    ports:
-      - 127.0.0.2:80:80
-    links:
-      - mongo
-      - redis
+      host-admin:
+        # The host-admin service initiates the mongo replica set
+        condition: service_healthy
     environment:
       OVERLEAF_APP_NAME: Overleaf Community Edition
       OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex?directConnection=true
@@ -23,36 +23,27 @@ services:
       EMAIL_CONFIRMATION_DISABLED: 'true'
     healthcheck:
       test: curl --fail http://localhost:3000/status || exit 1
-      interval: 10s
+      interval: 3s
       timeout: 10s
       retries: 10
-    volumes:
-      - ./util/seed-mongo.sh:/etc/my_init.d/99_seed-mongo.sh
-      - ./util/seed-mongo.js:/overleaf/services/web/modules/server-ce-scripts/scripts/seed-mongo.js
 
   mongo:
     image: mongo:5.0.17
-    container_name: mongo
     command: '--replSet overleaf'
-    expose:
-      - 27017
     healthcheck:
-      # FIXME: silly hack to make sure replicaset is initialized
-      test: 'echo ''rs.initiate({ _id: "overleaf", members: [{ _id: 0, host: "mongo:27017" }] })'' | mongo localhost:27017/test --quiet'
+      test: echo 'db.stats().ok' | mongo localhost:27017/test --quiet
       interval: 10s
       timeout: 10s
       retries: 5
 
   redis:
     image: redis:7.2.1
-    container_name: redis
-    expose:
-      - 6379
 
   e2e:
     image: cypress/included:13.6.6
-    links:
-      - sharelatex
+    stop_grace_period: 0s
+    entrypoint: npm
+    command: run cypress:run
     working_dir: /e2e
     volumes:
       - ./:/e2e
@@ -62,13 +53,14 @@ services:
     depends_on:
       sharelatex:
         condition: service_healthy
+      host-admin:
+        condition: service_healthy
 
   e2e-open:
     image: cypress/included:13.6.6
+    stop_grace_period: 0s
     entrypoint: npm
     command: run cypress:open
-    links:
-      - sharelatex
     working_dir: /e2e
     volumes:
       - ./:/e2e
@@ -81,3 +73,27 @@ services:
     depends_on:
       sharelatex:
         condition: service_healthy
+      host-admin:
+        condition: service_healthy
+
+  host-admin:
+    build: .
+    entrypoint: ["node", "--watch", "host-admin.js"]
+    # See comment in Makefile regarding matching file paths
+    working_dir: $PWD
+    volumes:
+      - $PWD:$PWD
+      - /var/run/docker.sock:/var/run/docker.sock
+    stop_grace_period: 0s
+    environment:
+      PWD:
+      IMAGE_TAG_CE: ${IMAGE_TAG_CE:-sharelatex/sharelatex:latest}
+      IMAGE_TAG_PRO: ${IMAGE_TAG_PRO:-quay.io/sharelatex/sharelatex-pro:latest}
+    depends_on:
+      mongo:
+        condition: service_healthy
+    healthcheck:
+      test: curl --fail http://localhost/status || exit 1
+      interval: 3s
+      timeout: 10s
+      retries: 10

+ 16 - 0
server-ce/test/helpers/config.ts

@@ -0,0 +1,16 @@
+import { reconfigure } from './hostAdminClient'
+
+let lastConfig: string
+
+export function startWith({ pro = false, version = 'latest', vars = {} }) {
+  before(async function () {
+    const cfg = JSON.stringify({ pro, version, vars })
+    if (lastConfig === cfg) return
+
+    this.timeout(100 * 1000)
+    await reconfigure({ pro, version, vars })
+    lastConfig = cfg
+  })
+}
+
+export { reconfigure }

+ 84 - 0
server-ce/test/helpers/hostAdminClient.ts

@@ -0,0 +1,84 @@
+export async function setVars(vars = {}) {
+  return await fetchJSON('http://host-admin/set/vars', {
+    method: 'POST',
+    body: JSON.stringify({ vars, path: 'docker-compose.yml' }),
+  })
+}
+
+export async function setVersion({ pro = false, version = 'latest' }) {
+  return await fetchJSON('http://host-admin/set/version', {
+    method: 'POST',
+    body: JSON.stringify({
+      pro,
+      version,
+      path: 'docker-compose.yml',
+    }),
+  })
+}
+
+export async function dockerCompose(cmd: string, ...args: string[]) {
+  return await fetchJSON(`http://host-admin/docker/compose/${cmd}`, {
+    method: 'POST',
+    body: JSON.stringify({
+      args,
+    }),
+  })
+}
+
+export async function mongoInit() {
+  return await fetchJSON('http://host-admin/mongo/init', {
+    method: 'POST',
+  })
+}
+
+export async function reconfigure({
+  pro = false,
+  version = 'latest',
+  vars = {},
+}) {
+  return await fetchJSON('http://host-admin/reconfigure', {
+    method: 'POST',
+    body: JSON.stringify({
+      pro,
+      version,
+      vars,
+    }),
+  })
+}
+
+async function fetchJSON(
+  input: RequestInfo,
+  init?: RequestInit
+): Promise<{ stdout: string; stderr: string }> {
+  if (init?.body) {
+    init.headers = { 'Content-Type': 'application/json' }
+  }
+  const res = await fetch(input, init)
+  const { error, stdout, stderr } = await res.json()
+  if (error) {
+    console.error(input, init, 'failed:', error)
+    const err = new Error(error.message)
+    Object.assign(err, error)
+    throw err
+  }
+  return { stdout, stderr }
+}
+
+export async function runScript({
+  cwd,
+  script,
+  args = [],
+}: {
+  cwd: string
+  script: string
+  args?: string[]
+}) {
+  return await fetchJSON('http://host-admin/run/script', {
+    method: 'POST',
+    body: JSON.stringify({
+      cwd,
+      script,
+      args,
+    }),
+  })
+}

+ 67 - 1
server-ce/test/helpers/login.ts

@@ -1,4 +1,57 @@
-export function login(username: string, password = 'Passw0rd!') {
+import { runScript } from './hostAdminClient'
+
+const DEFAULT_PASSWORD = 'Passw0rd!'
+
+const createdUsers = new Set<string>()
+
+async function createMongoUser({
+  email,
+  isAdmin = false,
+}: {
+  email: string
+  isAdmin?: boolean
+}) {
+  const t0 = Date.now()
+  const { stdout } = await runScript({
+    cwd: 'services/web',
+    script: 'modules/server-ce-scripts/scripts/create-user.js',
+    args: [`--email=${email}`, `--admin=${isAdmin}`],
+  })
+  const [url] = stdout.match(/\/user\/activate\?token=\S+/)!
+  const userId = new URL(url, location.origin).searchParams.get('user_id')!
+  const signupDate = parseInt(userId.slice(0, 8), 16) * 1000
+  if (signupDate < t0) {
+    return { url, exists: true }
+  }
+  return { url, exists: false }
+}
+
+export function ensureUserExists({
+  email,
+  password = DEFAULT_PASSWORD,
+  isAdmin = false,
+}: {
+  email: string
+  password?: string
+  isAdmin?: boolean
+}) {
+  let url: string
+  let exists: boolean
+  before(async function () {
+    exists = createdUsers.has(email)
+    if (exists) return
+    ;({ url, exists } = await createMongoUser({ email, isAdmin }))
+  })
+  before(function () {
+    if (exists) return
+    activateUser(url, password)
+    cy.then(() => {
+      createdUsers.add(email)
+    })
+  })
+}
+
+export function login(username: string, password = DEFAULT_PASSWORD) {
   cy.session([username, password, new Date()], () => {
     cy.visit('/login')
     cy.get('input[name="email"]').type(username)
@@ -7,3 +60,16 @@ export function login(username: string, password = 'Passw0rd!') {
     cy.url().should('contain', '/project')
   })
 }
+
+export function activateUser(url: string, password = DEFAULT_PASSWORD) {
+  cy.session(url, () => {
+    cy.visit(url)
+    cy.url().then(url => {
+      if (url.includes('/login')) return
+      cy.url().should('contain', '/user/activate')
+      cy.get('input[name="password"]').type(password)
+      cy.findByRole('button', { name: 'Activate' }).click()
+      cy.url().should('contain', '/project')
+    })
+  })
+}

+ 3 - 8
server-ce/test/helpers/project.ts

@@ -2,18 +2,13 @@ export function createProject(
   name: string,
   {
     type = 'Blank Project',
-    isFirstProject,
   }: {
     type?: 'Blank Project' | 'Example Project'
-    isFirstProject?: boolean
   } = {}
 ): Cypress.Chainable<string> {
-  if (isFirstProject) {
-    cy.findByText('Create a new project').click()
-  } else {
-    // FIXME: This should be be a data-test-id shared between the welcome page and project list
-    cy.get('.new-project-button').first().click()
-  }
+  cy.findAllByRole('button')
+    .contains(/new project/i)
+    .click()
   // FIXME: This should only look in the left menu
   cy.findAllByText(type).first().click()
   cy.findByRole('dialog').within(() => {

+ 279 - 0
server-ce/test/host-admin.js

@@ -0,0 +1,279 @@
+const fs = require('fs')
+const { execFile } = require('child_process')
+const express = require('express')
+const bodyParser = require('body-parser')
+const {
+  celebrate: validate,
+  Joi,
+  errors: handleValidationErrors,
+} = require('celebrate')
+const YAML = require('js-yaml')
+
+const FILES = {
+  DOCKER_COMPOSE: 'docker-compose.override.yml',
+}
+const IMAGES = {
+  CE: process.env.IMAGE_TAG_CE.replace(/:.+/, ''),
+  PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''),
+}
+
+let mongoIsInitialized = false
+
+function readDockerComposeOverride() {
+  try {
+    return YAML.load(fs.readFileSync(FILES.DOCKER_COMPOSE, 'utf-8'))
+  } catch (error) {
+    if (error.code !== 'ENOENT') {
+      throw error
+    }
+    return {
+      services: {
+        sharelatex: {
+          environment: {},
+        },
+      },
+    }
+  }
+}
+
+function writeDockerComposeOverride(cfg) {
+  fs.writeFileSync(FILES.DOCKER_COMPOSE, YAML.dump(cfg))
+}
+
+const app = express()
+app.get('/status', (req, res) => {
+  res.send('host-admin is up')
+})
+
+app.use(bodyParser.json())
+app.use((req, res, next) => {
+  // Basic access logs
+  console.log(req.method, req.url, req.body)
+  // Add CORS headers
+  res.setHeader('Access-Control-Allow-Origin', 'http://sharelatex')
+  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
+  next()
+})
+
+app.post(
+  '/run/script',
+  validate(
+    {
+      body: {
+        cwd: Joi.string().required(),
+        script: Joi.string().required(),
+        args: Joi.array().items(Joi.string()),
+      },
+    },
+    { allowUnknown: false }
+  ),
+  (req, res) => {
+    const { cwd, script, args } = req.body
+
+    execFile(
+      'docker',
+      [
+        'compose',
+        'exec',
+        'sharelatex',
+        'bash',
+        '-c',
+        `source /etc/container_environment.sh && source /etc/overleaf/env.sh && cd ${JSON.stringify(cwd)} && node ${JSON.stringify(script)} ${args.map(a => JSON.stringify(a)).join(' ')}`,
+      ],
+      (error, stdout, stderr) => {
+        res.json({
+          error,
+          stdout,
+          stderr,
+        })
+      }
+    )
+  }
+)
+
+function setVersionDockerCompose({ pro, version }) {
+  const cfg = readDockerComposeOverride()
+
+  cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version}`
+
+  writeDockerComposeOverride(cfg)
+}
+
+app.post(
+  '/set/version',
+  validate(
+    {
+      body: {
+        pro: Joi.boolean(),
+        version: Joi.string().required(),
+        path: Joi.allow(
+          'docker-compose.yml'
+          // When extending testing for Toolkit:
+          // 'config/version'
+        ),
+      },
+    },
+    { allowUnknown: false }
+  ),
+  (req, res) => {
+    const { pro, version } = req.body
+    if (req.body.path === 'docker-compose.yml') {
+      try {
+        setVersionDockerCompose({ pro, version })
+      } catch (error) {
+        return res.json({ error })
+      }
+    }
+    res.json({})
+  }
+)
+
+const allowedVars = Joi.object().keys({
+  OVERLEAF_APP_NAME: Joi.string(),
+  OVERLEAF_LEFT_FOOTER: Joi.string(),
+  OVERLEAF_RIGHT_FOOTER: Joi.string(),
+})
+
+function setVarsDockerCompose({ vars }) {
+  const cfg = readDockerComposeOverride()
+
+  cfg.services.sharelatex.environment = vars
+
+  writeDockerComposeOverride(cfg)
+}
+
+app.post(
+  '/set/vars',
+  validate(
+    {
+      body: {
+        vars: allowedVars,
+        path: Joi.allow(
+          'docker-compose.yml'
+          // When extending the testing for Toolkit:
+          // 'overleaf.rc', 'variables.env'
+        ),
+      },
+    },
+    { allowUnknown: false }
+  ),
+  (req, res) => {
+    if (req.body.path === 'docker-compose.yml') {
+      const { vars } = req.body
+      try {
+        setVarsDockerCompose({ vars })
+      } catch (error) {
+        return res.json({ error })
+      }
+    }
+    res.json({})
+  }
+)
+
+app.post(
+  '/docker/compose/:cmd',
+  validate(
+    {
+      body: {
+        args: Joi.array().allow(
+          '--detach',
+          '--wait',
+          '--volumes',
+          '--timeout',
+          '0',
+          'sharelatex',
+          'mongo',
+          'redis'
+        ),
+      },
+      params: {
+        cmd: Joi.allow('up', 'stop', 'down', 'ps', 'logs'),
+      },
+    },
+    { allowUnknown: false }
+  ),
+  (req, res) => {
+    const { cmd } = req.params
+    const { args } = req.body
+    if (['stop', 'down'].includes(cmd)) {
+      mongoIsInitialized = false
+    }
+    execFile('docker', ['compose', cmd, ...args], (error, stdout, stderr) => {
+      res.json({ error, stdout, stderr })
+    })
+  }
+)
+
+function mongoInit(callback) {
+  execFile(
+    'docker',
+    [
+      'compose',
+      'exec',
+      'mongo',
+      'mongo',
+      '--eval',
+      'rs.initiate({ _id: "overleaf", members: [ { _id: 0, host: "mongo:27017" } ] })',
+    ],
+    (error, stdout, stderr) => {
+      if (!error) {
+        mongoIsInitialized = true
+      }
+      callback(error, stdout, stderr)
+    }
+  )
+}
+
+app.post('/mongo/init', (req, res) => {
+  mongoInit((error, stdout, stderr) => {
+    res.json({ error, stdout, stderr })
+  })
+})
+
+app.post(
+  '/reconfigure',
+  validate(
+    {
+      body: {
+        pro: Joi.boolean().required(),
+        version: Joi.string().required(),
+        vars: allowedVars,
+      },
+    },
+    { allowUnknown: false }
+  ),
+  (req, res) => {
+    const doMongoInit = mongoIsInitialized ? cb => cb() : mongoInit
+    doMongoInit((error, stdout, stderr) => {
+      if (error) return res.json({ error, stdout, stderr })
+
+      const { pro, version, vars } = req.body
+      try {
+        setVersionDockerCompose({ pro, version })
+        setVarsDockerCompose({ vars })
+      } catch (error) {
+        return res.json({ error })
+      }
+
+      execFile(
+        'docker',
+        ['compose', 'up', '--detach', '--wait', 'sharelatex'],
+        (error, stdout, stderr) => {
+          res.json({ error, stdout, stderr })
+        }
+      )
+    })
+  }
+)
+
+app.use(handleValidationErrors())
+
+// Init on startup
+mongoInit(err => {
+  if (err) {
+    console.error('mongo init failed', err)
+    process.exit(1)
+  }
+
+  app.listen(80)
+})

+ 581 - 0
server-ce/test/package-lock.json

@@ -7,7 +7,11 @@
       "name": "@overleaf/server-ce/test",
       "dependencies": {
         "@testing-library/cypress": "^10.0.1",
+        "body-parser": "^1.20.2",
+        "celebrate": "^15.0.3",
         "cypress": "13.6.6",
+        "express": "^4.19.2",
+        "js-yaml": "^4.1.0",
         "typescript": "^5.0.4"
       }
     },
@@ -221,6 +225,37 @@
         "ms": "^2.1.1"
       }
     },
+    "node_modules/@hapi/hoek": {
+      "version": "9.3.0",
+      "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+      "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
+    },
+    "node_modules/@hapi/topo": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
+      "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
+      "dependencies": {
+        "@hapi/hoek": "^9.0.0"
+      }
+    },
+    "node_modules/@sideway/address": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
+      "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
+      "dependencies": {
+        "@hapi/hoek": "^9.0.0"
+      }
+    },
+    "node_modules/@sideway/formula": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
+      "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="
+    },
+    "node_modules/@sideway/pinpoint": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
+      "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
+    },
     "node_modules/@testing-library/cypress": {
       "version": "10.0.1",
       "resolved": "https://registry.npmjs.org/@testing-library/cypress/-/cypress-10.0.1.tgz",
@@ -288,6 +323,18 @@
         "@types/node": "*"
       }
     },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/aggregate-error": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -363,6 +410,11 @@
         }
       ]
     },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+    },
     "node_modules/aria-query": {
       "version": "5.1.3",
       "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
@@ -383,6 +435,11 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
+    },
     "node_modules/asn1": {
       "version": "0.2.6",
       "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -491,6 +548,56 @@
       "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
       "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
     },
+    "node_modules/body-parser": {
+      "version": "1.20.2",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz",
+      "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "http-errors": "2.0.0",
+        "iconv-lite": "0.4.24",
+        "on-finished": "2.4.1",
+        "qs": "6.11.0",
+        "raw-body": "2.5.2",
+        "type-is": "~1.6.18",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/body-parser/node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/body-parser/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+    },
+    "node_modules/body-parser/node_modules/qs": {
+      "version": "6.11.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+      "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
+      "dependencies": {
+        "side-channel": "^1.0.4"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -531,6 +638,14 @@
         "node": "*"
       }
     },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/cachedir": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz",
@@ -557,6 +672,16 @@
       "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
       "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
     },
+    "node_modules/celebrate": {
+      "version": "15.0.3",
+      "resolved": "https://registry.npmjs.org/celebrate/-/celebrate-15.0.3.tgz",
+      "integrity": "sha512-ToF8ILq/F0KhQ0CPtexP7Cu9GkqKJ91VKy3ZOCV24aaNWdm3QCHqnXAKfKHrtcM2B2zmPFe11p8WWsQkmq8k4g==",
+      "dependencies": {
+        "escape-html": "1.0.3",
+        "joi": "17.x.x",
+        "lodash": "4.17.x"
+      }
+    },
     "node_modules/chalk": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -695,6 +820,38 @@
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
       "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
     },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+      "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
+    },
     "node_modules/core-util-is": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -880,6 +1037,23 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
     "node_modules/dom-accessibility-api": {
       "version": "0.5.16",
       "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -894,11 +1068,24 @@
         "safer-buffer": "^2.1.0"
       }
     },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
+    },
     "node_modules/emoji-regex": {
       "version": "8.0.0",
       "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
       "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
     },
+    "node_modules/encodeurl": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+      "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/end-of-stream": {
       "version": "1.4.4",
       "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -938,6 +1125,11 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+    },
     "node_modules/escape-string-regexp": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -946,6 +1138,14 @@
         "node": ">=0.8.0"
       }
     },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/eventemitter2": {
       "version": "6.4.7",
       "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz",
@@ -984,6 +1184,74 @@
         "node": ">=4"
       }
     },
+    "node_modules/express": {
+      "version": "4.19.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
+      "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "1.20.2",
+        "content-disposition": "0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "0.6.0",
+        "cookie-signature": "1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "1.2.0",
+        "fresh": "0.5.2",
+        "http-errors": "2.0.0",
+        "merge-descriptors": "1.0.1",
+        "methods": "~1.1.2",
+        "on-finished": "2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "0.1.7",
+        "proxy-addr": "~2.0.7",
+        "qs": "6.11.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "0.18.0",
+        "serve-static": "1.15.0",
+        "setprototypeof": "1.2.0",
+        "statuses": "2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      }
+    },
+    "node_modules/express/node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/express/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+    },
+    "node_modules/express/node_modules/qs": {
+      "version": "6.11.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
+      "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
+      "dependencies": {
+        "side-channel": "^1.0.4"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/extend": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -1038,6 +1306,36 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/finalhandler": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
+      "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "on-finished": "2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "2.0.1",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/finalhandler/node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/finalhandler/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+    },
     "node_modules/for-each": {
       "version": "0.3.3",
       "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@@ -1067,6 +1365,22 @@
         "node": ">= 0.12"
       }
     },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/fs-extra": {
       "version": "9.1.0",
       "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -1269,6 +1583,21 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/http-errors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+      "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+      "dependencies": {
+        "depd": "2.0.0",
+        "inherits": "2.0.4",
+        "setprototypeof": "1.2.0",
+        "statuses": "2.0.1",
+        "toidentifier": "1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/http-signature": {
       "version": "1.3.6",
       "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz",
@@ -1290,6 +1619,17 @@
         "node": ">=8.12.0"
       }
     },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/ieee754": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -1352,6 +1692,14 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
     "node_modules/is-arguments": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
@@ -1633,11 +1981,34 @@
       "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
       "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="
     },
+    "node_modules/joi": {
+      "version": "17.13.1",
+      "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.1.tgz",
+      "integrity": "sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==",
+      "dependencies": {
+        "@hapi/hoek": "^9.3.0",
+        "@hapi/topo": "^5.1.0",
+        "@sideway/address": "^4.1.5",
+        "@sideway/formula": "^3.0.1",
+        "@sideway/pinpoint": "^2.0.0"
+      }
+    },
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
     },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
     "node_modules/jsbn": {
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
@@ -1802,11 +2173,43 @@
         "lz-string": "bin/bin.js"
       }
     },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+      "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w=="
+    },
     "node_modules/merge-stream": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
       "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
     },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/mime-db": {
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -1858,6 +2261,14 @@
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
       "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
     },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/npm-run-path": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -1917,6 +2328,17 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/once": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1958,6 +2380,14 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/path-is-absolute": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -1974,6 +2404,11 @@
         "node": ">=8"
       }
     },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+      "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ=="
+    },
     "node_modules/pend": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -2035,6 +2470,18 @@
         "node": ">= 0.6.0"
       }
     },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
     "node_modules/proxy-from-env": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz",
@@ -2081,6 +2528,28 @@
       "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
       "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
     },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
+      "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "http-errors": "2.0.0",
+        "iconv-lite": "0.4.24",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/react-is": {
       "version": "17.0.2",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -2197,6 +2666,61 @@
         "node": ">=10"
       }
     },
+    "node_modules/send": {
+      "version": "0.18.0",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
+      "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "0.5.2",
+        "http-errors": "2.0.0",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "2.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/send/node_modules/debug/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+    },
+    "node_modules/serve-static": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz",
+      "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==",
+      "dependencies": {
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "0.18.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
     "node_modules/set-function-length": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz",
@@ -2224,6 +2748,11 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
+    },
     "node_modules/shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -2298,6 +2827,14 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/statuses": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+      "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/stop-iteration-iterator": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
@@ -2373,6 +2910,14 @@
         "node": ">=8.17.0"
       }
     },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
     "node_modules/tough-cookie": {
       "version": "4.1.3",
       "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
@@ -2427,6 +2972,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/typescript": {
       "version": "5.2.2",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@@ -2453,6 +3010,14 @@
         "node": ">= 10.0.0"
       }
     },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/untildify": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
@@ -2470,6 +3035,14 @@
         "requires-port": "^1.0.0"
       }
     },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
     "node_modules/uuid": {
       "version": "8.3.2",
       "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@@ -2478,6 +3051,14 @@
         "uuid": "dist/bin/uuid"
       }
     },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/verror": {
       "version": "1.10.0",
       "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",

+ 4 - 0
server-ce/test/package.json

@@ -10,7 +10,11 @@
   },
   "dependencies": {
     "@testing-library/cypress": "^10.0.1",
+    "body-parser": "^1.20.2",
+    "celebrate": "^15.0.3",
     "cypress": "13.6.6",
+    "express": "^4.19.2",
+    "js-yaml": "^4.1.0",
     "typescript": "^5.0.4"
   }
 }

+ 0 - 121
server-ce/test/util/seed-mongo.js

@@ -1,121 +0,0 @@
-const { ObjectId } = require('mongodb')
-const { waitForDb, db } = require('../../../app/src/infrastructure/mongodb')
-
-waitForDb()
-  .then(async () => {
-    await seedUsers()
-    process.exit(0)
-  })
-  .catch(err => {
-    console.error(err)
-    process.exit(1)
-  })
-
-const DEFAULT_USER_PROPERTIES = {
-  staffAccess: {
-    publisherMetrics: false,
-    publisherManagement: false,
-    institutionMetrics: false,
-    institutionManagement: false,
-    groupMetrics: false,
-    groupManagement: false,
-    adminMetrics: false,
-    splitTestMetrics: false,
-    splitTestManagement: false,
-  },
-  ace: {
-    mode: 'none',
-    theme: 'textmate',
-    overallTheme: '',
-    fontSize: 12,
-    autoComplete: true,
-    autoPairDelimiters: true,
-    spellCheckLanguage: 'en',
-    pdfViewer: 'pdfjs',
-    syntaxValidation: true,
-  },
-  features: {
-    collaborators: -1,
-    versioning: true,
-    dropbox: true,
-    github: true,
-    gitBridge: true,
-    compileTimeout: 180,
-    compileGroup: 'standard',
-    templates: true,
-    references: true,
-    trackChanges: true,
-  },
-  first_name: 'user',
-  role: '',
-  institution: '',
-  isAdmin: false,
-  lastLoginIp: '',
-  loginCount: 0,
-  holdingAccount: false,
-  must_reconfirm: false,
-  refered_users: [],
-  refered_user_count: 0,
-  alphaProgram: false,
-  betaProgram: false,
-  labsProgram: false,
-  awareOfV2: false,
-  samlIdentifiers: [],
-  thirdPartyIdentifiers: [],
-
-  signUpDate: new Date('2023-11-02T11:36:40.151Z'),
-  featuresOverrides: [],
-  referal_id: 'scTS4kjjJENbfbjG',
-  __v: 0,
-  hashedPassword:
-    '$2a$12$nRvTj6U896uUnE.RFhnGKOyi/CvqBpfxezlqwyIPpezRa2xXLW7MO',
-}
-
-async function seedUsers() {
-  const adminUser = {
-    ...DEFAULT_USER_PROPERTIES,
-    email: 'admin@example.com',
-    first_name: 'admin',
-    isAdmin: true,
-    emails: [
-      {
-        email: 'admin@example.com',
-        reversedHostname: '',
-        _id: ObjectId('646ca54806d54400b74e77c6'),
-        createdAt: new Date('2023-05-23T11:36:40.494Z'),
-      },
-    ],
-  }
-
-  const user = {
-    ...DEFAULT_USER_PROPERTIES,
-    _id: ObjectId('6543cf90bbe1368944db04d7'),
-    emails: [
-      {
-        email: 'user@example.com',
-        reversedHostname: '',
-        _id: ObjectId('6543c614d58b090b461f3549'),
-        createdAt: new Date('2023-11-21T11:36:40.494Z'),
-      },
-    ],
-    email: 'user@example.com',
-  }
-
-  const collaborator = {
-    ...DEFAULT_USER_PROPERTIES,
-    _id: ObjectId('6544e78c9b6e937424976b64'),
-    emails: [
-      {
-        email: 'collaborator@example.com',
-        reversedHostname: '',
-        _id: ObjectId('6543c614d58b090b461f354a'),
-        createdAt: new Date('2023-11-21T11:36:40.494Z'),
-      },
-    ],
-    email: 'collaborator@example.com',
-  }
-
-  await db.users.insertOne(adminUser)
-  await db.users.insertOne(user)
-  await db.users.insertOne(collaborator)
-}

+ 0 - 8
server-ce/test/util/seed-mongo.sh

@@ -1,8 +0,0 @@
-#!/bin/sh
-set -e
-
-echo "Seeding mongo for e2e tests"
-cd /overleaf/services/web
-node modules/server-ce-scripts/scripts/seed-mongo
-node modules/server-ce-scripts/scripts/check-redis
-echo "mongo seeding complete"