overleaf/server-ce/test/git-bridge.spec.ts

392 lines
12 KiB
TypeScript
Raw Permalink Normal View History

import { v4 as uuid } from 'uuid'
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
enableLinkSharing,
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',
}
const gitBridgePublicHost = new URL(Cypress.config().baseUrl!).host
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()
cy.visit('/project')
createProject('git').as('projectId')
cy.get('header').findByText('Menu').click()
cy.findByText('Sync')
cy.findByText('Git').click()
cy.findByRole('dialog').within(() => {
cy.get('@projectId').then(id => {
cy.get('code').contains(
`git clone http://git@${gitBridgePublicHost}/git/${id}`
)
})
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.findByRole('dialog').within(() => {
cy.get('@projectId').then(id => {
cy.get('code').contains(
`git clone http://git@${gitBridgePublicHost}/git/${id}`
)
})
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(() => {
cy.visit('/project')
projectName = uuid()
createProject(projectName).as('projectId')
})
it('should expose r/w interface to owner', () => {
maybeClearAllTokens()
cy.visit('/project')
cy.findByText(projectName).click()
checkGitAccess('readAndWrite')
})
it('should expose r/w interface to invited r/w collaborator', () => {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-rw@example.com',
'Can edit'
)
maybeClearAllTokens()
cy.visit('/project')
cy.findByText(projectName).click()
checkGitAccess('readAndWrite')
})
it('should expose r/o interface to invited r/o collaborator', () => {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-ro@example.com',
'Read only'
)
maybeClearAllTokens()
cy.visit('/project')
cy.findByText(projectName).click()
checkGitAccess('readOnly')
})
it('should expose r/w interface to link-sharing r/w collaborator', () => {
enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
login('collaborator-link-rw@example.com')
maybeClearAllTokens()
cy.visit(linkSharingReadAndWrite)
cy.findByText(projectName) // wait for lazy loading
cy.findByText('Join Project').click()
checkGitAccess('readAndWrite')
})
})
it('should expose r/o interface to link-sharing r/o collaborator', () => {
enableLinkSharing().then(({ linkSharingReadOnly }) => {
login('collaborator-link-ro@example.com')
maybeClearAllTokens()
cy.visit(linkSharingReadOnly)
cy.findByText(projectName) // wait for lazy loading
cy.findByText('Join Project').click()
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.findByRole('dialog').within(() => {
cy.get('code').contains(
`git clone http://git@${gitBridgePublicHost}/git/${projectId}`
)
})
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').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 httpOptions = {
http,
url: `http://sharelatex/git/${projectId}`,
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')
cy.visit('/project')
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()
})
})