first commit
This commit is contained in:
2
server-ce/test/.gitignore
vendored
Normal file
2
server-ce/test/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
data/
|
||||
docker-mailtrap/
|
8
server-ce/test/Dockerfile
Normal file
8
server-ce/test/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:20.18.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/*
|
63
server-ce/test/Makefile
Normal file
63
server-ce/test/Makefile
Normal file
@@ -0,0 +1,63 @@
|
||||
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)
|
||||
|
||||
export TEX_LIVE_DOCKER_IMAGE ?= gcr.io/overleaf-ops/texlive-full:2023.1
|
||||
export ALL_TEX_LIVE_DOCKER_IMAGES ?= gcr.io/overleaf-ops/texlive-full:2023.1,gcr.io/overleaf-ops/texlive-full:2022.1
|
||||
export IMAGE_TAG_PRO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro:latest
|
||||
export CYPRESS_SHARD ?=
|
||||
export COMPOSE_PROJECT_NAME ?= test
|
||||
|
||||
test-e2e-native:
|
||||
docker compose -f docker-compose.yml -f docker-compose.native.yml up --no-log-prefix sharelatex host-admin -d
|
||||
CYPRESS_HOST_ADMIN_URL='http://localhost:8081' \
|
||||
CYPRESS_SAML_URL='http://localhost:8082' \
|
||||
CYPRESS_MAILTRAP_URL='http://localhost:8083' \
|
||||
npm run cypress:open
|
||||
|
||||
test-e2e:
|
||||
docker compose up --no-log-prefix --exit-code-from=e2e e2e
|
||||
|
||||
test-e2e-open:
|
||||
docker compose up --no-log-prefix --exit-code-from=e2e-open e2e-open
|
||||
|
||||
clean:
|
||||
docker compose down --volumes --timeout 0
|
||||
|
||||
prefetch: prefetch_default
|
||||
prefetch_default: prefetch_default_compose
|
||||
prefetch_default_compose:
|
||||
docker compose pull e2e mongo redis
|
||||
|
||||
prefetch_default: prefetch_default_compose_build
|
||||
prefetch_default_compose_build:
|
||||
docker compose build host-admin
|
||||
|
||||
prefetch: prefetch_custom
|
||||
prefetch_custom: prefetch_custom_compose_pull
|
||||
prefetch_custom_compose_pull:
|
||||
docker compose pull saml ldap
|
||||
|
||||
prefetch_custom: prefetch_custom_texlive
|
||||
prefetch_custom_texlive:
|
||||
echo "$$ALL_TEX_LIVE_DOCKER_IMAGES" | tr ',' '\n' | xargs -I% \
|
||||
sh -exc 'tag=%; re_tag=quay.io/sharelatex/$${tag#*/*/}; docker pull $$tag; docker tag $$tag $$re_tag'
|
||||
|
||||
prefetch_custom: prefetch_old
|
||||
prefetch_old:
|
||||
docker pull $(IMAGE_TAG_PRO:latest=4.2)
|
||||
docker pull $(IMAGE_TAG_PRO:latest=5.0.1-RC1)
|
||||
docker pull $(IMAGE_TAG_PRO:latest=5.0)
|
||||
|
||||
# Google Cloud Build runs on a very ancient Docker version that does not support the subdir flag.
|
||||
# Use services -> mailtrap -> build -> context = https://github.com/dbck/docker-mailtrap.git#v1.5.0:build in docker-compose.yml eventually.
|
||||
prefetch_default_compose_build: build_mailtrap
|
||||
build_mailtrap:
|
||||
git clone https://github.com/dbck/docker-mailtrap.git || true && cd docker-mailtrap && git checkout v1.5.0
|
||||
docker build -t mailtrap docker-mailtrap/build
|
||||
|
||||
.PHONY: test-e2e test-e2e-open
|
38
server-ce/test/accounts.spec.ts
Normal file
38
server-ce/test/accounts.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createMongoUser, ensureUserExists, login } from './helpers/login'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
|
||||
describe('Accounts', function () {
|
||||
if (isExcludedBySharding('CE_DEFAULT')) return
|
||||
startWith({})
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
|
||||
it('can log in and out', function () {
|
||||
login('user@example.com')
|
||||
cy.visit('/project')
|
||||
cy.findByText('Account').click()
|
||||
cy.findByText('Log Out').click()
|
||||
cy.url().should('include', '/login')
|
||||
cy.visit('/project')
|
||||
cy.url().should('include', '/login')
|
||||
})
|
||||
|
||||
it('should render the email on the user activate screen', () => {
|
||||
const email = 'not-activated-user@example.com'
|
||||
cy.then(async () => {
|
||||
const { url } = await createMongoUser({ email })
|
||||
return url
|
||||
}).as('url')
|
||||
cy.get('@url').then(url => {
|
||||
cy.visit(`${url}`)
|
||||
cy.url().should('contain', '/user/activate')
|
||||
cy.findByText('Please set a password')
|
||||
cy.get('input[autocomplete="username"]').should(
|
||||
'have.attr',
|
||||
'value',
|
||||
email
|
||||
)
|
||||
cy.get('input[name="password"]')
|
||||
cy.findByRole('button', { name: 'Activate' })
|
||||
})
|
||||
})
|
||||
})
|
327
server-ce/test/admin.spec.ts
Normal file
327
server-ce/test/admin.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import {
|
||||
activateUser,
|
||||
createMongoUser,
|
||||
ensureUserExists,
|
||||
login,
|
||||
} from './helpers/login'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { createProject } from './helpers/project'
|
||||
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
|
||||
import { openEmail } from './helpers/email'
|
||||
|
||||
describe('admin panel', function () {
|
||||
function registrationTests() {
|
||||
it('via GUI and opening URL manually', () => {
|
||||
const user = `${uuid()}@example.com`
|
||||
cy.get('input[name="email"]').type(user + '{enter}')
|
||||
|
||||
cy.get('td')
|
||||
.contains(/\/user\/activate/)
|
||||
.then($td => {
|
||||
const url = $td.text().trim()
|
||||
activateUser(url)
|
||||
})
|
||||
})
|
||||
|
||||
it('via GUI and email', () => {
|
||||
const user = `${uuid()}@example.com`
|
||||
cy.get('input[name="email"]').type(user + '{enter}')
|
||||
|
||||
let url: string
|
||||
cy.get('td')
|
||||
.contains(/\/user\/activate/)
|
||||
.then($td => {
|
||||
url = $td.text().trim()
|
||||
})
|
||||
|
||||
cy.then(() => {
|
||||
openEmail(
|
||||
'Activate your Overleaf Community Edition Account',
|
||||
(frame, { url }) => {
|
||||
frame.contains('Set password').then(el => {
|
||||
expect(el.attr('href')!).to.equal(url)
|
||||
})
|
||||
},
|
||||
{ url }
|
||||
)
|
||||
// Run activateUser in the main origin instead of inside openEmail. See docs on openEmail.
|
||||
activateUser(url)
|
||||
})
|
||||
})
|
||||
it('via script and opening URL manually', () => {
|
||||
const user = `${uuid()}@example.com`
|
||||
let url: string
|
||||
cy.then(async () => {
|
||||
;({ url } = await createMongoUser({ email: user }))
|
||||
})
|
||||
cy.then(() => {
|
||||
activateUser(url)
|
||||
})
|
||||
})
|
||||
it('via script and email', () => {
|
||||
const user = `${uuid()}@example.com`
|
||||
let url: string
|
||||
cy.then(async () => {
|
||||
;({ url } = await createMongoUser({ email: user }))
|
||||
})
|
||||
cy.then(() => {
|
||||
openEmail(
|
||||
'Activate your Overleaf Community Edition Account',
|
||||
(frame, { url }) => {
|
||||
frame.contains('Set password').then(el => {
|
||||
expect(el.attr('href')!).to.equal(url)
|
||||
})
|
||||
},
|
||||
{ url }
|
||||
)
|
||||
// Run activateUser in the main origin instead of inside openEmail. See docs on openEmail.
|
||||
activateUser(url)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('in CE', () => {
|
||||
if (isExcludedBySharding('CE_DEFAULT')) return
|
||||
startWith({ pro: false, version: 'latest' })
|
||||
const admin = 'admin@example.com'
|
||||
const user = `user+${uuid()}@example.com`
|
||||
ensureUserExists({ email: admin, isAdmin: true })
|
||||
ensureUserExists({ email: user })
|
||||
|
||||
describe('create users', () => {
|
||||
beforeEach(() => {
|
||||
login(admin)
|
||||
cy.visit('/project')
|
||||
cy.get('nav').findByText('Admin').click()
|
||||
cy.get('nav').findByText('Manage Users').click()
|
||||
})
|
||||
registrationTests()
|
||||
})
|
||||
})
|
||||
|
||||
describe('in server pro', () => {
|
||||
const admin = 'admin@example.com'
|
||||
const user1 = 'user@example.com'
|
||||
const user2 = 'user2@example.com'
|
||||
|
||||
let testProjectName = ''
|
||||
let testProjectId = ''
|
||||
let deletedProjectName = ''
|
||||
let projectToDeleteId = ''
|
||||
|
||||
const findProjectRow = (projectName: string) => {
|
||||
cy.log('find project row')
|
||||
return cy.findByText(projectName).parent().parent()
|
||||
}
|
||||
|
||||
if (isExcludedBySharding('PRO_DEFAULT_2')) return
|
||||
startWith({
|
||||
pro: true,
|
||||
})
|
||||
ensureUserExists({ email: admin, isAdmin: true })
|
||||
ensureUserExists({ email: user1 })
|
||||
ensureUserExists({ email: user2 })
|
||||
|
||||
beforeWithReRunOnTestRetry(() => {
|
||||
testProjectName = `project-${uuid()}`
|
||||
deletedProjectName = `deleted-project-${uuid()}`
|
||||
login(user1)
|
||||
createProject(testProjectName, { open: false }).then(
|
||||
id => (testProjectId = id)
|
||||
)
|
||||
createProject(deletedProjectName, { open: false }).then(
|
||||
id => (projectToDeleteId = id)
|
||||
)
|
||||
})
|
||||
|
||||
describe('manage site', () => {
|
||||
beforeEach(() => {
|
||||
login(admin)
|
||||
cy.visit('/project')
|
||||
cy.get('nav').findByText('Admin').click()
|
||||
cy.get('nav').findByText('Manage Site').click()
|
||||
})
|
||||
|
||||
it('publish and clear admin messages', () => {
|
||||
const message = 'Admin Message ' + uuid()
|
||||
|
||||
cy.log('create system message')
|
||||
cy.get('[role="tab"]').contains('System Messages').click()
|
||||
cy.get('input[name="content"]').type(message)
|
||||
cy.get('button').contains('Post Message').click()
|
||||
cy.findByText(message)
|
||||
|
||||
login(user1)
|
||||
cy.visit('/project')
|
||||
cy.findByText(message)
|
||||
|
||||
cy.log('clear system messages')
|
||||
login(admin)
|
||||
cy.visit('/project')
|
||||
cy.get('nav').findByText('Admin').click()
|
||||
cy.get('nav').findByText('Manage Site').click()
|
||||
cy.get('[role="tab"]').contains('System Messages').click()
|
||||
cy.get('button').contains('Clear all messages').click()
|
||||
|
||||
cy.log('verify system messages are no longer displayed')
|
||||
login(user1)
|
||||
cy.visit('/project')
|
||||
cy.findByText(message).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('manage users', () => {
|
||||
beforeEach(() => {
|
||||
login(admin)
|
||||
cy.visit('/project')
|
||||
cy.get('nav').findByText('Admin').click()
|
||||
cy.get('nav').findByText('Manage Users').click()
|
||||
})
|
||||
|
||||
describe('create users', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('a').contains('New User').click()
|
||||
})
|
||||
registrationTests()
|
||||
})
|
||||
|
||||
it('user list RegExp search', () => {
|
||||
cy.get('input[name="isRegExpSearch"]').click()
|
||||
cy.get('input[name="email"]').type('user[0-9]{enter}')
|
||||
cy.findByText(user2)
|
||||
cy.findByText(user1).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user page', () => {
|
||||
beforeEach(() => {
|
||||
login(admin)
|
||||
cy.visit('/project')
|
||||
cy.get('nav').findByText('Admin').click()
|
||||
cy.get('nav').findByText('Manage Users').click()
|
||||
cy.get('input[name="email"]').type(user1 + '{enter}')
|
||||
cy.findByText(user1).click()
|
||||
cy.url().should('match', /\/admin\/user\/[a-fA-F0-9]{24}/)
|
||||
})
|
||||
|
||||
it('displays expected tabs', () => {
|
||||
const tabs = [
|
||||
'User Info',
|
||||
'Projects',
|
||||
'Deleted Projects',
|
||||
'Audit Log',
|
||||
'Sessions',
|
||||
]
|
||||
cy.get('[role="tab"]').each((el, index) => {
|
||||
cy.wrap(el).findByText(tabs[index]).click()
|
||||
})
|
||||
cy.get('[role="tab"]').should('have.length', tabs.length)
|
||||
})
|
||||
|
||||
describe('user info tab', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('[role="tab"]').contains('User Info').click()
|
||||
})
|
||||
|
||||
it('displays required sections', () => {
|
||||
// not exhaustive list, checks the tab content is rendered
|
||||
cy.findByText('Profile')
|
||||
cy.findByText('Editor Settings')
|
||||
})
|
||||
|
||||
it('should not display SaaS-only sections', () => {
|
||||
cy.findByText('Referred User Count').should('not.exist')
|
||||
cy.findByText('Split Test Assignments').should('not.exist')
|
||||
cy.findByText('Experimental Features').should('not.exist')
|
||||
cy.findByText('Service Integration').should('not.exist')
|
||||
cy.findByText('SSO Integrations').should('not.exist')
|
||||
cy.findByText('Security').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
it('transfer project ownership', () => {
|
||||
cy.log("access project admin through owners' project list")
|
||||
cy.get('[role="tab"]').contains('Projects').click()
|
||||
cy.get(`a[href="/admin/project/${testProjectId}"]`).click()
|
||||
|
||||
cy.findByText('Transfer Ownership').click()
|
||||
cy.get('button[type="submit"]').should('be.disabled')
|
||||
cy.get('input[name="user_id"]').type(user2)
|
||||
cy.get('button[type="submit"]').should('not.be.disabled')
|
||||
cy.get('button[type="submit"]').click()
|
||||
cy.findByText('Transfer project to this user?')
|
||||
cy.get('button').contains('Confirm').click()
|
||||
|
||||
cy.log('check the project is displayed in the new owner projects tab')
|
||||
cy.get('input[name="email"]').type(user2 + '{enter}')
|
||||
cy.findByText(user2).click()
|
||||
cy.get('[role="tab"]').contains('Projects').click()
|
||||
cy.get(`a[href="/admin/project/${testProjectId}"]`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('project page', () => {
|
||||
beforeEach(() => {
|
||||
login(admin)
|
||||
cy.visit(`/admin/project/${testProjectId}`)
|
||||
})
|
||||
|
||||
it('displays expected tabs', () => {
|
||||
const tabs = ['Project Info', 'Deleted Docs', 'Audit Log']
|
||||
cy.get('[role="tab"]').each((el, index) => {
|
||||
cy.wrap(el).findByText(tabs[index]).click()
|
||||
})
|
||||
cy.get('[role="tab"]').should('have.length', tabs.length)
|
||||
})
|
||||
})
|
||||
|
||||
it('restore deleted projects', () => {
|
||||
login(user1)
|
||||
cy.visit('/project')
|
||||
|
||||
cy.log('select project to delete')
|
||||
findProjectRow(deletedProjectName).within(() =>
|
||||
cy.get('input[type="checkbox"]').first().check()
|
||||
)
|
||||
|
||||
cy.log('delete project')
|
||||
findProjectRow(deletedProjectName).within(() =>
|
||||
cy.findByRole('button', { name: 'Trash' }).click()
|
||||
)
|
||||
cy.get('button').contains('Confirm').click()
|
||||
cy.findByText(deletedProjectName).should('not.exist')
|
||||
|
||||
cy.log('navigate to thrashed projects and delete the project')
|
||||
cy.get('.project-list-sidebar-react').within(() => {
|
||||
cy.findByText('Trashed Projects').click()
|
||||
})
|
||||
findProjectRow(deletedProjectName).within(() =>
|
||||
cy.findByRole('button', { name: 'Delete' }).click()
|
||||
)
|
||||
cy.get('button').contains('Confirm').click()
|
||||
cy.findByText(deletedProjectName).should('not.exist')
|
||||
|
||||
cy.log('login as an admin and navigate to the deleted project')
|
||||
login(admin)
|
||||
cy.visit('/admin/user')
|
||||
cy.get('input[name="email"]').type(user1 + '{enter}')
|
||||
cy.get('a').contains(user1).click()
|
||||
cy.findByText('Deleted Projects').click()
|
||||
cy.get('a').contains(deletedProjectName).click()
|
||||
|
||||
cy.log('undelete the project')
|
||||
cy.findByText('Undelete').click()
|
||||
cy.findByText('Undelete').should('not.exist')
|
||||
cy.url().should('contain', `/admin/project/${projectToDeleteId}`)
|
||||
|
||||
cy.log('login as the user and verify the project is restored')
|
||||
login(user1)
|
||||
cy.visit('/project')
|
||||
cy.get('.project-list-sidebar-react').within(() => {
|
||||
cy.findByText('Trashed Projects').click()
|
||||
})
|
||||
cy.findByText(`${deletedProjectName} (Restored)`)
|
||||
})
|
||||
})
|
||||
})
|
124
server-ce/test/create-and-compile-project.spec.ts
Normal file
124
server-ce/test/create-and-compile-project.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import {
|
||||
createProject,
|
||||
openProjectViaInviteNotification,
|
||||
} from './helpers/project'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { throttledRecompile } from './helpers/compile'
|
||||
|
||||
describe('Project creation and compilation', function () {
|
||||
if (isExcludedBySharding('CE_DEFAULT')) return
|
||||
startWith({})
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
ensureUserExists({ email: 'collaborator@example.com' })
|
||||
|
||||
it('users can create project and compile it', function () {
|
||||
login('user@example.com')
|
||||
createProject('test-project')
|
||||
const recompile = throttledRecompile()
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}')
|
||||
recompile()
|
||||
cy.get('.pdf-viewer').should('contain.text', 'Test Section')
|
||||
})
|
||||
|
||||
it('create and edit markdown file', function () {
|
||||
const fileName = `test-${Date.now()}.md`
|
||||
const markdownContent = '# Markdown title'
|
||||
login('user@example.com')
|
||||
createProject('test-project')
|
||||
|
||||
// FIXME: Add aria-label maybe? or at least data-test-id
|
||||
cy.findByText('New file').click({ force: true })
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type(fileName)
|
||||
cy.findByText('Create').click()
|
||||
})
|
||||
cy.findByText(fileName).click()
|
||||
// wait until we've switched to the newly created empty file
|
||||
cy.get('.cm-line').should('have.length', 1)
|
||||
cy.get('.cm-line').type(markdownContent)
|
||||
cy.findByText('main.tex').click()
|
||||
cy.get('.cm-content').should('contain.text', '\\maketitle')
|
||||
cy.findByText(fileName).click()
|
||||
cy.get('.cm-content').should('contain.text', markdownContent)
|
||||
})
|
||||
|
||||
it('can link and display linked image from other project', function () {
|
||||
const sourceProjectName = `test-project-${Date.now()}`
|
||||
const targetProjectName = `${sourceProjectName}-target`
|
||||
login('user@example.com')
|
||||
|
||||
createProject(sourceProjectName, {
|
||||
type: 'Example Project',
|
||||
open: false,
|
||||
}).as('sourceProjectId')
|
||||
createProject(targetProjectName)
|
||||
|
||||
// link the image from `projectName` into this project
|
||||
cy.findByText('New file').click({ force: true })
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByText('From another project').click()
|
||||
cy.findByLabelText('Select a Project').select(sourceProjectName)
|
||||
cy.findByLabelText('Select a File').select('frog.jpg')
|
||||
cy.findByText('Create').click()
|
||||
})
|
||||
cy.findByTestId('file-tree').findByText('frog.jpg').click()
|
||||
cy.findByText('Another project')
|
||||
.should('have.attr', 'href')
|
||||
.then(href => {
|
||||
cy.get('@sourceProjectId').then(sourceProjectId => {
|
||||
expect(href).to.equal(`/project/${sourceProjectId}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('can refresh linked files as collaborator', function () {
|
||||
const sourceProjectName = `test-project-${Date.now()}`
|
||||
const targetProjectName = `${sourceProjectName}-target`
|
||||
login('user@example.com')
|
||||
createProject(sourceProjectName, {
|
||||
type: 'Example Project',
|
||||
open: false,
|
||||
}).as('sourceProjectId')
|
||||
createProject(targetProjectName).as('targetProjectId')
|
||||
|
||||
// link the image from `projectName` into this project
|
||||
cy.findByText('New file').click({ force: true })
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByText('From another project').click()
|
||||
cy.findByLabelText('Select a Project').select(sourceProjectName)
|
||||
cy.findByLabelText('Select a File').select('frog.jpg')
|
||||
cy.findByText('Create').click()
|
||||
})
|
||||
|
||||
cy.findByText('Share').click()
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByTestId('collaborator-email-input').type(
|
||||
'collaborator@example.com,'
|
||||
)
|
||||
cy.findByText('Invite').click({ force: true })
|
||||
cy.findByText('Invite not yet accepted.')
|
||||
})
|
||||
|
||||
cy.visit('/project')
|
||||
cy.findByText('Account').click()
|
||||
cy.findByText('Log Out').click()
|
||||
|
||||
login('collaborator@example.com')
|
||||
openProjectViaInviteNotification(targetProjectName)
|
||||
cy.get('@targetProjectId').then(targetProjectId => {
|
||||
cy.url().should('include', targetProjectId)
|
||||
})
|
||||
|
||||
cy.findByTestId('file-tree').findByText('frog.jpg').click()
|
||||
cy.findByText('Another project')
|
||||
.should('have.attr', 'href')
|
||||
.then(href => {
|
||||
cy.get('@sourceProjectId').then(sourceProjectId => {
|
||||
expect(href).to.equal(`/project/${sourceProjectId}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
26
server-ce/test/customization.spec.ts
Normal file
26
server-ce/test/customization.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
|
||||
describe('Customization', () => {
|
||||
if (isExcludedBySharding('CE_CUSTOM_1')) return
|
||||
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')
|
||||
})
|
||||
})
|
28
server-ce/test/cypress.config.js
Normal file
28
server-ce/test/cypress.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { defineConfig } = require('cypress')
|
||||
const { readPdf, readFileInZip } = require('./helpers/read-file')
|
||||
|
||||
const specPattern = process.env.SPEC_PATTERN || './**/*.spec.{js,ts,tsx}'
|
||||
|
||||
module.exports = defineConfig({
|
||||
defaultCommandTimeout: 10_000,
|
||||
fixturesFolder: 'cypress/fixtures',
|
||||
video: process.env.CYPRESS_VIDEO === 'true',
|
||||
screenshotsFolder: 'cypress/results',
|
||||
videosFolder: 'cypress/results',
|
||||
videoUploadOnPasses: false,
|
||||
viewportHeight: 768,
|
||||
viewportWidth: 1024,
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost',
|
||||
setupNodeEvents(on, config) {
|
||||
on('task', {
|
||||
readPdf,
|
||||
readFileInZip,
|
||||
})
|
||||
},
|
||||
specPattern,
|
||||
},
|
||||
retries: {
|
||||
runMode: 3,
|
||||
},
|
||||
})
|
3
server-ce/test/cypress/.gitignore
vendored
Normal file
3
server-ce/test/cypress/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
downloads/
|
||||
results/
|
||||
data/
|
12
server-ce/test/cypress/support/e2e.js
Normal file
12
server-ce/test/cypress/support/e2e.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import '@testing-library/cypress/add-commands'
|
||||
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
if (err.message.includes('ResizeObserver')) {
|
||||
// spurious error from PDF preview
|
||||
return false
|
||||
}
|
||||
if (err.message.includes('rcube_webmail')) {
|
||||
// spurious error from mailtrap
|
||||
return false
|
||||
}
|
||||
})
|
25
server-ce/test/docker-compose.native.yml
Normal file
25
server-ce/test/docker-compose.native.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
version: '2.2'
|
||||
services:
|
||||
sharelatex:
|
||||
ports:
|
||||
- "127.0.0.1:80:80"
|
||||
environment:
|
||||
OVERLEAF_SITE_URL: 'http://localhost'
|
||||
|
||||
host-admin:
|
||||
ports:
|
||||
- "127.0.0.1:8081:80"
|
||||
environment:
|
||||
NATIVE_CYPRESS: 'true'
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN: 'http://localhost'
|
||||
|
||||
saml:
|
||||
ports:
|
||||
- 127.0.0.1:8082:80
|
||||
environment:
|
||||
SAML_BASE_URL_PATH: 'http://localhost:8082/simplesaml/'
|
||||
SAML_TEST_SP_LOCATION: 'http://localhost/saml/callback'
|
||||
|
||||
mailtrap:
|
||||
ports:
|
||||
- 127.0.0.1:8083:80
|
149
server-ce/test/docker-compose.yml
Normal file
149
server-ce/test/docker-compose.yml
Normal file
@@ -0,0 +1,149 @@
|
||||
version: '2.2'
|
||||
services:
|
||||
sharelatex:
|
||||
image: ${IMAGE_TAG_CE:-sharelatex/sharelatex:latest}
|
||||
stop_grace_period: 0s
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
mailtrap:
|
||||
condition: service_started
|
||||
environment:
|
||||
OVERLEAF_SITE_URL: 'http://sharelatex'
|
||||
OVERLEAF_APP_NAME: Overleaf Community Edition
|
||||
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex?directConnection=true
|
||||
OVERLEAF_REDIS_HOST: redis
|
||||
REDIS_HOST: redis
|
||||
OVERLEAF_EMAIL_FROM_ADDRESS: 'welcome@example.com'
|
||||
OVERLEAF_EMAIL_SMTP_HOST: 'mailtrap'
|
||||
OVERLEAF_EMAIL_SMTP_PORT: '25'
|
||||
OVERLEAF_EMAIL_SMTP_IGNORE_TLS: 'true'
|
||||
ENABLED_LINKED_FILE_TYPES: 'project_file,project_output_file'
|
||||
ENABLE_CONVERSIONS: 'true'
|
||||
EMAIL_CONFIRMATION_DISABLED: 'true'
|
||||
healthcheck:
|
||||
test: curl --fail http://localhost:3000/status
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
mailtrap:
|
||||
image: mailtrap
|
||||
environment:
|
||||
MAILTRAP_PASSWORD: 'password-for-mailtrap'
|
||||
|
||||
mongo:
|
||||
image: mongo:6.0
|
||||
command: '--replSet overleaf'
|
||||
volumes:
|
||||
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: sharelatex
|
||||
extra_hosts:
|
||||
# Required when using the automatic database setup for initializing the replica set.
|
||||
# This override is not needed when running the setup after starting up mongo.
|
||||
- mongo:127.0.0.1
|
||||
healthcheck:
|
||||
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
redis:
|
||||
image: redis:7.2.1
|
||||
|
||||
git-bridge:
|
||||
image: quay.io/sharelatex/git-bridge:latest
|
||||
environment:
|
||||
GIT_BRIDGE_API_BASE_URL: "http://sharelatex:3000/api/v0/" # "http://sharelatex/api/v0/" for version 4.1.6 and earlier
|
||||
GIT_BRIDGE_OAUTH2_SERVER: "http://sharelatex"
|
||||
GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000"
|
||||
GIT_BRIDGE_ROOT_DIR: "/data/git-bridge"
|
||||
user: root
|
||||
command: ["/server-pro-start.sh"]
|
||||
|
||||
e2e:
|
||||
image: cypress/included:13.13.2
|
||||
stop_grace_period: 0s
|
||||
entrypoint: npm
|
||||
command: run cypress:run
|
||||
working_dir: /e2e
|
||||
volumes:
|
||||
- ./:/e2e
|
||||
environment:
|
||||
CYPRESS_SHARD:
|
||||
CYPRESS_BASE_URL: http://sharelatex
|
||||
SPEC_PATTERN: '**/*.spec.{js,jsx,ts,tsx}'
|
||||
depends_on:
|
||||
sharelatex:
|
||||
condition: service_healthy
|
||||
host-admin:
|
||||
condition: service_healthy
|
||||
|
||||
e2e-open:
|
||||
image: cypress/included:13.13.2
|
||||
stop_grace_period: 0s
|
||||
entrypoint: npm
|
||||
command: run cypress:open
|
||||
working_dir: /e2e
|
||||
volumes:
|
||||
- ./:/e2e
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix
|
||||
user: "${DOCKER_USER:-1000:1000}"
|
||||
environment:
|
||||
CYPRESS_SHARD:
|
||||
CYPRESS_BASE_URL: http://sharelatex
|
||||
SPEC_PATTERN: '**/*.spec.{js,jsx,ts,tsx}'
|
||||
DISPLAY: ${DISPLAY:-:0}
|
||||
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:
|
||||
CYPRESS_SHARD:
|
||||
COMPOSE_PROJECT_NAME:
|
||||
TEX_LIVE_DOCKER_IMAGE:
|
||||
ALL_TEX_LIVE_DOCKER_IMAGES:
|
||||
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
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
saml:
|
||||
restart: always
|
||||
image: gcr.io/overleaf-ops/saml-test
|
||||
environment:
|
||||
SAML_TEST_SP_ENTITY_ID: 'sharelatex-test-saml'
|
||||
SAML_BASE_URL_PATH: 'http://saml/simplesaml/'
|
||||
SAML_TEST_SP_LOCATION: 'http://sharelatex/saml/callback'
|
||||
|
||||
ldap:
|
||||
restart: always
|
||||
image: rroemhild/test-openldap:1.1
|
||||
# Workaround large memory allocation (using the max-open-files-limit as socket buffer scale).
|
||||
# REF: https://github.com/moby/moby/issues/8231#issuecomment-63877553
|
||||
# REF: https://github.com/moby/moby/issues/8231#issuecomment-63871343
|
||||
command:
|
||||
- 'bash'
|
||||
- '-c'
|
||||
- 'ulimit -n 1024 && exec bash /run.sh'
|
382
server-ce/test/editor.spec.ts
Normal file
382
server-ce/test/editor.spec.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
createNewFile,
|
||||
createProject,
|
||||
enableLinkSharing,
|
||||
openFile,
|
||||
openProjectById,
|
||||
openProjectViaLinkSharingAsUser,
|
||||
toggleTrackChanges,
|
||||
} from './helpers/project'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
|
||||
import { prepareWaitForNextCompileSlot } from './helpers/compile'
|
||||
|
||||
describe('editor', () => {
|
||||
if (isExcludedBySharding('PRO_DEFAULT_1')) return
|
||||
startWith({ pro: true })
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
ensureUserExists({ email: 'collaborator@example.com' })
|
||||
|
||||
let projectName: string
|
||||
let projectId: string
|
||||
let recompile: () => void
|
||||
let waitForCompileRateLimitCoolOff: (fn: () => void) => void
|
||||
beforeWithReRunOnTestRetry(function () {
|
||||
projectName = `project-${uuid()}`
|
||||
login('user@example.com')
|
||||
createProject(projectName, { type: 'Example Project', open: false }).then(
|
||||
id => (projectId = id)
|
||||
)
|
||||
;({ recompile, waitForCompileRateLimitCoolOff } =
|
||||
prepareWaitForNextCompileSlot())
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
login('user@example.com')
|
||||
waitForCompileRateLimitCoolOff(() => {
|
||||
openProjectById(projectId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spelling', function () {
|
||||
function changeSpellCheckLanguageTo(lng: string) {
|
||||
cy.log(`change project language to '${lng}'`)
|
||||
cy.get('button').contains('Menu').click()
|
||||
cy.get('select[id=settings-menu-spellCheckLanguage]').select(lng)
|
||||
cy.get('[id="left-menu"]').type('{esc}') // close left menu
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
changeSpellCheckLanguageTo('Off')
|
||||
})
|
||||
|
||||
it('word dictionary and spelling', () => {
|
||||
changeSpellCheckLanguageTo('English (American)')
|
||||
createNewFile()
|
||||
const word = createRandomLetterString()
|
||||
|
||||
cy.log('edit project file')
|
||||
cy.get('.cm-line').type(word)
|
||||
|
||||
cy.get('.ol-cm-spelling-error').should('exist')
|
||||
|
||||
changeSpellCheckLanguageTo('Spanish')
|
||||
|
||||
cy.log('add word to dictionary')
|
||||
cy.get('.ol-cm-spelling-error').contains(word).rightclick()
|
||||
cy.findByText('Add to Dictionary').click()
|
||||
cy.get('.ol-cm-spelling-error').should('not.exist')
|
||||
|
||||
cy.log('remove word from dictionary')
|
||||
cy.get('button').contains('Menu').click()
|
||||
cy.get('button').contains('Edit').click()
|
||||
cy.get('[id="dictionary-modal"]').within(() => {
|
||||
cy.findByText(word)
|
||||
.parent()
|
||||
.within(() => cy.get('button').click())
|
||||
|
||||
// the modal has 2 close buttons, this ensures the one with the visible label is
|
||||
// clicked, otherwise it would need `force: true`
|
||||
cy.get('.btn').contains('Close').click()
|
||||
})
|
||||
|
||||
cy.log('close left panel')
|
||||
cy.get('[id="left-menu"]').type('{esc}')
|
||||
|
||||
cy.log('rewrite word to force spelling error')
|
||||
cy.get('.cm-line').type('{selectAll}{del}' + word + '{enter}')
|
||||
|
||||
cy.get('.ol-cm-spelling-error').should('contain.text', word)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collaboration', () => {
|
||||
beforeWithReRunOnTestRetry(function () {
|
||||
enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
|
||||
const email = 'collaborator@example.com'
|
||||
login(email)
|
||||
openProjectViaLinkSharingAsUser(
|
||||
linkSharingReadAndWrite,
|
||||
projectName,
|
||||
email
|
||||
)
|
||||
})
|
||||
|
||||
login('user@example.com')
|
||||
waitForCompileRateLimitCoolOff(() => {
|
||||
openProjectById(projectId)
|
||||
})
|
||||
})
|
||||
|
||||
it('track-changes', () => {
|
||||
cy.log('disable track-changes before populating doc')
|
||||
toggleTrackChanges(false)
|
||||
|
||||
const fileName = createNewFile()
|
||||
const oldContent = 'oldContent'
|
||||
cy.get('.cm-line').type(`${oldContent}\n\nstatic`)
|
||||
|
||||
cy.log('recompile to force flush')
|
||||
recompile()
|
||||
|
||||
cy.log('enable track-changes for everyone')
|
||||
toggleTrackChanges(true)
|
||||
|
||||
login('collaborator@example.com')
|
||||
waitForCompileRateLimitCoolOff(() => {
|
||||
openProjectById(projectId)
|
||||
})
|
||||
openFile(fileName, 'static')
|
||||
|
||||
cy.log('make changes in main file')
|
||||
// cy.type() "clicks" in the center of the selected element before typing. This "click" discards the text as selected by the dblclick.
|
||||
// Go down to the lower level event based typing, the frontend tests in web use similar events.
|
||||
cy.get('.cm-editor').as('editor')
|
||||
cy.get('@editor').findByText(oldContent).dblclick()
|
||||
cy.get('@editor').trigger('keydown', { key: 'Delete' })
|
||||
cy.get('@editor').trigger('keydown', { key: 'Enter' })
|
||||
cy.get('@editor').trigger('keydown', { key: 'Enter' })
|
||||
|
||||
cy.log('recompile to force flush')
|
||||
recompile()
|
||||
|
||||
login('user@example.com')
|
||||
waitForCompileRateLimitCoolOff(() => {
|
||||
openProjectById(projectId)
|
||||
})
|
||||
openFile(fileName, 'static')
|
||||
|
||||
cy.log('reject changes')
|
||||
cy.contains('.toolbar-item', 'Review').click()
|
||||
cy.get('.cm-content').should('not.contain.text', oldContent)
|
||||
cy.findByText('Reject change').click({ force: true })
|
||||
cy.contains('.toolbar-item', 'Review').click()
|
||||
|
||||
cy.log('recompile to force flush')
|
||||
recompile()
|
||||
|
||||
cy.log('verify the changes are applied')
|
||||
cy.get('.cm-content').should('contain.text', oldContent)
|
||||
|
||||
cy.log('disable track-changes for everyone again')
|
||||
toggleTrackChanges(false)
|
||||
})
|
||||
|
||||
it('track-changes rich text', () => {
|
||||
cy.log('disable track-changes before populating doc')
|
||||
toggleTrackChanges(false)
|
||||
|
||||
const fileName = createNewFile()
|
||||
const oldContent = 'oldContent'
|
||||
cy.get('.cm-line').type(`\\section{{}${oldContent}}\n\nstatic`)
|
||||
|
||||
cy.log('recompile to force flush')
|
||||
recompile()
|
||||
|
||||
cy.log('enable track-changes for everyone')
|
||||
toggleTrackChanges(true)
|
||||
|
||||
login('collaborator@example.com')
|
||||
waitForCompileRateLimitCoolOff(() => {
|
||||
openProjectById(projectId)
|
||||
})
|
||||
cy.log('enable visual editor and make changes in main file')
|
||||
cy.findByText('Visual Editor').click()
|
||||
|
||||
openFile(fileName, 'static')
|
||||
|
||||
// cy.type() "clicks" in the center of the selected element before typing. This "click" discards the text as selected by the dblclick.
|
||||
// Go down to the lower level event based typing, the frontend tests in web use similar events.
|
||||
cy.get('.cm-editor').as('editor')
|
||||
cy.get('@editor').findByText(oldContent).dblclick()
|
||||
cy.get('@editor').trigger('keydown', { key: 'Delete' })
|
||||
cy.get('@editor').trigger('keydown', { key: 'Enter' })
|
||||
cy.get('@editor').trigger('keydown', { key: 'Enter' })
|
||||
|
||||
cy.log('recompile to force flush')
|
||||
recompile()
|
||||
|
||||
login('user@example.com')
|
||||
waitForCompileRateLimitCoolOff(() => {
|
||||
openProjectById(projectId)
|
||||
})
|
||||
openFile(fileName, 'static')
|
||||
|
||||
cy.log('reject changes')
|
||||
cy.contains('.toolbar-item', 'Review').click()
|
||||
cy.get('.cm-content').should('not.contain.text', oldContent)
|
||||
cy.findAllByText('Reject change').first().click({ force: true })
|
||||
cy.contains('.toolbar-item', 'Review').click()
|
||||
|
||||
cy.log('recompile to force flush')
|
||||
recompile()
|
||||
|
||||
cy.log('verify the changes are applied in the visual editor')
|
||||
cy.findByText('Visual Editor').click()
|
||||
cy.get('.cm-content').should('contain.text', oldContent)
|
||||
|
||||
cy.log('disable track-changes for everyone again')
|
||||
toggleTrackChanges(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor', () => {
|
||||
it('renders jpg', () => {
|
||||
cy.findByTestId('file-tree').findByText('frog.jpg').click()
|
||||
cy.get('[alt="frog.jpg"]')
|
||||
.should('be.visible')
|
||||
.and('have.prop', 'naturalWidth')
|
||||
.should('be.greaterThan', 0)
|
||||
})
|
||||
|
||||
it('symbol palette', () => {
|
||||
createNewFile()
|
||||
|
||||
cy.get('button[aria-label="Toggle Symbol Palette"]').click({
|
||||
force: true,
|
||||
})
|
||||
cy.get('button').contains('𝜉').click()
|
||||
cy.get('.cm-content').should('contain.text', '\\xi')
|
||||
|
||||
cy.log('recompile to force flush and avoid "unsaved changes" prompt')
|
||||
recompile()
|
||||
})
|
||||
})
|
||||
|
||||
describe('add new file to project', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('button').contains('New file').click({ force: true })
|
||||
})
|
||||
|
||||
it('can upload file', () => {
|
||||
const name = `${uuid()}.txt`
|
||||
const content = `Test File Content ${name}`
|
||||
cy.get('button').contains('Upload').click({ force: true })
|
||||
cy.get('input[type=file]')
|
||||
.first()
|
||||
.selectFile(
|
||||
{
|
||||
contents: Cypress.Buffer.from(content),
|
||||
fileName: name,
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
{ force: true }
|
||||
)
|
||||
// force: The file-tree pane is too narrow to display the full name.
|
||||
cy.findByTestId('file-tree').findByText(name).click({ force: true })
|
||||
cy.findByText(content)
|
||||
})
|
||||
|
||||
it('should not display import from URL', () => {
|
||||
cy.findByText('From external URL').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('left menu', () => {
|
||||
beforeEach(() => {
|
||||
cy.get('button').contains('Menu').click()
|
||||
})
|
||||
|
||||
it('can download project sources', () => {
|
||||
cy.get('a').contains('Source').click()
|
||||
cy.task('readFileInZip', {
|
||||
pathToZip: `cypress/downloads/${projectName}.zip`,
|
||||
fileToRead: 'main.tex',
|
||||
}).should('contain', 'Your introduction goes here')
|
||||
})
|
||||
|
||||
it('can download project PDF', () => {
|
||||
cy.log('ensure project is compiled')
|
||||
cy.get('.pdf-viewer').should('contain.text', 'Your Paper')
|
||||
|
||||
cy.get('.nav-downloads').within(() => {
|
||||
cy.findByText('PDF').click()
|
||||
const pdfName = projectName.replaceAll('-', '_')
|
||||
cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should(
|
||||
'contain',
|
||||
'Your introduction goes here'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('word count', () => {
|
||||
cy.log('ensure project is compiled')
|
||||
cy.get('.pdf-viewer').should('contain.text', 'Your Paper')
|
||||
|
||||
cy.findByText('Word Count').click()
|
||||
|
||||
cy.get('#word-count-modal').within(() => {
|
||||
cy.findByText('Total Words:')
|
||||
cy.findByText('607')
|
||||
cy.findByText('Headers:')
|
||||
cy.findByText('14')
|
||||
cy.findByText('Math Inline:')
|
||||
cy.findByText('6')
|
||||
cy.findByText('Math Display:')
|
||||
cy.findByText('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('layout selector', () => {
|
||||
it('show editor only and switch between editor and pdf', () => {
|
||||
cy.get('.pdf-viewer').should('be.visible')
|
||||
cy.get('.cm-editor').should('be.visible')
|
||||
|
||||
cy.findByText('Layout').click()
|
||||
cy.findByText('Editor only').click()
|
||||
|
||||
cy.get('.pdf-viewer').should('not.be.visible')
|
||||
cy.get('.cm-editor').should('be.visible')
|
||||
|
||||
cy.findByText('Switch to PDF').click()
|
||||
|
||||
cy.get('.pdf-viewer').should('be.visible')
|
||||
cy.get('.cm-editor').should('not.be.visible')
|
||||
|
||||
cy.findByText('Switch to editor').click()
|
||||
|
||||
cy.get('.pdf-viewer').should('not.be.visible')
|
||||
cy.get('.cm-editor').should('be.visible')
|
||||
})
|
||||
|
||||
it('show PDF only and go back to Editor & PDF', () => {
|
||||
cy.get('.pdf-viewer').should('be.visible')
|
||||
cy.get('.cm-editor').should('be.visible')
|
||||
|
||||
cy.findByText('Layout').click()
|
||||
cy.findByText('PDF only').click()
|
||||
|
||||
cy.get('.pdf-viewer').should('be.visible')
|
||||
cy.get('.cm-editor').should('not.be.visible')
|
||||
|
||||
cy.findByText('Layout').click()
|
||||
cy.findByText('Editor & PDF').click()
|
||||
|
||||
cy.get('.pdf-viewer').should('be.visible')
|
||||
cy.get('.cm-editor').should('be.visible')
|
||||
})
|
||||
|
||||
it('PDF in a separate tab (tests editor only)', () => {
|
||||
cy.get('.pdf-viewer').should('be.visible')
|
||||
cy.get('.cm-editor').should('be.visible')
|
||||
|
||||
cy.findByText('Layout').click()
|
||||
cy.findByText('PDF in separate tab').click()
|
||||
|
||||
cy.get('.pdf-viewer').should('not.exist')
|
||||
cy.get('.cm-editor').should('be.visible')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createRandomLetterString() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz'
|
||||
let result = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
73
server-ce/test/external-auth.spec.ts
Normal file
73
server-ce/test/external-auth.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { createProject } from './helpers/project'
|
||||
|
||||
describe('SAML', () => {
|
||||
if (isExcludedBySharding('PRO_CUSTOM_1')) return
|
||||
const samlURL = Cypress.env('SAML_URL') || 'http://saml'
|
||||
|
||||
startWith({
|
||||
pro: true,
|
||||
vars: {
|
||||
EXTERNAL_AUTH: 'saml',
|
||||
OVERLEAF_SAML_ENTRYPOINT: `${samlURL}/simplesaml/saml2/idp/SSOService.php`,
|
||||
OVERLEAF_SAML_CALLBACK_URL: `${Cypress.config().baseUrl}/saml/callback`,
|
||||
OVERLEAF_SAML_ISSUER: 'sharelatex-test-saml',
|
||||
OVERLEAF_SAML_IDENTITY_SERVICE_NAME: 'SAML Test Server',
|
||||
OVERLEAF_SAML_EMAIL_FIELD: 'email',
|
||||
OVERLEAF_SAML_FIRST_NAME_FIELD: 'givenName',
|
||||
OVERLEAF_SAML_LAST_NAME_FIELD: 'sn',
|
||||
OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN: 'true',
|
||||
OVERLEAF_SAML_CERT:
|
||||
'MIIDXTCCAkWgAwIBAgIJAOvOeQ4xFTzsMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMTE1MTQxMjU5WhcNMjYxMTE1MTQxMjU5WjBFMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCT6MBe5G9VoLU8MfztOEbUhnwLp17ak8eFUqxqeXkkqtWB0b/cmIBU3xoQoO3dIF8PBzfqehqfYVhrNt/TFgcmDfmJnPJRL1RJWMW3VmiP5odJ3LwlkKbZpkeT3wZ8HEJIR1+zbpxiBNkbd2GbdR1iumcsHzMYX1A2CBj+ZMV5VijC+K4P0e9c05VsDEUtLmfeAasJAiumQoVVgAe/BpiXjICGGewa6EPFI7mKkifIRKOGxdRESwZZjxP30bI31oDN0cgKqIgSJtJ9nfCn9jgBMBkQHu42WMuaWD4jrGd7+vYdX+oIfArs9aKgAH5kUGhGdew2R9SpBefrhbNxG8QIDAQABo1AwTjAdBgNVHQ4EFgQU+aSojSyyLChP/IpZcafvSdhj7KkwHwYDVR0jBBgwFoAU+aSojSyyLChP/IpZcafvSdhj7KkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABl3+OOVLBWMKs6PjA8lPuloWDNzSr3v76oUcHqAb+cfbucjXrOVsS9RJ0X9yxvCQyfM9FfY43DbspnN3izYhdvbJD8kKLNf0LA5st+ZxLfy0ACyL2iyAwICaqndqxAjQYplFAHmpUiu1DiHckyBPekokDJd+ze95urHMOsaGS5RWPoKJVE0bkaAeZCmEu0NNpXRSBiuxXSTeSAJfv6kyE/rkdhzUKyUl/cGQFrsVYfAFQVA+W6CKOh74ErSEzSHQQYndl7nD33snD/YqdU1ROxV6aJzLKCg+sdj+wRXSP2u/UHnM4jW9TGJfhO42jzL6WVuEvr9q4l7zWzUQKKKhtQ==',
|
||||
},
|
||||
})
|
||||
|
||||
it('login', () => {
|
||||
cy.visit('/')
|
||||
cy.findByText('Log in with SAML Test Server').click()
|
||||
|
||||
cy.origin(samlURL, () => {
|
||||
cy.get('input[name="username"]').type('sally')
|
||||
cy.get('input[name="password"]').type('sally123')
|
||||
cy.get('button[type="submit"]').click()
|
||||
})
|
||||
|
||||
cy.log('wait for login to finish')
|
||||
cy.url().should('contain', '/project')
|
||||
|
||||
createProject('via SAML')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LDAP', () => {
|
||||
if (isExcludedBySharding('PRO_CUSTOM_1')) return
|
||||
startWith({
|
||||
pro: true,
|
||||
vars: {
|
||||
EXTERNAL_AUTH: 'ldap',
|
||||
OVERLEAF_LDAP_URL: 'ldap://ldap:389',
|
||||
OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com',
|
||||
OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})',
|
||||
OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com',
|
||||
OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone',
|
||||
OVERLEAF_LDAP_EMAIL_ATT: 'mail',
|
||||
OVERLEAF_LDAP_NAME_ATT: 'cn',
|
||||
OVERLEAF_LDAP_LAST_NAME_ATT: 'sn',
|
||||
OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true',
|
||||
},
|
||||
})
|
||||
|
||||
it('login', () => {
|
||||
cy.visit('/')
|
||||
cy.findByText('Log in LDAP')
|
||||
|
||||
cy.get('input[name="login"]').type('fry')
|
||||
cy.get('input[name="password"]').type('fry')
|
||||
cy.get('button[type="submit"]').click()
|
||||
|
||||
cy.log('wait for login to finish')
|
||||
cy.url().should('contain', '/project')
|
||||
|
||||
createProject('via LDAP')
|
||||
})
|
||||
})
|
396
server-ce/test/git-bridge.spec.ts
Normal file
396
server-ce/test/git-bridge.spec.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import {
|
||||
createProject,
|
||||
enableLinkSharing,
|
||||
openProjectByName,
|
||||
openProjectViaLinkSharingAsUser,
|
||||
shareProjectByEmailAndAcceptInviteViaDash,
|
||||
} from './helpers/project'
|
||||
|
||||
import git from 'isomorphic-git'
|
||||
import http from 'isomorphic-git/http/web'
|
||||
import LightningFS from '@isomorphic-git/lightning-fs'
|
||||
import { throttledRecompile } from './helpers/compile'
|
||||
|
||||
describe('git-bridge', function () {
|
||||
const ENABLED_VARS = {
|
||||
GIT_BRIDGE_ENABLED: 'true',
|
||||
GIT_BRIDGE_HOST: 'git-bridge',
|
||||
GIT_BRIDGE_PORT: '8000',
|
||||
V1_HISTORY_URL: 'http://sharelatex:3100/api',
|
||||
}
|
||||
|
||||
function gitURL(projectId: string) {
|
||||
const url = new URL(Cypress.config().baseUrl!)
|
||||
url.username = 'git'
|
||||
url.pathname = `/git/${projectId}`
|
||||
return url
|
||||
}
|
||||
|
||||
describe('enabled in Server Pro', function () {
|
||||
if (isExcludedBySharding('PRO_CUSTOM_1')) return
|
||||
startWith({
|
||||
pro: true,
|
||||
vars: ENABLED_VARS,
|
||||
})
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
|
||||
function clearAllTokens() {
|
||||
cy.get('button.linking-git-bridge-revoke-button').each(el => {
|
||||
cy.wrap(el).click()
|
||||
cy.findByText('Delete token').click()
|
||||
})
|
||||
}
|
||||
|
||||
function maybeClearAllTokens() {
|
||||
cy.visit('/user/settings')
|
||||
cy.findByText('Git Integration')
|
||||
cy.get('button')
|
||||
.contains(/Generate token|Add another token/)
|
||||
.then(btn => {
|
||||
if (btn.text() === 'Add another token') {
|
||||
clearAllTokens()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
login('user@example.com')
|
||||
})
|
||||
|
||||
it('should render the git-bridge UI in the settings', () => {
|
||||
maybeClearAllTokens()
|
||||
cy.visit('/user/settings')
|
||||
cy.findByText('Git Integration')
|
||||
cy.get('button').contains('Generate token').click()
|
||||
cy.get('code')
|
||||
.contains(/olp_[a-zA-Z0-9]{16}/)
|
||||
.as('newToken')
|
||||
cy.findAllByText('Close').last().click()
|
||||
cy.get('@newToken').then(token => {
|
||||
// There can be more than one token with the same prefix when retrying
|
||||
cy.findAllByText(
|
||||
`${token.text().slice(0, 'olp_1234'.length)}${'*'.repeat(12)}`
|
||||
).should('have.length.at.least', 1)
|
||||
})
|
||||
cy.get('button').contains('Generate token').should('not.exist')
|
||||
cy.get('button').contains('Add another token').should('exist')
|
||||
clearAllTokens()
|
||||
cy.get('button').contains('Generate token').should('exist')
|
||||
cy.get('button').contains('Add another token').should('not.exist')
|
||||
})
|
||||
|
||||
it('should render the git-bridge UI in the editor', function () {
|
||||
maybeClearAllTokens()
|
||||
createProject('git').as('projectId')
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Sync')
|
||||
cy.findByText('Git').click()
|
||||
cy.findByTestId('git-bridge-modal').within(() => {
|
||||
cy.get('@projectId').then(id => {
|
||||
cy.get('code').contains(`git clone ${gitURL(id.toString())}`)
|
||||
})
|
||||
cy.findByRole('button', {
|
||||
name: 'Generate token',
|
||||
}).click()
|
||||
cy.get('code').contains(/olp_[a-zA-Z0-9]{16}/)
|
||||
})
|
||||
|
||||
// Re-open
|
||||
cy.url().then(url => cy.visit(url))
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Git').click()
|
||||
cy.findByTestId('git-bridge-modal').within(() => {
|
||||
cy.get('@projectId').then(id => {
|
||||
cy.get('code').contains(`git clone ${gitURL(id.toString())}`)
|
||||
})
|
||||
cy.findByText('Generate token').should('not.exist')
|
||||
cy.findByText(/generate a new one in Account Settings/)
|
||||
cy.findByText('Go to settings')
|
||||
.should('have.attr', 'target', '_blank')
|
||||
.and('have.attr', 'href', '/user/settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('git access', () => {
|
||||
ensureUserExists({ email: 'collaborator-rw@example.com' })
|
||||
ensureUserExists({ email: 'collaborator-ro@example.com' })
|
||||
ensureUserExists({ email: 'collaborator-link-rw@example.com' })
|
||||
ensureUserExists({ email: 'collaborator-link-ro@example.com' })
|
||||
|
||||
let projectName: string
|
||||
beforeEach(() => {
|
||||
projectName = uuid()
|
||||
createProject(projectName, { open: false }).as('projectId')
|
||||
})
|
||||
|
||||
it('should expose r/w interface to owner', () => {
|
||||
maybeClearAllTokens()
|
||||
openProjectByName(projectName)
|
||||
checkGitAccess('readAndWrite')
|
||||
})
|
||||
|
||||
it('should expose r/w interface to invited r/w collaborator', () => {
|
||||
shareProjectByEmailAndAcceptInviteViaDash(
|
||||
projectName,
|
||||
'collaborator-rw@example.com',
|
||||
'Editor'
|
||||
)
|
||||
maybeClearAllTokens()
|
||||
openProjectByName(projectName)
|
||||
checkGitAccess('readAndWrite')
|
||||
})
|
||||
|
||||
it('should expose r/o interface to invited r/o collaborator', () => {
|
||||
shareProjectByEmailAndAcceptInviteViaDash(
|
||||
projectName,
|
||||
'collaborator-ro@example.com',
|
||||
'Viewer'
|
||||
)
|
||||
maybeClearAllTokens()
|
||||
openProjectByName(projectName)
|
||||
checkGitAccess('readOnly')
|
||||
})
|
||||
|
||||
it('should expose r/w interface to link-sharing r/w collaborator', () => {
|
||||
openProjectByName(projectName)
|
||||
enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
|
||||
const email = 'collaborator-link-rw@example.com'
|
||||
login(email)
|
||||
maybeClearAllTokens()
|
||||
openProjectViaLinkSharingAsUser(
|
||||
linkSharingReadAndWrite,
|
||||
projectName,
|
||||
email
|
||||
)
|
||||
checkGitAccess('readAndWrite')
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose r/o interface to link-sharing r/o collaborator', () => {
|
||||
openProjectByName(projectName)
|
||||
enableLinkSharing().then(({ linkSharingReadOnly }) => {
|
||||
const email = 'collaborator-link-ro@example.com'
|
||||
login(email)
|
||||
maybeClearAllTokens()
|
||||
openProjectViaLinkSharingAsUser(
|
||||
linkSharingReadOnly,
|
||||
projectName,
|
||||
email
|
||||
)
|
||||
checkGitAccess('readOnly')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function checkGitAccess(access: 'readOnly' | 'readAndWrite') {
|
||||
const recompile = throttledRecompile()
|
||||
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Sync')
|
||||
cy.findByText('Git').click()
|
||||
cy.get('@projectId').then(projectId => {
|
||||
cy.findByTestId('git-bridge-modal').within(() => {
|
||||
cy.get('code').contains(`git clone ${gitURL(projectId.toString())}`)
|
||||
})
|
||||
cy.findByRole('button', {
|
||||
name: 'Generate token',
|
||||
}).click()
|
||||
cy.get('code')
|
||||
.contains(/olp_[a-zA-Z0-9]{16}/)
|
||||
.then(async tokenEl => {
|
||||
const token = tokenEl.text()
|
||||
|
||||
// close Git modal
|
||||
cy.findAllByText('Close').last().click()
|
||||
// close editor menu
|
||||
cy.get('.left-menu-modal-backdrop').click()
|
||||
|
||||
const fs = new LightningFS('fs')
|
||||
const dir = `/${projectId}`
|
||||
|
||||
async function readFile(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
|
||||
if (err) return reject(err)
|
||||
resolve(blob as string)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function writeFile(path: string, data: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
fs.writeFile(path, data, undefined, err => {
|
||||
if (err) return reject(err)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const commonOptions = {
|
||||
dir,
|
||||
fs,
|
||||
}
|
||||
const url = gitURL(projectId.toString())
|
||||
url.username = '' // basic auth is specified separately.
|
||||
const httpOptions = {
|
||||
http,
|
||||
url: url.toString(),
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`,
|
||||
},
|
||||
}
|
||||
const authorOptions = {
|
||||
author: { name: 'user', email: 'user@example.com' },
|
||||
committer: { name: 'user', email: 'user@example.com' },
|
||||
}
|
||||
const mainTex = `${dir}/main.tex`
|
||||
|
||||
// Clone
|
||||
cy.then({ timeout: 10_000 }, async () => {
|
||||
await git.clone({
|
||||
...commonOptions,
|
||||
...httpOptions,
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByText(/\\documentclass/)
|
||||
.parent()
|
||||
.parent()
|
||||
.then(async editor => {
|
||||
const onDisk = await readFile(mainTex)
|
||||
expect(onDisk.replaceAll('\n', '')).to.equal(editor.text())
|
||||
})
|
||||
|
||||
const text = `
|
||||
\\documentclass{article}
|
||||
\\begin{document}
|
||||
Hello world
|
||||
\\end{document}
|
||||
`
|
||||
|
||||
// Make a change
|
||||
cy.then(async () => {
|
||||
await writeFile(mainTex, text)
|
||||
await git.add({
|
||||
...commonOptions,
|
||||
filepath: 'main.tex',
|
||||
})
|
||||
await git.commit({
|
||||
...commonOptions,
|
||||
...authorOptions,
|
||||
message: 'Swap main.tex',
|
||||
})
|
||||
})
|
||||
|
||||
if (access === 'readAndWrite') {
|
||||
// check history before push
|
||||
cy.findAllByText('History').last().click()
|
||||
cy.findByText('(via Git)').should('not.exist')
|
||||
cy.findAllByText('Back to editor').last().click()
|
||||
|
||||
cy.then(async () => {
|
||||
await git.push({
|
||||
...commonOptions,
|
||||
...httpOptions,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
cy.then(async () => {
|
||||
try {
|
||||
await git.push({
|
||||
...commonOptions,
|
||||
...httpOptions,
|
||||
})
|
||||
expect.fail('push should have failed')
|
||||
} catch (err) {
|
||||
expect(err).to.match(/branches were not updated/)
|
||||
expect(err).to.match(/forbidden/)
|
||||
}
|
||||
})
|
||||
|
||||
return // return early, below are write access bits
|
||||
}
|
||||
|
||||
// check push in editor
|
||||
cy.findByText(/\\documentclass/)
|
||||
.parent()
|
||||
.parent()
|
||||
.should('have.text', text.replaceAll('\n', ''))
|
||||
|
||||
// Wait for history sync - trigger flush by toggling the UI
|
||||
cy.findAllByText('History').last().click()
|
||||
cy.findAllByText('Back to editor').last().click()
|
||||
|
||||
// check push in history
|
||||
cy.findAllByText('History').last().click()
|
||||
cy.findByText(/Hello world/)
|
||||
cy.findByText('(via Git)').should('exist')
|
||||
|
||||
// Back to the editor
|
||||
cy.findAllByText('Back to editor').last().click()
|
||||
cy.findByText(/\\documentclass/)
|
||||
.parent()
|
||||
.parent()
|
||||
.click()
|
||||
.type('% via editor{enter}')
|
||||
|
||||
// Trigger flush via compile
|
||||
recompile()
|
||||
|
||||
// Back into the history, check what we just added
|
||||
cy.findAllByText('History').last().click()
|
||||
cy.findByText(/% via editor/)
|
||||
|
||||
// Pull the change
|
||||
cy.then(async () => {
|
||||
await git.pull({
|
||||
...commonOptions,
|
||||
...httpOptions,
|
||||
...authorOptions,
|
||||
})
|
||||
|
||||
expect(await readFile(mainTex)).to.equal(text + '% via editor\n')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function checkDisabled() {
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
|
||||
it('should not render the git-bridge UI in the settings', () => {
|
||||
login('user@example.com')
|
||||
cy.visit('/user/settings')
|
||||
cy.findByText('Git Integration').should('not.exist')
|
||||
})
|
||||
it('should not render the git-bridge UI in the editor', function () {
|
||||
login('user@example.com')
|
||||
createProject('maybe git')
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Word Count') // wait for lazy loading
|
||||
cy.findByText('Sync').should('not.exist')
|
||||
cy.findByText('Git').should('not.exist')
|
||||
})
|
||||
}
|
||||
|
||||
describe('disabled in Server Pro', () => {
|
||||
if (isExcludedBySharding('PRO_DEFAULT_1')) return
|
||||
startWith({
|
||||
pro: true,
|
||||
})
|
||||
checkDisabled()
|
||||
})
|
||||
|
||||
describe('unavailable in CE', () => {
|
||||
if (isExcludedBySharding('CE_CUSTOM_1')) return
|
||||
startWith({
|
||||
pro: false,
|
||||
vars: ENABLED_VARS,
|
||||
})
|
||||
checkDisabled()
|
||||
})
|
||||
})
|
95
server-ce/test/graceful-shutdown.spec.ts
Normal file
95
server-ce/test/graceful-shutdown.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import {
|
||||
isExcludedBySharding,
|
||||
STARTUP_TIMEOUT,
|
||||
startWith,
|
||||
} from './helpers/config'
|
||||
import { dockerCompose, getRedisKeys } from './helpers/hostAdminClient'
|
||||
import { createProject } from './helpers/project'
|
||||
import { throttledRecompile } from './helpers/compile'
|
||||
|
||||
const USER = 'user@example.com'
|
||||
const PROJECT_NAME = 'Old Project'
|
||||
|
||||
function bringServerProBackUp() {
|
||||
cy.log('bring server pro back up')
|
||||
cy.then({ timeout: STARTUP_TIMEOUT }, async () => {
|
||||
await dockerCompose('up', '--detach', '--wait', 'sharelatex')
|
||||
})
|
||||
}
|
||||
|
||||
describe('GracefulShutdown', function () {
|
||||
if (isExcludedBySharding('PRO_CUSTOM_1')) return
|
||||
startWith({
|
||||
pro: true,
|
||||
withDataDir: true,
|
||||
resetData: true,
|
||||
})
|
||||
ensureUserExists({ email: USER })
|
||||
|
||||
let projectId: string
|
||||
it('should display banner and flush changes out of redis', () => {
|
||||
bringServerProBackUp()
|
||||
login(USER)
|
||||
createProject(PROJECT_NAME).then(id => {
|
||||
projectId = id
|
||||
})
|
||||
const recompile = throttledRecompile()
|
||||
|
||||
cy.log('add additional content')
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle').parent().type(`\n\\section{{}New Section}`)
|
||||
recompile()
|
||||
|
||||
cy.log(
|
||||
'check flush from frontend to backend: should include new section in PDF'
|
||||
)
|
||||
cy.get('.pdf-viewer').should('contain.text', 'New Section')
|
||||
|
||||
cy.log('should have unflushed content in redis before shutdown')
|
||||
cy.then(async () => {
|
||||
const keys = await getRedisKeys()
|
||||
expect(keys).to.contain(`DocsIn:${projectId}`)
|
||||
expect(keys).to.contain(`ProjectHistory:Ops:{${projectId}}`)
|
||||
})
|
||||
|
||||
cy.log('trigger graceful shutdown')
|
||||
let pendingShutdown: Promise<any>
|
||||
cy.then(() => {
|
||||
pendingShutdown = dockerCompose('stop', '--timeout=60', 'sharelatex')
|
||||
})
|
||||
|
||||
cy.log('wait for banner')
|
||||
cy.findByText(/performing maintenance/)
|
||||
cy.log('wait for page reload')
|
||||
cy.findByText(/is currently down for maintenance/)
|
||||
|
||||
cy.log('wait for shutdown to complete')
|
||||
cy.then({ timeout: 60 * 1000 }, async () => {
|
||||
await pendingShutdown
|
||||
})
|
||||
|
||||
cy.log('should not have any unflushed content in redis after shutdown')
|
||||
cy.then(async () => {
|
||||
const keys = await getRedisKeys()
|
||||
expect(keys).to.not.contain(`DocsIn:${projectId}`)
|
||||
expect(keys).to.not.contain(`ProjectHistory:Ops:{${projectId}}`)
|
||||
})
|
||||
|
||||
bringServerProBackUp()
|
||||
|
||||
cy.then(() => {
|
||||
cy.visit(`/project/${projectId}?trick-cypress-into-page-reload=true`)
|
||||
})
|
||||
|
||||
cy.log('check loading doc from mongo')
|
||||
cy.findByText('New Section')
|
||||
|
||||
cy.log('check PDF')
|
||||
cy.get('.pdf-viewer').should('contain.text', 'New Section')
|
||||
|
||||
cy.log('check history')
|
||||
cy.findByText('History').click()
|
||||
cy.findByText(/\\section\{New Section}/)
|
||||
})
|
||||
})
|
8
server-ce/test/helpers/beforeWithReRunOnTestRetry.ts
Normal file
8
server-ce/test/helpers/beforeWithReRunOnTestRetry.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function beforeWithReRunOnTestRetry(fn: () => void | Promise<any>) {
|
||||
let ranOnce = false
|
||||
beforeEach(() => {
|
||||
if (ranOnce && Cypress.currentRetry === 0) return
|
||||
ranOnce = true
|
||||
return fn()
|
||||
})
|
||||
}
|
40
server-ce/test/helpers/compile.ts
Normal file
40
server-ce/test/helpers/compile.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Helper function for throttling clicks on the recompile button to avoid hitting server side rate limits.
|
||||
* The naive approach is waiting a fixed a mount of time (3s) just before clicking the button.
|
||||
* This helper takes into account that other UI interactions take time. We can deduce that latency from the fixed delay (3s minus other latency). This can bring down the effective waiting time to 0s.
|
||||
*/
|
||||
export function throttledRecompile() {
|
||||
const { queueReset, recompile } = prepareWaitForNextCompileSlot()
|
||||
queueReset()
|
||||
return recompile
|
||||
}
|
||||
|
||||
export function prepareWaitForNextCompileSlot() {
|
||||
let lastCompile = 0
|
||||
function queueReset() {
|
||||
cy.then(() => {
|
||||
lastCompile = Date.now()
|
||||
})
|
||||
}
|
||||
function waitForCompileRateLimitCoolOff(triggerCompile: () => void) {
|
||||
cy.then(() => {
|
||||
cy.log('Wait for recompile rate-limit to cool off')
|
||||
const msSinceLastCompile = Date.now() - lastCompile
|
||||
cy.wait(Math.max(0, 1_000 - msSinceLastCompile))
|
||||
queueReset()
|
||||
triggerCompile()
|
||||
cy.log('Wait for compile to finish')
|
||||
cy.findByText('Recompile').should('be.visible')
|
||||
})
|
||||
}
|
||||
function recompile() {
|
||||
waitForCompileRateLimitCoolOff(() => {
|
||||
cy.findByText('Recompile').click()
|
||||
})
|
||||
}
|
||||
return {
|
||||
queueReset,
|
||||
waitForCompileRateLimitCoolOff,
|
||||
recompile,
|
||||
}
|
||||
}
|
64
server-ce/test/helpers/config.ts
Normal file
64
server-ce/test/helpers/config.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { reconfigure } from './hostAdminClient'
|
||||
import { resetActivateUserRateLimit, resetCreatedUsersCache } from './login'
|
||||
|
||||
export const STARTUP_TIMEOUT =
|
||||
parseInt(Cypress.env('STARTUP_TIMEOUT'), 10) || 120_000
|
||||
|
||||
export function isExcludedBySharding(
|
||||
shard:
|
||||
| 'CE_DEFAULT'
|
||||
| 'CE_CUSTOM_1'
|
||||
| 'CE_CUSTOM_2'
|
||||
| 'PRO_DEFAULT_1'
|
||||
| 'PRO_DEFAULT_2'
|
||||
| 'PRO_CUSTOM_1'
|
||||
| 'PRO_CUSTOM_2'
|
||||
| 'PRO_CUSTOM_3'
|
||||
) {
|
||||
const SHARD = Cypress.env('SHARD')
|
||||
return SHARD && shard !== SHARD
|
||||
}
|
||||
|
||||
let previousConfigFrontend: string
|
||||
|
||||
export function startWith({
|
||||
pro = false,
|
||||
version = 'latest',
|
||||
vars = {},
|
||||
varsFn = () => ({}),
|
||||
withDataDir = false,
|
||||
resetData = false,
|
||||
}) {
|
||||
before(async function () {
|
||||
Object.assign(vars, varsFn())
|
||||
const cfg = JSON.stringify({
|
||||
pro,
|
||||
version,
|
||||
vars,
|
||||
withDataDir,
|
||||
resetData,
|
||||
})
|
||||
if (resetData) {
|
||||
resetCreatedUsersCache()
|
||||
resetActivateUserRateLimit()
|
||||
// no return here, always reconfigure when resetting data
|
||||
} else if (previousConfigFrontend === cfg) {
|
||||
return
|
||||
}
|
||||
|
||||
this.timeout(STARTUP_TIMEOUT)
|
||||
const { previousConfigServer } = await reconfigure({
|
||||
pro,
|
||||
version,
|
||||
vars,
|
||||
withDataDir,
|
||||
resetData,
|
||||
})
|
||||
if (previousConfigServer !== cfg) {
|
||||
await Cypress.session.clearAllSavedSessions()
|
||||
}
|
||||
previousConfigFrontend = cfg
|
||||
})
|
||||
}
|
||||
|
||||
export { reconfigure }
|
39
server-ce/test/helpers/email.ts
Normal file
39
server-ce/test/helpers/email.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Helper function for opening an email in Roundcube based mailtrap.
|
||||
* We need to cross an origin boundary, which complicates the use of variables.
|
||||
* Any variables need to be explicitly defined and the "runner" may only reference these and none from its scope.
|
||||
* It is not possible to use Cypress helper functions, e.g. from the testing library or other functions like "activateUser", inside the "runner".
|
||||
* REF: https://github.com/testing-library/cypress-testing-library/issues/221
|
||||
*/
|
||||
export function openEmail<T>(
|
||||
subject: string | RegExp,
|
||||
runner: (frame: Cypress.Chainable<JQuery<any>>, args: T) => void,
|
||||
args?: T
|
||||
) {
|
||||
const runnerS = runner.toString()
|
||||
cy.origin(
|
||||
Cypress.env('MAILTRAP_URL') || 'http://mailtrap',
|
||||
{ args: { args, runnerS, subject } },
|
||||
({ args, runnerS, subject }) => {
|
||||
cy.visit('/')
|
||||
cy.get('input[name="_user"]').type('mailtrap')
|
||||
cy.get('input[name="_pass"]').type('password-for-mailtrap')
|
||||
cy.get('button[type="submit"]').click()
|
||||
cy.url().then(url => {
|
||||
if (!url.includes('?_task=login')) return
|
||||
cy.log('mailtrap login is flaky in cypress, submit again')
|
||||
cy.get('input[name="_pass"]').type('password-for-mailtrap')
|
||||
cy.get('button[type="submit"]').click()
|
||||
})
|
||||
// Use force as the subject is partially hidden
|
||||
cy.contains(subject).click({ force: true })
|
||||
cy.log('wait for iframe loading')
|
||||
cy.wait(1000)
|
||||
cy.get('iframe[id="messagecontframe"]').then(frame => {
|
||||
// runnerS='(frame, args) => { runner body }'. Extract the runnable function.
|
||||
const runner = new Function('return ' + runnerS)()
|
||||
runner(cy.wrap(frame.prop('contentWindow').document.body), args)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
92
server-ce/test/helpers/hostAdminClient.ts
Normal file
92
server-ce/test/helpers/hostAdminClient.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
const hostAdminURL = Cypress.env('HOST_ADMIN_URL') || 'http://host-admin'
|
||||
|
||||
export async function dockerCompose(cmd: string, ...args: string[]) {
|
||||
return await fetchJSON(`${hostAdminURL}/docker/compose/${cmd}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
args,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function reconfigure({
|
||||
pro = false,
|
||||
version = 'latest',
|
||||
vars = {},
|
||||
withDataDir = false,
|
||||
resetData = false,
|
||||
}): Promise<{ previousConfigServer: string }> {
|
||||
return await fetchJSON(`${hostAdminURL}/reconfigure`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
pro,
|
||||
version,
|
||||
vars,
|
||||
withDataDir,
|
||||
resetData,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchJSON<T = { stdout: string; stderr: string }>(
|
||||
input: RequestInfo,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
if (init?.body) {
|
||||
init.headers = { 'Content-Type': 'application/json' }
|
||||
}
|
||||
let res
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
res = await fetch(input, init)
|
||||
break
|
||||
} catch {
|
||||
await sleep(3_000)
|
||||
}
|
||||
}
|
||||
if (!res) {
|
||||
res = await fetch(input, init)
|
||||
}
|
||||
const { error, stdout, stderr, ...rest } = await res.json()
|
||||
if (error) {
|
||||
console.error(input, init, 'failed:', error)
|
||||
if (stdout) console.log(stdout)
|
||||
if (stderr) console.warn(stderr)
|
||||
const err = new Error(error.message)
|
||||
Object.assign(err, error)
|
||||
throw err
|
||||
}
|
||||
return { stdout, stderr, ...rest }
|
||||
}
|
||||
|
||||
export async function runScript({
|
||||
cwd,
|
||||
script,
|
||||
args = [],
|
||||
}: {
|
||||
cwd: string
|
||||
script: string
|
||||
args?: string[]
|
||||
}) {
|
||||
return await fetchJSON(`${hostAdminURL}/run/script`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
cwd,
|
||||
script,
|
||||
args,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getRedisKeys() {
|
||||
const { stdout } = await fetchJSON(`${hostAdminURL}/redis/keys`, {
|
||||
method: 'GET',
|
||||
})
|
||||
return stdout.split('\n')
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
113
server-ce/test/helpers/login.ts
Normal file
113
server-ce/test/helpers/login.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { runScript } from './hostAdminClient'
|
||||
|
||||
const DEFAULT_PASSWORD = 'Passw0rd!'
|
||||
|
||||
const createdUsers = new Set<string>()
|
||||
|
||||
export function resetCreatedUsersCache() {
|
||||
createdUsers.clear()
|
||||
}
|
||||
|
||||
export async function createMongoUser({
|
||||
email,
|
||||
isAdmin = false,
|
||||
}: {
|
||||
email: string
|
||||
isAdmin?: boolean
|
||||
}) {
|
||||
const t0 = Math.floor(Date.now() / 1000)
|
||||
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(/http:\/\/.+\/user\/activate\?token=\S+/)!
|
||||
const userId = new URL(url, location.origin).searchParams.get('user_id')!
|
||||
const signupDate = parseInt(userId.slice(0, 8), 16)
|
||||
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],
|
||||
() => {
|
||||
cy.visit('/login')
|
||||
cy.get('input[name="email"]').type(username)
|
||||
cy.get('input[name="password"]').type(password)
|
||||
cy.findByRole('button', { name: 'Login' }).click()
|
||||
cy.url().should('contain', '/project')
|
||||
},
|
||||
{
|
||||
cacheAcrossSpecs: true,
|
||||
async validate() {
|
||||
// Hit a cheap endpoint that is behind AuthenticationController.requireLogin().
|
||||
cy.request({ url: '/user/personal_info', followRedirect: false }).then(
|
||||
response => {
|
||||
expect(response.status).to.equal(200)
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let activateRateLimitState = { count: 0, reset: 0 }
|
||||
export function resetActivateUserRateLimit() {
|
||||
activateRateLimitState = { count: 0, reset: 0 }
|
||||
}
|
||||
|
||||
function handleActivateUserRateLimit() {
|
||||
cy.then(() => {
|
||||
activateRateLimitState.count++
|
||||
if (activateRateLimitState.reset < Date.now()) {
|
||||
activateRateLimitState.reset = Date.now() + 65_000
|
||||
activateRateLimitState.count = 1
|
||||
} else if (activateRateLimitState.count >= 6) {
|
||||
cy.wait(activateRateLimitState.reset - Date.now())
|
||||
activateRateLimitState.count = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function activateUser(url: string, password = DEFAULT_PASSWORD) {
|
||||
handleActivateUserRateLimit()
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
}
|
251
server-ce/test/helpers/project.ts
Normal file
251
server-ce/test/helpers/project.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { login } from './login'
|
||||
import { openEmail } from './email'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
export function createProject(
|
||||
name: string,
|
||||
{
|
||||
type = 'Blank Project',
|
||||
newProjectButtonMatcher = /new project/i,
|
||||
open = true,
|
||||
}: {
|
||||
type?: 'Blank Project' | 'Example Project'
|
||||
newProjectButtonMatcher?: RegExp
|
||||
open?: boolean
|
||||
} = {}
|
||||
): Cypress.Chainable<string> {
|
||||
cy.url().then(url => {
|
||||
if (!url.endsWith('/project')) {
|
||||
cy.visit('/project')
|
||||
}
|
||||
})
|
||||
const interceptId = uuid()
|
||||
let projectId = ''
|
||||
if (!open) {
|
||||
cy.then(() => {
|
||||
// Register intercept just before creating the project, otherwise we might
|
||||
// intercept a request from a prior createProject invocation.
|
||||
cy.intercept(
|
||||
{ method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
|
||||
req => {
|
||||
projectId = req.url.split('/').pop()!
|
||||
// Redirect back to the project dashboard, effectively reload the page.
|
||||
req.redirect('/project')
|
||||
}
|
||||
).as(interceptId)
|
||||
})
|
||||
}
|
||||
cy.findAllByRole('button').contains(newProjectButtonMatcher).click()
|
||||
// FIXME: This should only look in the left menu
|
||||
cy.findAllByText(type).first().click()
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.get('input').type(name)
|
||||
cy.findByText('Create').click()
|
||||
})
|
||||
if (open) {
|
||||
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
|
||||
waitForMainDocToLoad()
|
||||
return cy
|
||||
.url()
|
||||
.should('match', /\/project\/[a-fA-F0-9]{24}/)
|
||||
.then(url => url.split('/').pop())
|
||||
} else {
|
||||
const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
|
||||
cy.wait(alias)
|
||||
return cy.then(() => projectId)
|
||||
}
|
||||
}
|
||||
|
||||
export function openProjectByName(projectName: string) {
|
||||
cy.visit('/project')
|
||||
cy.findByText(projectName).click()
|
||||
waitForMainDocToLoad()
|
||||
}
|
||||
|
||||
export function openProjectById(projectId: string) {
|
||||
cy.visit(`/project/${projectId}`)
|
||||
waitForMainDocToLoad()
|
||||
}
|
||||
|
||||
export function openProjectViaLinkSharingAsAnon(url: string) {
|
||||
cy.visit(url)
|
||||
waitForMainDocToLoad()
|
||||
}
|
||||
|
||||
export function openProjectViaLinkSharingAsUser(
|
||||
url: string,
|
||||
projectName: string,
|
||||
email: string
|
||||
) {
|
||||
cy.visit(url)
|
||||
cy.findByText(projectName) // wait for lazy loading
|
||||
cy.contains(`as ${email}`)
|
||||
cy.findByText('OK, join project').click()
|
||||
waitForMainDocToLoad()
|
||||
}
|
||||
|
||||
export function openProjectViaInviteNotification(projectName: string) {
|
||||
cy.visit('/project')
|
||||
cy.findByText(projectName)
|
||||
.parent()
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.findByText('Join Project').click()
|
||||
})
|
||||
cy.findByText('Open Project').click()
|
||||
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
|
||||
waitForMainDocToLoad()
|
||||
}
|
||||
|
||||
function shareProjectByEmail(
|
||||
projectName: string,
|
||||
email: string,
|
||||
level: 'Viewer' | 'Editor'
|
||||
) {
|
||||
openProjectByName(projectName)
|
||||
cy.findByText('Share').click()
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByLabelText('Add people', { selector: 'input' }).type(`${email},`)
|
||||
cy.findByLabelText('Add people', { selector: 'input' })
|
||||
.parents('form')
|
||||
.within(() => {
|
||||
cy.findByTestId('add-collaborator-select')
|
||||
.click()
|
||||
.then(() => {
|
||||
cy.findByText(level).click()
|
||||
})
|
||||
})
|
||||
cy.findByText('Invite').click({ force: true })
|
||||
cy.findByText('Invite not yet accepted.')
|
||||
})
|
||||
}
|
||||
|
||||
export function shareProjectByEmailAndAcceptInviteViaDash(
|
||||
projectName: string,
|
||||
email: string,
|
||||
level: 'Viewer' | 'Editor'
|
||||
) {
|
||||
shareProjectByEmail(projectName, email, level)
|
||||
|
||||
login(email)
|
||||
openProjectViaInviteNotification(projectName)
|
||||
}
|
||||
|
||||
export function shareProjectByEmailAndAcceptInviteViaEmail(
|
||||
projectName: string,
|
||||
email: string,
|
||||
level: 'Viewer' | 'Editor'
|
||||
) {
|
||||
shareProjectByEmail(projectName, email, level)
|
||||
|
||||
login(email)
|
||||
|
||||
openEmail(projectName, frame => {
|
||||
frame.contains('View project').then(a => {
|
||||
cy.log(
|
||||
'bypass target=_blank and navigate current browser tab/cypress-iframe to project invite'
|
||||
)
|
||||
cy.visit(a.attr('href')!)
|
||||
})
|
||||
})
|
||||
cy.url().should('match', /\/project\/[a-f0-9]+\/invite\/token\/[a-f0-9]+/)
|
||||
cy.findByText(/user would like you to join/)
|
||||
cy.contains(new RegExp(`You are accepting this invite as ${email}`))
|
||||
cy.findByText('Join Project').click()
|
||||
waitForMainDocToLoad()
|
||||
}
|
||||
|
||||
export function enableLinkSharing() {
|
||||
let linkSharingReadOnly: string
|
||||
let linkSharingReadAndWrite: string
|
||||
const origin = new URL(Cypress.config().baseUrl!).origin
|
||||
|
||||
waitForMainDocToLoad()
|
||||
|
||||
cy.findByText('Share').click()
|
||||
cy.findByText('Turn on link sharing').click()
|
||||
cy.findByText('Anyone with this link can view this project')
|
||||
.next()
|
||||
.should('contain.text', origin + '/read')
|
||||
.then(el => {
|
||||
linkSharingReadOnly = el.text()
|
||||
})
|
||||
cy.findByText('Anyone with this link can edit this project')
|
||||
.next()
|
||||
.should('contain.text', origin + '/')
|
||||
.then(el => {
|
||||
linkSharingReadAndWrite = el.text()
|
||||
})
|
||||
|
||||
return cy.then(() => {
|
||||
return { linkSharingReadOnly, linkSharingReadAndWrite }
|
||||
})
|
||||
}
|
||||
|
||||
export function waitForMainDocToLoad() {
|
||||
cy.log('Wait for main doc to load; it will steal the focus after loading')
|
||||
cy.get('.cm-content').should('contain.text', 'Introduction')
|
||||
}
|
||||
|
||||
export function openFile(fileName: string, waitFor: string) {
|
||||
// force: The file-tree pane is too narrow to display the full name.
|
||||
cy.findByTestId('file-tree').findByText(fileName).click({ force: true })
|
||||
|
||||
// wait until we've switched to the selected file
|
||||
cy.findByText('Loading…').should('not.exist')
|
||||
cy.findByText(waitFor)
|
||||
}
|
||||
|
||||
export function createNewFile() {
|
||||
const fileName = `${uuid()}.tex`
|
||||
|
||||
cy.log('create new project file')
|
||||
cy.get('button').contains('New file').click({ force: true })
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.get('input').clear()
|
||||
cy.get('input').type(fileName)
|
||||
cy.findByText('Create').click()
|
||||
})
|
||||
// force: The file-tree pane is too narrow to display the full name.
|
||||
cy.findByTestId('file-tree').findByText(fileName).click({ force: true })
|
||||
|
||||
// wait until we've switched to the newly created empty file
|
||||
cy.findByText('Loading…').should('not.exist')
|
||||
cy.get('.cm-line').should('have.length', 1)
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
export function toggleTrackChanges(state: boolean) {
|
||||
cy.findByText('Review').click()
|
||||
cy.get('.track-changes-menu-button').then(el => {
|
||||
// when the menu is expanded renders the `expand_more` icon,
|
||||
// and the `chevron_right` icon when it's collapsed
|
||||
if (!el.text().includes('expand_more')) {
|
||||
el.click()
|
||||
}
|
||||
})
|
||||
|
||||
cy.findByText('Everyone')
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get('.form-check-input').then(el => {
|
||||
if (el.prop('checked') === state) return
|
||||
|
||||
const id = uuid()
|
||||
const alias = `@${id}`
|
||||
cy.intercept({
|
||||
method: 'POST',
|
||||
url: '**/track_changes',
|
||||
times: 1,
|
||||
}).as(id)
|
||||
if (state) {
|
||||
cy.get('.form-check-input').check()
|
||||
} else {
|
||||
cy.get('.form-check-input').uncheck()
|
||||
}
|
||||
cy.wait(alias)
|
||||
})
|
||||
})
|
||||
cy.contains('.toolbar-item', 'Review').click()
|
||||
}
|
52
server-ce/test/helpers/read-file.ts
Normal file
52
server-ce/test/helpers/read-file.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import pdf from 'pdf-parse'
|
||||
import AdmZip from 'adm-zip'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const sleep = promisify(setTimeout)
|
||||
|
||||
const MAX_ATTEMPTS = 15
|
||||
const POLL_INTERVAL = 500
|
||||
|
||||
type ReadFileInZipArgs = {
|
||||
pathToZip: string
|
||||
fileToRead: string
|
||||
}
|
||||
|
||||
export async function readFileInZip({
|
||||
pathToZip,
|
||||
fileToRead,
|
||||
}: ReadFileInZipArgs) {
|
||||
let attempt = 0
|
||||
while (attempt < MAX_ATTEMPTS) {
|
||||
if (fs.existsSync(pathToZip)) {
|
||||
const zip = new AdmZip(path.resolve(pathToZip))
|
||||
const entry = zip
|
||||
.getEntries()
|
||||
.find(entry => entry.entryName == fileToRead)
|
||||
if (entry) {
|
||||
return entry.getData().toString('utf8')
|
||||
} else {
|
||||
throw new Error(`${fileToRead} not found in ${pathToZip}`)
|
||||
}
|
||||
}
|
||||
await sleep(POLL_INTERVAL)
|
||||
attempt++
|
||||
}
|
||||
throw new Error(`${pathToZip} not found`)
|
||||
}
|
||||
|
||||
export async function readPdf(file: string) {
|
||||
let attempt = 0
|
||||
while (attempt < MAX_ATTEMPTS) {
|
||||
if (fs.existsSync(file)) {
|
||||
const dataBuffer = fs.readFileSync(path.resolve(file))
|
||||
const { text } = await pdf(dataBuffer)
|
||||
return text
|
||||
}
|
||||
await sleep(POLL_INTERVAL)
|
||||
attempt++
|
||||
}
|
||||
throw new Error(`${file} not found`)
|
||||
}
|
39
server-ce/test/helpers/waitUntilScrollingFinished.ts
Normal file
39
server-ce/test/helpers/waitUntilScrollingFinished.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export function waitUntilScrollingFinished(selector: string, start = -1) {
|
||||
const pollSlow = 100
|
||||
const pollFast = 10
|
||||
const deadline =
|
||||
performance.now() + Cypress.config('defaultCommandTimeout') - pollSlow * 2
|
||||
return cy.get(selector).then(el => {
|
||||
cy.log(
|
||||
`waiting until scrolling finished for ${selector}, starting from ${start}`
|
||||
)
|
||||
return new Cypress.Promise<number>((resolve, reject) => {
|
||||
const waitForStable = (prev: number, stableFor: number) => {
|
||||
if (performance.now() > deadline) {
|
||||
return reject(new Error('timeout waiting for scrolling to finish'))
|
||||
}
|
||||
const current = el.scrollTop()!
|
||||
if (current !== prev) {
|
||||
setTimeout(() => waitForStable(current, 0), pollFast)
|
||||
} else if (stableFor < 5) {
|
||||
setTimeout(() => waitForStable(current, stableFor + 1), pollFast)
|
||||
} else {
|
||||
resolve(current)
|
||||
}
|
||||
}
|
||||
|
||||
const waitForChange = () => {
|
||||
if (performance.now() > deadline) {
|
||||
return reject(new Error('timeout waiting for scrolling to start'))
|
||||
}
|
||||
const current = el.scrollTop()!
|
||||
if (current === start) {
|
||||
setTimeout(() => waitForChange(), pollSlow)
|
||||
} else {
|
||||
setTimeout(() => waitForStable(current, 0), pollFast)
|
||||
}
|
||||
}
|
||||
waitForChange()
|
||||
})
|
||||
})
|
||||
}
|
124
server-ce/test/history.spec.ts
Normal file
124
server-ce/test/history.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createProject } from './helpers/project'
|
||||
import { throttledRecompile } from './helpers/compile'
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
|
||||
describe('History', function () {
|
||||
if (isExcludedBySharding('CE_DEFAULT')) return
|
||||
startWith({})
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
beforeEach(function () {
|
||||
login('user@example.com')
|
||||
})
|
||||
|
||||
function addLabel(name: string) {
|
||||
cy.log(`add label ${JSON.stringify(name)}`)
|
||||
cy.findByText('Labels').click()
|
||||
cy.findAllByTestId('history-version-details')
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.get('button').click() // TODO: add test-id or aria-label
|
||||
cy.findByText('Label this version').click()
|
||||
})
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.get('input[placeholder="New label name"]').type(`${name}{enter}`)
|
||||
})
|
||||
}
|
||||
|
||||
function downloadVersion(name: string) {
|
||||
cy.log(`download version ${JSON.stringify(name)}`)
|
||||
cy.findByText('Labels').click()
|
||||
cy.findByText(name)
|
||||
.closest('[data-testid="history-version-details"]')
|
||||
.within(() => {
|
||||
cy.get('.history-version-dropdown-menu-btn').click()
|
||||
cy.findByText('Download this version').click()
|
||||
})
|
||||
}
|
||||
|
||||
const CLASS_ADDITION = 'ol-cm-addition-marker'
|
||||
const CLASS_DELETION = 'ol-cm-deletion-marker'
|
||||
|
||||
it('should support labels, comparison and download', () => {
|
||||
createProject('labels')
|
||||
const recompile = throttledRecompile()
|
||||
|
||||
cy.log('add content, including a line that will get removed soon')
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle').parent().type('\n% added')
|
||||
cy.findByText('\\maketitle').parent().type('\n% to be removed')
|
||||
recompile()
|
||||
cy.findByText('History').click()
|
||||
|
||||
cy.log('expect to see additions in history')
|
||||
cy.get('.document-diff-container').within(() => {
|
||||
cy.findByText('% to be removed').should('have.class', CLASS_ADDITION)
|
||||
cy.findByText('% added').should('have.class', CLASS_ADDITION)
|
||||
})
|
||||
|
||||
addLabel('Before removal')
|
||||
|
||||
cy.log('remove content')
|
||||
cy.findByText('Back to editor').click()
|
||||
cy.findByText('% to be removed').parent().type('{end}{shift}{upArrow}{del}')
|
||||
recompile()
|
||||
cy.findByText('History').click()
|
||||
|
||||
cy.log('expect to see annotation for newly removed content in history')
|
||||
cy.get('.document-diff-container').within(() => {
|
||||
cy.findByText('% to be removed').should('have.class', CLASS_DELETION)
|
||||
cy.findByText('% added').should('not.have.class', CLASS_ADDITION)
|
||||
})
|
||||
|
||||
addLabel('After removal')
|
||||
|
||||
cy.log('add more content after labeling')
|
||||
cy.findByText('Back to editor').click()
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle').parent().type('\n% more')
|
||||
recompile()
|
||||
|
||||
cy.log('compare non current versions')
|
||||
cy.findByText('History').click()
|
||||
cy.findByText('Labels').click()
|
||||
cy.findAllByTestId('compare-icon-version').last().click()
|
||||
cy.findAllByTestId('compare-icon-version').filter(':visible').click()
|
||||
cy.findByText('Compare up to this version').click()
|
||||
|
||||
cy.log(
|
||||
'expect to see annotation for removed content between the two versions'
|
||||
)
|
||||
cy.get('.document-diff-container').within(() => {
|
||||
cy.findByText('% to be removed').should('have.class', CLASS_DELETION)
|
||||
cy.findByText('% added').should('not.have.class', CLASS_ADDITION)
|
||||
cy.findByText('% more').should('not.exist')
|
||||
})
|
||||
|
||||
downloadVersion('Before removal')
|
||||
cy.task('readFileInZip', {
|
||||
pathToZip: `cypress/downloads/labels (Version 2).zip`,
|
||||
fileToRead: 'main.tex',
|
||||
})
|
||||
.should('contain', '% added')
|
||||
.should('contain', '% to be removed')
|
||||
.should('not.contain', '% more')
|
||||
|
||||
downloadVersion('After removal')
|
||||
cy.task('readFileInZip', {
|
||||
pathToZip: `cypress/downloads/labels (Version 3).zip`,
|
||||
fileToRead: 'main.tex',
|
||||
})
|
||||
.should('contain', '% added')
|
||||
.should('not.contain', '% to be removed')
|
||||
.should('not.contain', '% more')
|
||||
|
||||
downloadVersion('Current state')
|
||||
cy.task('readFileInZip', {
|
||||
pathToZip: `cypress/downloads/labels (Version 4).zip`,
|
||||
fileToRead: 'main.tex',
|
||||
})
|
||||
.should('contain', '% added')
|
||||
.should('not.contain', '% to be removed')
|
||||
.should('contain', '% more')
|
||||
})
|
||||
})
|
331
server-ce/test/host-admin.js
Normal file
331
server-ce/test/host-admin.js
Normal file
@@ -0,0 +1,331 @@
|
||||
const fs = require('fs')
|
||||
const Path = require('path')
|
||||
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 DATA_DIR = Path.join(
|
||||
__dirname,
|
||||
'data',
|
||||
// Give each shard their own data dir.
|
||||
process.env.CYPRESS_SHARD || 'default'
|
||||
)
|
||||
const PATHS = {
|
||||
DOCKER_COMPOSE_FILE: 'docker-compose.yml',
|
||||
// Give each shard their own override file.
|
||||
DOCKER_COMPOSE_OVERRIDE: `docker-compose.${process.env.CYPRESS_SHARD || 'override'}.yml`,
|
||||
DOCKER_COMPOSE_NATIVE: 'docker-compose.native.yml',
|
||||
DATA_DIR,
|
||||
SANDBOXED_COMPILES_HOST_DIR: Path.join(DATA_DIR, 'compiles'),
|
||||
}
|
||||
const IMAGES = {
|
||||
CE: process.env.IMAGE_TAG_CE.replace(/:.+/, ''),
|
||||
PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''),
|
||||
}
|
||||
|
||||
let previousConfig = ''
|
||||
|
||||
function readDockerComposeOverride() {
|
||||
try {
|
||||
return YAML.load(fs.readFileSync(PATHS.DOCKER_COMPOSE_OVERRIDE, 'utf-8'))
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
services: {
|
||||
sharelatex: {
|
||||
environment: {},
|
||||
},
|
||||
'git-bridge': {},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeDockerComposeOverride(cfg) {
|
||||
fs.writeFileSync(PATHS.DOCKER_COMPOSE_OVERRIDE, YAML.dump(cfg))
|
||||
}
|
||||
|
||||
function runDockerCompose(command, args, callback) {
|
||||
const files = ['-f', PATHS.DOCKER_COMPOSE_FILE]
|
||||
if (process.env.NATIVE_CYPRESS) {
|
||||
files.push('-f', PATHS.DOCKER_COMPOSE_NATIVE)
|
||||
}
|
||||
if (fs.existsSync(PATHS.DOCKER_COMPOSE_OVERRIDE)) {
|
||||
files.push('-f', PATHS.DOCKER_COMPOSE_OVERRIDE)
|
||||
}
|
||||
execFile('docker', ['compose', ...files, command, ...args], callback)
|
||||
}
|
||||
|
||||
function purgeDataDir() {
|
||||
fs.rmSync(PATHS.DATA_DIR, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
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
|
||||
const accessControlAllowOrigin =
|
||||
process.env.ACCESS_CONTROL_ALLOW_ORIGIN || 'http://sharelatex'
|
||||
res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||
res.setHeader('Access-Control-Max-Age', '3600')
|
||||
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
|
||||
|
||||
runDockerCompose(
|
||||
'exec',
|
||||
[
|
||||
'sharelatex',
|
||||
'bash',
|
||||
'-c',
|
||||
`source /etc/container_environment.sh && source /etc/overleaf/env.sh || source /etc/sharelatex/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,
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const allowedVars = Joi.object(
|
||||
Object.fromEntries(
|
||||
[
|
||||
'OVERLEAF_APP_NAME',
|
||||
'OVERLEAF_LEFT_FOOTER',
|
||||
'OVERLEAF_RIGHT_FOOTER',
|
||||
'OVERLEAF_PROXY_LEARN',
|
||||
'GIT_BRIDGE_ENABLED',
|
||||
'GIT_BRIDGE_HOST',
|
||||
'GIT_BRIDGE_PORT',
|
||||
'V1_HISTORY_URL',
|
||||
'DOCKER_RUNNER',
|
||||
'SANDBOXED_COMPILES',
|
||||
'SANDBOXED_COMPILES_SIBLING_CONTAINERS',
|
||||
'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES',
|
||||
'OVERLEAF_TEMPLATES_USER_ID',
|
||||
'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS',
|
||||
'OVERLEAF_ALLOW_PUBLIC_ACCESS',
|
||||
'OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING',
|
||||
'EXTERNAL_AUTH',
|
||||
'OVERLEAF_SAML_ENTRYPOINT',
|
||||
'OVERLEAF_SAML_CALLBACK_URL',
|
||||
'OVERLEAF_SAML_ISSUER',
|
||||
'OVERLEAF_SAML_IDENTITY_SERVICE_NAME',
|
||||
'OVERLEAF_SAML_EMAIL_FIELD',
|
||||
'OVERLEAF_SAML_FIRST_NAME_FIELD',
|
||||
'OVERLEAF_SAML_LAST_NAME_FIELD',
|
||||
'OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN',
|
||||
'OVERLEAF_SAML_CERT',
|
||||
'OVERLEAF_LDAP_URL',
|
||||
'OVERLEAF_LDAP_SEARCH_BASE',
|
||||
'OVERLEAF_LDAP_SEARCH_FILTER',
|
||||
'OVERLEAF_LDAP_BIND_DN',
|
||||
'OVERLEAF_LDAP_BIND_CREDENTIALS',
|
||||
'OVERLEAF_LDAP_EMAIL_ATT',
|
||||
'OVERLEAF_LDAP_NAME_ATT',
|
||||
'OVERLEAF_LDAP_LAST_NAME_ATT',
|
||||
'OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN',
|
||||
// Old branding, used for upgrade tests
|
||||
'SHARELATEX_SITE_URL',
|
||||
'SHARELATEX_MONGO_URL',
|
||||
'SHARELATEX_REDIS_HOST',
|
||||
].map(name => [name, Joi.string()])
|
||||
)
|
||||
)
|
||||
|
||||
function setVarsDockerCompose({ pro, vars, version, withDataDir }) {
|
||||
const cfg = readDockerComposeOverride()
|
||||
|
||||
cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version}`
|
||||
cfg.services['git-bridge'].image = `quay.io/sharelatex/git-bridge:${version}`
|
||||
|
||||
cfg.services.sharelatex.environment = vars
|
||||
|
||||
if (cfg.services.sharelatex.environment.GIT_BRIDGE_ENABLED === 'true') {
|
||||
cfg.services.sharelatex.depends_on = ['git-bridge']
|
||||
} else {
|
||||
cfg.services.sharelatex.depends_on = []
|
||||
}
|
||||
|
||||
if (['ldap', 'saml'].includes(vars.EXTERNAL_AUTH)) {
|
||||
cfg.services.sharelatex.depends_on.push(vars.EXTERNAL_AUTH)
|
||||
}
|
||||
|
||||
const dataDirInContainer =
|
||||
version === 'latest' || version >= '5.0'
|
||||
? '/var/lib/overleaf/data'
|
||||
: '/var/lib/sharelatex/data'
|
||||
|
||||
cfg.services.sharelatex.volumes = []
|
||||
if (withDataDir) {
|
||||
cfg.services.sharelatex.volumes.push(
|
||||
`${PATHS.DATA_DIR}:${dataDirInContainer}`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
cfg.services.sharelatex.environment
|
||||
.SANDBOXED_COMPILES_SIBLING_CONTAINERS === 'true'
|
||||
) {
|
||||
cfg.services.sharelatex.environment.SANDBOXED_COMPILES_HOST_DIR =
|
||||
PATHS.SANDBOXED_COMPILES_HOST_DIR
|
||||
cfg.services.sharelatex.environment.TEX_LIVE_DOCKER_IMAGE =
|
||||
process.env.TEX_LIVE_DOCKER_IMAGE
|
||||
cfg.services.sharelatex.environment.ALL_TEX_LIVE_DOCKER_IMAGES =
|
||||
process.env.ALL_TEX_LIVE_DOCKER_IMAGES
|
||||
cfg.services.sharelatex.volumes.push(
|
||||
'/var/run/docker.sock:/var/run/docker.sock'
|
||||
)
|
||||
if (!withDataDir) {
|
||||
cfg.services.sharelatex.volumes.push(
|
||||
`${PATHS.SANDBOXED_COMPILES_HOST_DIR}:${dataDirInContainer}/compiles`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
writeDockerComposeOverride(cfg)
|
||||
}
|
||||
|
||||
app.post(
|
||||
'/docker/compose/:cmd',
|
||||
validate(
|
||||
{
|
||||
body: {
|
||||
args: Joi.array().allow(
|
||||
'--detach',
|
||||
'--wait',
|
||||
'--volumes',
|
||||
'--timeout=60',
|
||||
'sharelatex',
|
||||
'git-bridge',
|
||||
'mongo',
|
||||
'redis'
|
||||
),
|
||||
},
|
||||
params: {
|
||||
cmd: Joi.allow('up', 'stop', 'down', 'ps', 'logs'),
|
||||
},
|
||||
},
|
||||
{ allowUnknown: false }
|
||||
),
|
||||
(req, res) => {
|
||||
const { cmd } = req.params
|
||||
const { args } = req.body
|
||||
runDockerCompose(cmd, args, (error, stdout, stderr) => {
|
||||
res.json({ error, stdout, stderr })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
function maybeResetData(resetData, callback) {
|
||||
if (!resetData) return callback()
|
||||
|
||||
previousConfig = ''
|
||||
runDockerCompose(
|
||||
'down',
|
||||
['--timeout=0', '--volumes', 'mongo', 'redis', 'sharelatex'],
|
||||
(error, stdout, stderr) => {
|
||||
if (error) return callback(error, stdout, stderr)
|
||||
|
||||
try {
|
||||
purgeDataDir()
|
||||
} catch (error) {
|
||||
return callback(error)
|
||||
}
|
||||
callback()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
app.post(
|
||||
'/reconfigure',
|
||||
validate(
|
||||
{
|
||||
body: {
|
||||
pro: Joi.boolean().required(),
|
||||
version: Joi.string().required(),
|
||||
vars: allowedVars,
|
||||
withDataDir: Joi.boolean().optional(),
|
||||
resetData: Joi.boolean().optional(),
|
||||
},
|
||||
},
|
||||
{ allowUnknown: false }
|
||||
),
|
||||
(req, res) => {
|
||||
const { pro, version, vars, withDataDir, resetData } = req.body
|
||||
maybeResetData(resetData, (error, stdout, stderr) => {
|
||||
if (error) return res.json({ error, stdout, stderr })
|
||||
|
||||
const previousConfigServer = previousConfig
|
||||
const newConfig = JSON.stringify(req.body)
|
||||
if (previousConfig === newConfig) {
|
||||
return res.json({ previousConfigServer })
|
||||
}
|
||||
|
||||
try {
|
||||
setVarsDockerCompose({ pro, version, vars, withDataDir })
|
||||
} catch (error) {
|
||||
return res.json({ error })
|
||||
}
|
||||
|
||||
if (error) return res.json({ error, stdout, stderr })
|
||||
runDockerCompose(
|
||||
'up',
|
||||
['--detach', '--wait', 'sharelatex'],
|
||||
(error, stdout, stderr) => {
|
||||
previousConfig = newConfig
|
||||
res.json({ error, stdout, stderr, previousConfigServer })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
app.get('/redis/keys', (req, res) => {
|
||||
runDockerCompose(
|
||||
'exec',
|
||||
['redis', 'redis-cli', 'KEYS', '*'],
|
||||
(error, stdout, stderr) => {
|
||||
res.json({ error, stdout, stderr })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
app.use(handleValidationErrors())
|
||||
|
||||
purgeDataDir()
|
||||
|
||||
app.listen(80)
|
106
server-ce/test/learn-wiki.spec.ts
Normal file
106
server-ce/test/learn-wiki.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
describe('LearnWiki', function () {
|
||||
const COPYING_A_PROJECT_URL = '/learn/how-to/Copying_a_project'
|
||||
const UPLOADING_A_PROJECT_URL = '/learn/how-to/Uploading_a_project'
|
||||
|
||||
const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
|
||||
const REGULAR_USER = 'user@example.com'
|
||||
|
||||
// Re-use value for "exists" and "does not exist" tests
|
||||
const LABEL_LEARN_LATEX = 'Learn LaTeX with a tutorial'
|
||||
|
||||
ensureUserExists({ email: WITHOUT_PROJECTS_USER })
|
||||
ensureUserExists({ email: REGULAR_USER })
|
||||
|
||||
describe('enabled in Pro', () => {
|
||||
if (isExcludedBySharding('PRO_CUSTOM_2')) return
|
||||
startWith({
|
||||
pro: true,
|
||||
vars: {
|
||||
OVERLEAF_PROXY_LEARN: 'true',
|
||||
},
|
||||
})
|
||||
|
||||
it('should add a documentation entry to the nav bar', () => {
|
||||
login(REGULAR_USER)
|
||||
cy.visit('/project')
|
||||
cy.get('nav').findByText('Documentation')
|
||||
})
|
||||
|
||||
it('should display a tutorial link in the welcome page', () => {
|
||||
login(WITHOUT_PROJECTS_USER)
|
||||
cy.visit('/project')
|
||||
cy.findByText(LABEL_LEARN_LATEX)
|
||||
})
|
||||
|
||||
it('should render wiki page', () => {
|
||||
login(REGULAR_USER)
|
||||
cy.visit(UPLOADING_A_PROJECT_URL)
|
||||
// Wiki content
|
||||
cy.get('.page').findByText('Uploading a project')
|
||||
cy.get('.page').contains(/how to create an Overleaf project/)
|
||||
cy.get('img[alt="Creating a new project on Overleaf"]')
|
||||
.should('be.visible')
|
||||
.and((el: any) => {
|
||||
expect(el[0].naturalWidth, 'renders image').to.be.greaterThan(0)
|
||||
})
|
||||
// Wiki navigation
|
||||
cy.get('.contents').findByText('Copying a project')
|
||||
})
|
||||
|
||||
it('should navigate back and forth', function () {
|
||||
login(REGULAR_USER)
|
||||
cy.visit(COPYING_A_PROJECT_URL)
|
||||
cy.get('.page').findByText('Copying a project')
|
||||
cy.get('.contents').findByText('Uploading a project').click()
|
||||
cy.url().should('contain', UPLOADING_A_PROJECT_URL)
|
||||
cy.get('.page').findByText('Uploading a project')
|
||||
cy.get('.contents').findByText('Copying a project').click()
|
||||
cy.url().should('contain', COPYING_A_PROJECT_URL)
|
||||
cy.get('.page').findByText('Copying a project')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled in Pro', () => {
|
||||
if (isExcludedBySharding('PRO_DEFAULT_1')) return
|
||||
startWith({ pro: true })
|
||||
checkDisabled()
|
||||
})
|
||||
|
||||
describe('unavailable in CE', () => {
|
||||
if (isExcludedBySharding('CE_CUSTOM_1')) return
|
||||
startWith({
|
||||
pro: false,
|
||||
vars: {
|
||||
OVERLEAF_PROXY_LEARN: 'true',
|
||||
},
|
||||
})
|
||||
|
||||
checkDisabled()
|
||||
})
|
||||
|
||||
function checkDisabled() {
|
||||
it('should not add a documentation entry to the nav bar', () => {
|
||||
login(REGULAR_USER)
|
||||
cy.visit('/project')
|
||||
cy.findByText('Documentation').should('not.exist')
|
||||
})
|
||||
|
||||
it('should not render wiki page', () => {
|
||||
login(REGULAR_USER)
|
||||
cy.visit(COPYING_A_PROJECT_URL, {
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
cy.findByText('Not found')
|
||||
})
|
||||
|
||||
it('should not display a tutorial link in the welcome page', () => {
|
||||
login(WITHOUT_PROJECTS_USER)
|
||||
cy.visit('/project')
|
||||
cy.findByText(LABEL_LEARN_LATEX).should('not.exist')
|
||||
})
|
||||
}
|
||||
})
|
3538
server-ce/test/package-lock.json
generated
Normal file
3538
server-ce/test/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server-ce/test/package.json
Normal file
28
server-ce/test/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@overleaf/server-ce/test",
|
||||
"description": "e2e tests for Overleaf Community Edition",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"cypress:open": "cypress open --e2e --browser chrome",
|
||||
"cypress:run": "cypress run --e2e --browser chrome",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,mjs,ts,tsx,json}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,mjs,ts,tsx,json}'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@isomorphic-git/lightning-fs": "^4.6.0",
|
||||
"@testing-library/cypress": "^10.0.1",
|
||||
"@types/adm-zip": "^0.5.5",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"adm-zip": "^0.5.12",
|
||||
"body-parser": "^1.20.3",
|
||||
"celebrate": "^15.0.3",
|
||||
"cypress": "13.13.2",
|
||||
"express": "^4.21.2",
|
||||
"isomorphic-git": "^1.25.10",
|
||||
"js-yaml": "^4.1.0",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"typescript": "^5.0.4",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
106
server-ce/test/project-list.spec.ts
Normal file
106
server-ce/test/project-list.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import { createProject } from './helpers/project'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
|
||||
const REGULAR_USER = 'user@example.com'
|
||||
|
||||
describe('Project List', () => {
|
||||
if (isExcludedBySharding('PRO_DEFAULT_2')) return
|
||||
startWith({ pro: true })
|
||||
|
||||
const findProjectRow = (projectName: string) => {
|
||||
cy.log('find project row')
|
||||
return cy.findByText(projectName).parent().parent()
|
||||
}
|
||||
|
||||
describe('user with no projects', () => {
|
||||
ensureUserExists({ email: WITHOUT_PROJECTS_USER })
|
||||
|
||||
it("'Import from GitHub' is not displayed in the welcome page", () => {
|
||||
login(WITHOUT_PROJECTS_USER)
|
||||
cy.visit('/project')
|
||||
cy.findByText('Create a new project').click()
|
||||
cy.findByText(/Import from GitHub/i).should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user with projects', () => {
|
||||
const projectName = `test-project-${uuid()}`
|
||||
ensureUserExists({ email: REGULAR_USER })
|
||||
|
||||
before(() => {
|
||||
login(REGULAR_USER)
|
||||
createProject(projectName, { type: 'Example Project', open: false })
|
||||
})
|
||||
beforeEach(function () {
|
||||
login(REGULAR_USER)
|
||||
cy.visit('/project')
|
||||
})
|
||||
|
||||
it('Can download project sources', () => {
|
||||
findProjectRow(projectName).within(() =>
|
||||
cy.findByRole('button', { name: 'Download .zip file' }).click()
|
||||
)
|
||||
|
||||
cy.task('readFileInZip', {
|
||||
pathToZip: `cypress/downloads/${projectName}.zip`,
|
||||
fileToRead: 'main.tex',
|
||||
}).should('contain', 'Your introduction goes here')
|
||||
})
|
||||
|
||||
it('Can download project PDF', () => {
|
||||
findProjectRow(projectName).within(() =>
|
||||
cy.findByRole('button', { name: 'Download PDF' }).click()
|
||||
)
|
||||
|
||||
const pdfName = projectName.replaceAll('-', '_')
|
||||
cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should(
|
||||
'contain',
|
||||
'Your introduction goes here'
|
||||
)
|
||||
})
|
||||
|
||||
it('can assign and remove tags to projects', () => {
|
||||
const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors
|
||||
cy.log('select project')
|
||||
cy.get(`[aria-label="Select ${projectName}"]`).click()
|
||||
|
||||
cy.log('add tag to project')
|
||||
cy.get('button[aria-label="Tags"]').click()
|
||||
cy.findByText('Create new tag').click()
|
||||
cy.get('input[name="new-tag-form-name"]').type(`${tagName}{enter}`)
|
||||
cy.get(`button[aria-label="Select tag ${tagName}"]`) // tag label in project row
|
||||
|
||||
cy.log('remove tag')
|
||||
cy.get(`button[aria-label="Remove tag ${tagName}"]`)
|
||||
.first()
|
||||
.click({ force: true })
|
||||
cy.get(`button[aria-label="Select tag ${tagName}"]`).should('not.exist')
|
||||
})
|
||||
|
||||
it('can filter by tag', () => {
|
||||
cy.log('create a separate project to filter')
|
||||
const nonTaggedProjectName = `project-${uuid()}`
|
||||
login(REGULAR_USER)
|
||||
createProject(nonTaggedProjectName, { open: false })
|
||||
|
||||
cy.log('select project')
|
||||
cy.get(`[aria-label="Select ${projectName}"]`).click()
|
||||
|
||||
cy.log('add tag to project')
|
||||
const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors
|
||||
cy.get('button[aria-label="Tags"]').click()
|
||||
cy.findByText('Create new tag').click()
|
||||
cy.get('input[name="new-tag-form-name"]').type(`${tagName}{enter}`)
|
||||
|
||||
cy.log(
|
||||
'check the non-tagged project is filtered out after clicking the tag'
|
||||
)
|
||||
cy.findByText(nonTaggedProjectName).should('exist')
|
||||
cy.get('button').contains(tagName).click({ force: true })
|
||||
cy.findByText(nonTaggedProjectName).should('not.exist')
|
||||
})
|
||||
})
|
||||
})
|
305
server-ce/test/project-sharing.spec.ts
Normal file
305
server-ce/test/project-sharing.spec.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import {
|
||||
createProject,
|
||||
enableLinkSharing,
|
||||
openProjectByName,
|
||||
openProjectViaLinkSharingAsAnon,
|
||||
openProjectViaLinkSharingAsUser,
|
||||
shareProjectByEmailAndAcceptInviteViaDash,
|
||||
shareProjectByEmailAndAcceptInviteViaEmail,
|
||||
} from './helpers/project'
|
||||
import { throttledRecompile } from './helpers/compile'
|
||||
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
|
||||
|
||||
describe('Project Sharing', function () {
|
||||
if (isExcludedBySharding('CE_CUSTOM_2')) return
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
startWith({ withDataDir: true })
|
||||
|
||||
let projectName: string
|
||||
beforeWithReRunOnTestRetry(function () {
|
||||
projectName = `Project ${uuid()}`
|
||||
setupTestProject()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Always start with a fresh session
|
||||
cy.session([uuid()], () => {})
|
||||
})
|
||||
|
||||
let linkSharingReadOnly: string
|
||||
let linkSharingReadAndWrite: string
|
||||
|
||||
function setupTestProject() {
|
||||
login('user@example.com')
|
||||
createProject(projectName)
|
||||
|
||||
// Add chat message
|
||||
cy.findByText('Chat').click()
|
||||
// wait for lazy loading of the chat pane
|
||||
cy.findByText('Send your first message to your collaborators')
|
||||
cy.get(
|
||||
'textarea[placeholder="Send a message to your collaborators…"]'
|
||||
).type('New Chat Message{enter}')
|
||||
|
||||
// Get link sharing links
|
||||
enableLinkSharing().then(
|
||||
({ linkSharingReadOnly: ro, linkSharingReadAndWrite: rw }) => {
|
||||
linkSharingReadAndWrite = rw
|
||||
linkSharingReadOnly = ro
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function expectContentReadOnlyAccess() {
|
||||
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
|
||||
cy.get('.cm-content').should('contain.text', '\\maketitle')
|
||||
cy.get('.cm-content').should('have.attr', 'contenteditable', 'false')
|
||||
}
|
||||
|
||||
function expectContentWriteAccess() {
|
||||
const section = `Test Section ${uuid()}`
|
||||
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
|
||||
const recompile = throttledRecompile()
|
||||
// wait for the editor to finish loading
|
||||
cy.get('.cm-content').should('contain.text', '\\maketitle')
|
||||
// the editor should be writable
|
||||
cy.get('.cm-content').should('have.attr', 'contenteditable', 'true')
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle').parent().type(`\n\\section{{}${section}}`)
|
||||
// should have written
|
||||
cy.get('.cm-content').should('contain.text', `\\section{${section}}`)
|
||||
// check PDF
|
||||
recompile()
|
||||
cy.get('.pdf-viewer').should('contain.text', projectName)
|
||||
cy.get('.pdf-viewer').should('contain.text', section)
|
||||
}
|
||||
|
||||
function expectNoAccess() {
|
||||
// try read only access link
|
||||
cy.visit(linkSharingReadOnly)
|
||||
cy.url().should('match', /\/login/)
|
||||
|
||||
// Cypress bugs: cypress resolves the link-sharing link outside the browser, and it carries over the hash of the link-sharing link to the login page redirect (bug 1).
|
||||
// Effectively, cypress then instructs the browser to change the page from /login#read-only-hash to /login#read-and-write-hash.
|
||||
// This is turn does not trigger a "page load", but rather just "scrolling", which in turn trips up the "page loaded" detection in cypress (bug 2).
|
||||
// Work around this by navigating away from the /login page in between checks.
|
||||
cy.visit('/user/password/reset')
|
||||
|
||||
// try read and write access link
|
||||
cy.visit(linkSharingReadAndWrite)
|
||||
cy.url().should('match', /\/login/)
|
||||
}
|
||||
|
||||
function expectChatAccess() {
|
||||
cy.findByText('Chat').click()
|
||||
cy.findByText('New Chat Message')
|
||||
}
|
||||
|
||||
function expectHistoryAccess() {
|
||||
cy.findByText('History').click()
|
||||
cy.findByText('Labels')
|
||||
cy.findByText(/\\begin\{document}/)
|
||||
cy.findAllByTestId('history-version-metadata-users')
|
||||
.last()
|
||||
.should('have.text', 'user')
|
||||
cy.findByText('Back to editor').click()
|
||||
}
|
||||
|
||||
function expectNoChatAccess() {
|
||||
cy.findByText('Layout') // wait for lazy loading
|
||||
cy.findByText('Chat').should('not.exist')
|
||||
}
|
||||
|
||||
function expectNoHistoryAccess() {
|
||||
cy.findByText('Layout') // wait for lazy loading
|
||||
cy.findByText('History').should('not.exist')
|
||||
}
|
||||
|
||||
function expectFullReadOnlyAccess() {
|
||||
expectContentReadOnlyAccess()
|
||||
expectChatAccess()
|
||||
expectHistoryAccess()
|
||||
}
|
||||
|
||||
function expectRestrictedReadOnlyAccess() {
|
||||
expectContentReadOnlyAccess()
|
||||
expectNoChatAccess()
|
||||
expectNoHistoryAccess()
|
||||
}
|
||||
|
||||
function expectReadAndWriteAccess() {
|
||||
expectContentWriteAccess()
|
||||
expectChatAccess()
|
||||
expectHistoryAccess()
|
||||
}
|
||||
|
||||
function expectProjectDashboardEntry() {
|
||||
cy.visit('/project')
|
||||
cy.findByText(projectName)
|
||||
}
|
||||
|
||||
function expectEditAuthoredAs(author: string) {
|
||||
cy.findByText('History').click()
|
||||
cy.findAllByTestId('history-version-metadata-users')
|
||||
.first()
|
||||
.should('contain.text', author) // might have other edits in the same group
|
||||
}
|
||||
|
||||
describe('via email', function () {
|
||||
const email = 'collaborator-email@example.com'
|
||||
ensureUserExists({ email })
|
||||
|
||||
beforeEach(function () {
|
||||
login('user@example.com')
|
||||
shareProjectByEmailAndAcceptInviteViaEmail(projectName, email, 'Viewer')
|
||||
})
|
||||
|
||||
it('should grant the collaborator read access', () => {
|
||||
expectFullReadOnlyAccess()
|
||||
expectProjectDashboardEntry()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read only', () => {
|
||||
const email = 'collaborator-ro@example.com'
|
||||
ensureUserExists({ email })
|
||||
|
||||
beforeWithReRunOnTestRetry(function () {
|
||||
login('user@example.com')
|
||||
shareProjectByEmailAndAcceptInviteViaDash(projectName, email, 'Viewer')
|
||||
})
|
||||
|
||||
it('should grant the collaborator read access', () => {
|
||||
login(email)
|
||||
openProjectByName(projectName)
|
||||
expectFullReadOnlyAccess()
|
||||
expectProjectDashboardEntry()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read and write', () => {
|
||||
const email = 'collaborator-rw@example.com'
|
||||
ensureUserExists({ email })
|
||||
|
||||
beforeWithReRunOnTestRetry(function () {
|
||||
login('user@example.com')
|
||||
shareProjectByEmailAndAcceptInviteViaDash(projectName, email, 'Editor')
|
||||
})
|
||||
|
||||
it('should grant the collaborator write access', () => {
|
||||
login(email)
|
||||
openProjectByName(projectName)
|
||||
expectReadAndWriteAccess()
|
||||
expectEditAuthoredAs('You')
|
||||
expectProjectDashboardEntry()
|
||||
})
|
||||
})
|
||||
|
||||
describe('token access', () => {
|
||||
describe('logged in', () => {
|
||||
describe('read only', () => {
|
||||
const email = 'collaborator-link-ro@example.com'
|
||||
ensureUserExists({ email })
|
||||
|
||||
it('should grant restricted read access', () => {
|
||||
login(email)
|
||||
openProjectViaLinkSharingAsUser(
|
||||
linkSharingReadOnly,
|
||||
projectName,
|
||||
email
|
||||
)
|
||||
expectRestrictedReadOnlyAccess()
|
||||
expectProjectDashboardEntry()
|
||||
})
|
||||
})
|
||||
|
||||
describe('read and write', () => {
|
||||
const email = 'collaborator-link-rw@example.com'
|
||||
ensureUserExists({ email })
|
||||
|
||||
it('should grant full write access', () => {
|
||||
login(email)
|
||||
openProjectViaLinkSharingAsUser(
|
||||
linkSharingReadAndWrite,
|
||||
projectName,
|
||||
email
|
||||
)
|
||||
expectReadAndWriteAccess()
|
||||
expectEditAuthoredAs('You')
|
||||
expectProjectDashboardEntry()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=false', () => {
|
||||
describe('wrap startup', () => {
|
||||
startWith({
|
||||
vars: {
|
||||
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
|
||||
},
|
||||
withDataDir: true,
|
||||
})
|
||||
it('should block access', () => {
|
||||
expectNoAccess()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', () => {
|
||||
startWith({
|
||||
vars: {
|
||||
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
|
||||
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
|
||||
},
|
||||
withDataDir: true,
|
||||
})
|
||||
it('should block access', () => {
|
||||
expectNoAccess()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=true', () => {
|
||||
describe('wrap startup', () => {
|
||||
startWith({
|
||||
vars: {
|
||||
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
|
||||
},
|
||||
withDataDir: true,
|
||||
})
|
||||
it('should grant read access with read link', () => {
|
||||
openProjectViaLinkSharingAsAnon(linkSharingReadOnly)
|
||||
expectRestrictedReadOnlyAccess()
|
||||
})
|
||||
|
||||
it('should prompt for login with write link', () => {
|
||||
cy.visit(linkSharingReadAndWrite)
|
||||
cy.url().should('match', /\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', () => {
|
||||
startWith({
|
||||
vars: {
|
||||
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
|
||||
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
|
||||
},
|
||||
withDataDir: true,
|
||||
})
|
||||
|
||||
it('should grant read access with read link', () => {
|
||||
openProjectViaLinkSharingAsAnon(linkSharingReadOnly)
|
||||
expectRestrictedReadOnlyAccess()
|
||||
})
|
||||
|
||||
it('should grant write access with write link', () => {
|
||||
openProjectViaLinkSharingAsAnon(linkSharingReadAndWrite)
|
||||
expectReadAndWriteAccess()
|
||||
expectEditAuthoredAs('Anonymous')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
245
server-ce/test/sandboxed-compiles.spec.ts
Normal file
245
server-ce/test/sandboxed-compiles.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import { createProject } from './helpers/project'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { throttledRecompile } from './helpers/compile'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { waitUntilScrollingFinished } from './helpers/waitUntilScrollingFinished'
|
||||
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
|
||||
|
||||
const LABEL_TEX_LIVE_VERSION = 'TeX Live version'
|
||||
|
||||
describe('SandboxedCompiles', function () {
|
||||
const enabledVars = {
|
||||
DOCKER_RUNNER: 'true',
|
||||
SANDBOXED_COMPILES: 'true',
|
||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true',
|
||||
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: '2023,2022',
|
||||
}
|
||||
|
||||
describe('enabled in Server Pro', function () {
|
||||
if (isExcludedBySharding('PRO_CUSTOM_2')) return
|
||||
startWith({
|
||||
pro: true,
|
||||
vars: enabledVars,
|
||||
resetData: true,
|
||||
})
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
beforeEach(function () {
|
||||
login('user@example.com')
|
||||
})
|
||||
|
||||
it('should offer TexLive images and switch the compiler', function () {
|
||||
createProject('sandboxed')
|
||||
const recompile = throttledRecompile()
|
||||
cy.log('wait for compile')
|
||||
cy.get('.pdf-viewer').should('contain.text', 'sandboxed')
|
||||
|
||||
cy.log('Check which compiler version was used, expect 2023')
|
||||
cy.get('[aria-label="View logs"]').click()
|
||||
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2023\) /)
|
||||
|
||||
cy.log('Switch TeXLive version from 2023 to 2022')
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText(LABEL_TEX_LIVE_VERSION)
|
||||
.parent()
|
||||
.findByText('2023')
|
||||
.parent()
|
||||
.select('2022')
|
||||
cy.get('.left-menu-modal-backdrop').click()
|
||||
|
||||
cy.log('Trigger compile with other TeX Live version')
|
||||
recompile()
|
||||
|
||||
cy.log('Check which compiler version was used, expect 2022')
|
||||
cy.get('[aria-label="View logs"]').click()
|
||||
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2022\) /)
|
||||
})
|
||||
|
||||
checkSyncTeX()
|
||||
checkXeTeX()
|
||||
checkRecompilesAfterErrors()
|
||||
})
|
||||
|
||||
function checkSyncTeX() {
|
||||
describe('SyncTeX', function () {
|
||||
let projectName: string
|
||||
beforeEach(function () {
|
||||
projectName = `Project ${uuid()}`
|
||||
createProject(projectName)
|
||||
const recompile = throttledRecompile()
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle')
|
||||
.parent()
|
||||
.type(
|
||||
`\n\\pagebreak\n\\section{{}Section A}\n\\pagebreak\n\\section{{}Section B}\n\\pagebreak`
|
||||
)
|
||||
recompile()
|
||||
cy.log('wait for pdf-rendering')
|
||||
cy.get('.pdf-viewer').within(() => {
|
||||
cy.findByText(projectName)
|
||||
})
|
||||
})
|
||||
|
||||
it('should sync to code', function () {
|
||||
cy.log('navigate to \\maketitle using double click in PDF')
|
||||
cy.get('.pdf-viewer').within(() => {
|
||||
cy.findByText(projectName).dblclick()
|
||||
})
|
||||
cy.get('.cm-activeLine').should('have.text', '\\maketitle')
|
||||
|
||||
cy.log('navigate to Section A using double click in PDF')
|
||||
cy.get('.pdf-viewer').within(() => {
|
||||
cy.findByText('Section A').dblclick()
|
||||
})
|
||||
cy.get('.cm-activeLine').should('have.text', '\\section{Section A}')
|
||||
|
||||
cy.log('navigate to Section B using arrow button')
|
||||
cy.get('.pdfjs-viewer-inner')
|
||||
.should('have.prop', 'scrollTop')
|
||||
.as('start')
|
||||
cy.get('.pdf-viewer').within(() => {
|
||||
cy.findByText('Section B').scrollIntoView()
|
||||
})
|
||||
cy.get('@start').then((start: any) => {
|
||||
waitUntilScrollingFinished('.pdfjs-viewer-inner', start)
|
||||
})
|
||||
// The sync button is swapped as the position in the PDF changes.
|
||||
// Cypress appears to click on a button that references a stale position.
|
||||
// Adding a cy.wait() statement is the most reliable "fix" so far :/
|
||||
cy.wait(1000)
|
||||
cy.get('[aria-label^="Go to PDF location in code"]').click()
|
||||
cy.get('.cm-activeLine').should('have.text', '\\section{Section B}')
|
||||
})
|
||||
|
||||
it('should sync to pdf', function () {
|
||||
cy.log('zoom in')
|
||||
cy.findByText('45%').click()
|
||||
cy.findByText('400%').click()
|
||||
cy.log('scroll to top')
|
||||
cy.get('.pdfjs-viewer-inner').scrollTo('top')
|
||||
waitUntilScrollingFinished('.pdfjs-viewer-inner', -1).as('start')
|
||||
|
||||
cy.log('navigate to title')
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.get('[aria-label="Go to code location in PDF"]').click()
|
||||
cy.get('@start').then((start: any) => {
|
||||
waitUntilScrollingFinished('.pdfjs-viewer-inner', start)
|
||||
.as('title')
|
||||
.should('be.greaterThan', start)
|
||||
})
|
||||
|
||||
cy.log('navigate to Section A')
|
||||
cy.get('.cm-content').within(() => cy.findByText('Section A').click())
|
||||
cy.get('[aria-label="Go to code location in PDF"]').click()
|
||||
cy.get('@title').then((title: any) => {
|
||||
waitUntilScrollingFinished('.pdfjs-viewer-inner', title)
|
||||
.as('sectionA')
|
||||
.should('be.greaterThan', title)
|
||||
})
|
||||
|
||||
cy.log('navigate to Section B')
|
||||
cy.get('.cm-content').within(() => cy.findByText('Section B').click())
|
||||
cy.get('[aria-label="Go to code location in PDF"]').click()
|
||||
cy.get('@sectionA').then((title: any) => {
|
||||
waitUntilScrollingFinished('.pdfjs-viewer-inner', title)
|
||||
.as('sectionB')
|
||||
.should('be.greaterThan', title)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function checkRecompilesAfterErrors() {
|
||||
it('recompiles even if there are Latex errors', function () {
|
||||
login('user@example.com')
|
||||
createProject('test-project')
|
||||
const recompile = throttledRecompile()
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle')
|
||||
.parent()
|
||||
.type('\n\\fakeCommand{} \n\\section{{}Test Section}')
|
||||
recompile()
|
||||
recompile()
|
||||
cy.get('.pdf-viewer').should('contain.text', 'Test Section')
|
||||
cy.get('.logs-pane').should('not.contain.text', 'No PDF')
|
||||
})
|
||||
}
|
||||
|
||||
function checkXeTeX() {
|
||||
it('should be able to use XeLaTeX', function () {
|
||||
createProject('XeLaTeX')
|
||||
const recompile = throttledRecompile()
|
||||
cy.log('wait for compile')
|
||||
cy.get('.pdf-viewer').should('contain.text', 'XeLaTeX')
|
||||
|
||||
cy.log('Check which compiler was used, expect pdfLaTeX')
|
||||
cy.get('[aria-label="View logs"]').click()
|
||||
cy.findByText(/This is pdfTeX/)
|
||||
|
||||
cy.log('Switch compiler to from pdfLaTeX to XeLaTeX')
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Compiler')
|
||||
.parent()
|
||||
.findByText('pdfLaTeX')
|
||||
.parent()
|
||||
.select('XeLaTeX')
|
||||
cy.get('.left-menu-modal-backdrop').click()
|
||||
|
||||
cy.log('Trigger compile with other compiler')
|
||||
recompile()
|
||||
|
||||
cy.log('Check which compiler was used, expect XeLaTeX')
|
||||
cy.get('[aria-label="View logs"]').click()
|
||||
cy.findByText(/This is XeTeX/)
|
||||
})
|
||||
}
|
||||
|
||||
function checkUsesDefaultCompiler() {
|
||||
beforeEach(function () {
|
||||
login('user@example.com')
|
||||
})
|
||||
|
||||
it('should not offer TexLive images and use default compiler', function () {
|
||||
createProject('sandboxed')
|
||||
cy.log('wait for compile')
|
||||
cy.get('.pdf-viewer').should('contain.text', 'sandboxed')
|
||||
|
||||
cy.log('Check which compiler version was used, expect 2025')
|
||||
cy.get('[aria-label="View logs"]').click()
|
||||
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2025\) /)
|
||||
|
||||
cy.log('Check that there is no TeX Live version toggle')
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Word Count') // wait for lazy loading
|
||||
cy.findByText(LABEL_TEX_LIVE_VERSION).should('not.exist')
|
||||
})
|
||||
}
|
||||
|
||||
describe('disabled in Server Pro', function () {
|
||||
if (isExcludedBySharding('PRO_DEFAULT_2')) return
|
||||
startWith({ pro: true })
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
beforeEach(function () {
|
||||
login('user@example.com')
|
||||
})
|
||||
|
||||
checkUsesDefaultCompiler()
|
||||
checkSyncTeX()
|
||||
checkXeTeX()
|
||||
checkRecompilesAfterErrors()
|
||||
})
|
||||
|
||||
describe.skip('unavailable in CE', function () {
|
||||
if (isExcludedBySharding('CE_CUSTOM_1')) return
|
||||
startWith({ pro: false, vars: enabledVars, resetData: true })
|
||||
ensureUserExists({ email: 'user@example.com' })
|
||||
beforeEach(function () {
|
||||
login('user@example.com')
|
||||
})
|
||||
|
||||
checkUsesDefaultCompiler()
|
||||
checkSyncTeX()
|
||||
checkXeTeX()
|
||||
checkRecompilesAfterErrors()
|
||||
})
|
||||
})
|
257
server-ce/test/templates.spec.ts
Normal file
257
server-ce/test/templates.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import { createProject } from './helpers/project'
|
||||
|
||||
const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
|
||||
const ADMIN_USER = 'admin@example.com'
|
||||
const REGULAR_USER = 'user@example.com'
|
||||
const TEMPLATES_USER = 'templates@example.com'
|
||||
|
||||
// Re-use value for "exists" and "does not exist" tests
|
||||
const LABEL_BROWSE_TEMPLATES = 'Browse templates'
|
||||
|
||||
describe('Templates', () => {
|
||||
ensureUserExists({ email: TEMPLATES_USER })
|
||||
ensureUserExists({ email: WITHOUT_PROJECTS_USER })
|
||||
|
||||
let OVERLEAF_TEMPLATES_USER_ID: string
|
||||
before(function () {
|
||||
login(TEMPLATES_USER)
|
||||
cy.visit('/')
|
||||
cy.get('meta[name="ol-user_id"]').then(el => {
|
||||
OVERLEAF_TEMPLATES_USER_ID = el.attr('content')!
|
||||
})
|
||||
})
|
||||
|
||||
function varsFn() {
|
||||
return {
|
||||
OVERLEAF_TEMPLATES_USER_ID,
|
||||
OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS:
|
||||
'[{"name":"All Templates","url":"/templates/all"}]',
|
||||
}
|
||||
}
|
||||
|
||||
describe('enabled in Server Pro', () => {
|
||||
if (isExcludedBySharding('PRO_CUSTOM_2')) return
|
||||
startWith({
|
||||
pro: true,
|
||||
varsFn,
|
||||
})
|
||||
ensureUserExists({ email: REGULAR_USER })
|
||||
ensureUserExists({ email: ADMIN_USER, isAdmin: true })
|
||||
|
||||
it('should show templates link on welcome page', () => {
|
||||
login(WITHOUT_PROJECTS_USER)
|
||||
cy.visit('/')
|
||||
cy.findByText(LABEL_BROWSE_TEMPLATES).click()
|
||||
cy.url().should('match', /\/templates$/)
|
||||
})
|
||||
|
||||
it('should have templates feature', () => {
|
||||
login(TEMPLATES_USER)
|
||||
const name = `Template ${Date.now()}`
|
||||
const description = `Template Description ${Date.now()}`
|
||||
|
||||
cy.visit('/')
|
||||
createProject(name).as('templateProjectId')
|
||||
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Manage Template').click()
|
||||
|
||||
cy.findByText('Template Description')
|
||||
.click()
|
||||
.parent()
|
||||
.get('textarea')
|
||||
.type(description)
|
||||
cy.findByText('Publish').click()
|
||||
cy.findByText('Publishing…').parent().should('be.disabled')
|
||||
cy.findByText('Publish').should('not.exist')
|
||||
cy.findByText('Unpublish', { timeout: 10_000 })
|
||||
cy.findByText('Republish')
|
||||
|
||||
cy.findByText('View it in the template gallery').click()
|
||||
cy.url()
|
||||
.should('match', /\/templates\/[a-f0-9]{24}$/)
|
||||
.as('templateURL')
|
||||
|
||||
cy.findAllByText(name).first().should('exist')
|
||||
cy.findByText(description)
|
||||
cy.findByText('Open as Template')
|
||||
cy.findByText('Unpublish')
|
||||
cy.findByText('Republish')
|
||||
cy.get('img')
|
||||
.should('have.attr', 'src')
|
||||
.and('match', /\/v\/0\//)
|
||||
cy.findByText('Republish').click()
|
||||
cy.findByText('Publishing…').parent().should('be.disabled')
|
||||
cy.findByText('Republish', { timeout: 10_000 })
|
||||
cy.get('img', { timeout: 10_000 })
|
||||
.should('have.attr', 'src')
|
||||
.and('match', /\/v\/1\//)
|
||||
|
||||
// custom tag
|
||||
const tagName = `${Date.now()}`
|
||||
cy.visit('/')
|
||||
cy.findByText(name)
|
||||
.parent()
|
||||
.parent()
|
||||
.within(() => cy.get('input[type="checkbox"]').first().check())
|
||||
cy.get('.project-list-sidebar-react').within(() => {
|
||||
cy.findAllByText('New Tag').first().click()
|
||||
})
|
||||
cy.focused().type(tagName)
|
||||
cy.findByText('Create').click()
|
||||
cy.get('.project-list-sidebar-react').within(() => {
|
||||
cy.findByText(tagName)
|
||||
.parent()
|
||||
.within(() => cy.get('.name').should('have.text', `${tagName} (1)`))
|
||||
})
|
||||
|
||||
// Check listing
|
||||
cy.visit('/templates')
|
||||
cy.findByText(tagName)
|
||||
cy.visit('/templates/all')
|
||||
cy.findByText(name)
|
||||
cy.visit(`/templates/${tagName}`)
|
||||
cy.findByText(name)
|
||||
|
||||
// Unpublish via template page
|
||||
cy.get('@templateURL').then(url => cy.visit(`${url}`))
|
||||
cy.findByText('Unpublish').click()
|
||||
cy.url().should('match', /\/templates$/)
|
||||
cy.get('@templateURL').then(url =>
|
||||
cy.visit(`${url}`, {
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
)
|
||||
cy.findByText('Not found')
|
||||
cy.visit('/templates/all')
|
||||
cy.findByText(name).should('not.exist')
|
||||
cy.visit(`/templates/${tagName}`)
|
||||
cy.findByText(name).should('not.exist')
|
||||
|
||||
// Publish again
|
||||
cy.get('@templateProjectId').then(projectId =>
|
||||
cy.visit(`/project/${projectId}`)
|
||||
)
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Manage Template').click()
|
||||
cy.findByText('Publish').click()
|
||||
cy.findByText('Unpublish', { timeout: 10_000 })
|
||||
|
||||
// Should assign a new template id
|
||||
cy.findByText('View it in the template gallery').click()
|
||||
cy.url()
|
||||
.should('match', /\/templates\/[a-f0-9]{24}$/)
|
||||
.as('newTemplateURL')
|
||||
cy.get('@newTemplateURL').then(newURL => {
|
||||
cy.get('@templateURL').then(prevURL => {
|
||||
expect(newURL).to.match(/\/templates\/[a-f0-9]{24}$/)
|
||||
expect(prevURL).to.not.equal(newURL)
|
||||
})
|
||||
})
|
||||
|
||||
// Open project from template
|
||||
login(REGULAR_USER)
|
||||
cy.visit('/templates')
|
||||
cy.findByText(tagName).click()
|
||||
cy.findByText(name).click()
|
||||
cy.findByText('Open as Template').click()
|
||||
cy.url().should('match', /\/project\/[a-f0-9]{24}$/)
|
||||
cy.get('.project-name').findByText(name)
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Word Count') // wait for lazy loading
|
||||
cy.findByText('Manage Template').should('not.exist')
|
||||
|
||||
// Check management as regular user
|
||||
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
|
||||
cy.findByText('Open as Template')
|
||||
cy.findByText('Unpublish').should('not.exist')
|
||||
cy.findByText('Republish').should('not.exist')
|
||||
|
||||
// Check management as admin user
|
||||
login(ADMIN_USER)
|
||||
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
|
||||
cy.findByText('Open as Template')
|
||||
cy.findByText('Unpublish')
|
||||
cy.findByText('Republish')
|
||||
cy.get('@templateProjectId').then(projectId =>
|
||||
cy.visit(`/project/${projectId}`)
|
||||
)
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Manage Template').click()
|
||||
cy.findByText('Unpublish')
|
||||
|
||||
// Back to templates user
|
||||
login(TEMPLATES_USER)
|
||||
|
||||
// Unpublish via editor
|
||||
cy.get('@templateProjectId').then(projectId =>
|
||||
cy.visit(`/project/${projectId}`)
|
||||
)
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Manage Template').click()
|
||||
cy.findByText('Unpublish').click()
|
||||
cy.findByText('Publish')
|
||||
cy.visit('/templates/all')
|
||||
cy.findByText(name).should('not.exist')
|
||||
|
||||
// check for template links, after creating the first project
|
||||
cy.visit('/')
|
||||
cy.findAllByRole('button')
|
||||
.contains(/new project/i)
|
||||
.click()
|
||||
cy.findAllByText('All Templates')
|
||||
.first()
|
||||
.parent()
|
||||
.should('have.attr', 'href', '/templates/all')
|
||||
})
|
||||
})
|
||||
|
||||
function checkDisabled() {
|
||||
it('should not have templates feature', () => {
|
||||
login(TEMPLATES_USER)
|
||||
|
||||
cy.visit('/')
|
||||
createProject('maybe templates')
|
||||
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Word Count') // wait for lazy loading
|
||||
cy.findByText('Manage Template').should('not.exist')
|
||||
|
||||
cy.visit('/templates', { failOnStatusCode: false })
|
||||
cy.findByText('Not found')
|
||||
cy.visit('/templates/all', { failOnStatusCode: false })
|
||||
cy.findByText('Not found')
|
||||
|
||||
// check for template links, after creating the first project
|
||||
cy.visit('/')
|
||||
cy.findAllByRole('button')
|
||||
.contains(/new project/i)
|
||||
.click()
|
||||
cy.findAllByText('All Templates').should('not.exist')
|
||||
})
|
||||
|
||||
it('should not show templates link on welcome page', () => {
|
||||
login(WITHOUT_PROJECTS_USER)
|
||||
cy.visit('/')
|
||||
cy.findByText(/new project/i) // wait for lazy loading
|
||||
cy.findByText(LABEL_BROWSE_TEMPLATES).should('not.exist')
|
||||
})
|
||||
}
|
||||
|
||||
describe('disabled Server Pro', () => {
|
||||
if (isExcludedBySharding('PRO_DEFAULT_2')) return
|
||||
startWith({ pro: true })
|
||||
checkDisabled()
|
||||
})
|
||||
|
||||
describe('unavailable in CE', () => {
|
||||
if (isExcludedBySharding('CE_CUSTOM_1')) return
|
||||
startWith({
|
||||
pro: false,
|
||||
varsFn,
|
||||
})
|
||||
checkDisabled()
|
||||
})
|
||||
})
|
17
server-ce/test/tsconfig.json
Normal file
17
server-ce/test/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext" /* Specify ECMAScript target version */,
|
||||
"module": "es2020" /* Specify module code generation */,
|
||||
"allowJs": true /* Allow JavaScript files to be compiled. */,
|
||||
// "checkJs": true /* Report errors in .js files. */,
|
||||
"jsx": "preserve" /* Specify JSX code generation */,
|
||||
"noEmit": true /* Do not emit outputs. */,
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"moduleResolution": "node" /* Specify module resolution strategy */,
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||
"skipLibCheck": true /* Skip type checking of declaration files. */,
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
|
||||
"types": ["cypress", "node", "@testing-library/cypress"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"]
|
||||
}
|
226
server-ce/test/upgrading.spec.ts
Normal file
226
server-ce/test/upgrading.spec.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { ensureUserExists, login } from './helpers/login'
|
||||
import { isExcludedBySharding, startWith } from './helpers/config'
|
||||
import { dockerCompose, runScript } from './helpers/hostAdminClient'
|
||||
import { createProject, openProjectByName } from './helpers/project'
|
||||
import { throttledRecompile } from './helpers/compile'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
const USER = 'user@example.com'
|
||||
const PROJECT_NAME = 'Old Project'
|
||||
|
||||
describe('Upgrading', function () {
|
||||
if (isExcludedBySharding('PRO_CUSTOM_3')) return
|
||||
|
||||
function testUpgrade(
|
||||
steps: {
|
||||
version: string
|
||||
vars?: Object
|
||||
newProjectButtonMatcher?: RegExp
|
||||
hook?: () => void
|
||||
}[]
|
||||
) {
|
||||
const startOptions = steps.shift()!
|
||||
|
||||
before(async () => {
|
||||
cy.log('Create old instance')
|
||||
})
|
||||
startWith({
|
||||
pro: true,
|
||||
version: startOptions.version,
|
||||
withDataDir: true,
|
||||
resetData: true,
|
||||
vars: startOptions.vars,
|
||||
})
|
||||
before(function () {
|
||||
cy.log('Create initial user after deleting it')
|
||||
})
|
||||
ensureUserExists({ email: USER })
|
||||
before(() => {
|
||||
cy.log('Populate old instance')
|
||||
login(USER)
|
||||
createProject(PROJECT_NAME, {
|
||||
newProjectButtonMatcher: startOptions.newProjectButtonMatcher,
|
||||
})
|
||||
const recompile = throttledRecompile()
|
||||
cy.log('Wait for successful compile')
|
||||
cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
|
||||
|
||||
cy.log('Increment the doc version three times')
|
||||
for (let i = 0; i < 3; i++) {
|
||||
cy.log('Add content')
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle')
|
||||
.parent()
|
||||
.type(`\n\\section{{}Old Section ${i}}`)
|
||||
|
||||
cy.log('Trigger full flush')
|
||||
recompile()
|
||||
cy.get('header').findByText('Menu').click()
|
||||
cy.findByText('Source').click()
|
||||
cy.get('.left-menu-modal-backdrop').click({ force: true })
|
||||
}
|
||||
|
||||
cy.log('Check compile and history')
|
||||
for (let i = 0; i < 3; i++) {
|
||||
cy.get('.pdf-viewer').should('contain.text', `Old Section ${i}`)
|
||||
}
|
||||
cy.findByText('History').click()
|
||||
for (let i = 0; i < 3; i++) {
|
||||
cy.findByText(new RegExp(`\\\\section\{Old Section ${i}}`))
|
||||
}
|
||||
})
|
||||
|
||||
for (const step of steps) {
|
||||
before(() => {
|
||||
cy.log(`Upgrade to version ${step.version}`)
|
||||
|
||||
// Navigate way from editor to avoid redirect to /login when the next instance comes up (which slows down tests)
|
||||
cy.visit('/project', {})
|
||||
})
|
||||
before(async function () {
|
||||
cy.log('Graceful shutdown: flush all the things')
|
||||
this.timeout(20 * 1000)
|
||||
// Ideally we could use the container shutdown procedure, but it's too slow and unreliable for tests.
|
||||
// TODO(das7pad): adopt the below after speeding up the graceful shutdown procedure on all supported releases
|
||||
// await dockerCompose('stop', 'sharelatex')
|
||||
|
||||
// For now, we are stuck with manually flushing things
|
||||
await runScript({
|
||||
cwd: 'services/document-updater',
|
||||
script: 'scripts/flush_all.js',
|
||||
})
|
||||
await runScript({
|
||||
cwd: 'services/project-history',
|
||||
script: 'scripts/flush_all.js',
|
||||
})
|
||||
})
|
||||
startWith({
|
||||
pro: true,
|
||||
version: step.version,
|
||||
vars: step.vars,
|
||||
withDataDir: true,
|
||||
})
|
||||
|
||||
step.hook?.()
|
||||
}
|
||||
beforeEach(() => {
|
||||
login(USER)
|
||||
})
|
||||
|
||||
it('should list the old project', () => {
|
||||
cy.visit('/project')
|
||||
cy.findByText(PROJECT_NAME)
|
||||
})
|
||||
|
||||
it('should open the old project', () => {
|
||||
openProjectByName(PROJECT_NAME)
|
||||
|
||||
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
|
||||
cy.findByRole('navigation').within(() => {
|
||||
cy.findByText(PROJECT_NAME)
|
||||
})
|
||||
const recompile = throttledRecompile()
|
||||
|
||||
cy.log('wait for successful compile')
|
||||
cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
|
||||
cy.get('.pdf-viewer').should('contain.text', 'Old Section 2')
|
||||
|
||||
cy.log('Add more content')
|
||||
const newSection = `New Section ${uuid()}`
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle').parent().type(`\n\\section{{}${newSection}}`)
|
||||
|
||||
cy.log('Check compile and history')
|
||||
recompile()
|
||||
cy.get('.pdf-viewer').should('contain.text', newSection)
|
||||
cy.findByText('History').click()
|
||||
cy.findByText(/\\section\{Old Section 2}/)
|
||||
cy.findByText(new RegExp(`\\\\section\\{${newSection}}`))
|
||||
})
|
||||
}
|
||||
|
||||
const optionsFourDotTwo = {
|
||||
version: '4.2',
|
||||
vars: {
|
||||
// Add core vars with old branding
|
||||
SHARELATEX_SITE_URL: 'http://sharelatex',
|
||||
SHARELATEX_MONGO_URL: 'mongodb://mongo/sharelatex',
|
||||
SHARELATEX_REDIS_HOST: 'redis',
|
||||
},
|
||||
newProjectButtonMatcher: /create first project/i,
|
||||
}
|
||||
describe('from 4.2 to latest', () => {
|
||||
testUpgrade([optionsFourDotTwo, { version: 'latest' }])
|
||||
})
|
||||
describe('from 5.0 to latest', () => {
|
||||
testUpgrade([{ version: '5.0' }, { version: 'latest' }])
|
||||
})
|
||||
describe('doc version recovery', () => {
|
||||
testUpgrade([
|
||||
optionsFourDotTwo,
|
||||
{
|
||||
version: '5.0.1-RC1',
|
||||
hook() {
|
||||
before(function () {
|
||||
login(USER)
|
||||
cy.visit('/')
|
||||
cy.findByText(PROJECT_NAME).click()
|
||||
const recompile = throttledRecompile()
|
||||
|
||||
cy.log('Make a change')
|
||||
cy.findByText('\\maketitle').parent().click()
|
||||
cy.findByText('\\maketitle')
|
||||
.parent()
|
||||
.type('\n\\section{{}FiveOOne Section}')
|
||||
|
||||
cy.log('Trigger flush')
|
||||
recompile()
|
||||
cy.get('.pdf-viewer').should('contain.text', 'FiveOOne Section')
|
||||
|
||||
cy.log('Check for broken history, i.e. not synced with latest edit')
|
||||
cy.findByText('History').click()
|
||||
cy.findByText(/\\section\{Old Section 2}/) // wait for lazy loading
|
||||
cy.findByText(/\\section\{FiveOOne Section}/).should('not.exist')
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 'latest',
|
||||
hook() {
|
||||
before(async function () {
|
||||
this.timeout(20_000)
|
||||
const needle = 'Finished resyncing history for all projects.'
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const { stdout } = await dockerCompose('logs', 'sharelatex')
|
||||
if (stdout.includes(needle)) {
|
||||
return
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
const { stdout } = await dockerCompose('logs', 'sharelatex')
|
||||
expect(stdout).to.contain(
|
||||
needle,
|
||||
'Doc version recovery did not finish yet.'
|
||||
)
|
||||
})
|
||||
|
||||
before(function () {
|
||||
login(USER)
|
||||
cy.visit('/')
|
||||
cy.findByText(PROJECT_NAME).click()
|
||||
|
||||
cy.log(
|
||||
'The edit that was made while the history was broken should be there now.'
|
||||
)
|
||||
cy.findByText('History').click()
|
||||
cy.findByText(/\\section\{FiveOOne Section}/)
|
||||
|
||||
// TODO(das7pad): restore after https://github.com/overleaf/internal/issues/19588 is fixed.
|
||||
// cy.log('Check indicator of force resync')
|
||||
// cy.findByText('Overleaf History System')
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
Reference in New Issue
Block a user