2024-07-25 11:13:52 -04:00
|
|
|
import { v4 as uuid } from 'uuid'
|
2024-07-10 10:00:41 -04:00
|
|
|
import { isExcludedBySharding, startWith } from './helpers/config'
|
2024-05-30 03:13:24 -04:00
|
|
|
import { ensureUserExists, login } from './helpers/login'
|
2024-07-25 11:13:52 -04:00
|
|
|
import {
|
|
|
|
createProject,
|
|
|
|
enableLinkSharing,
|
2024-07-29 05:56:56 -04:00
|
|
|
shareProjectByEmailAndAcceptInviteViaDash,
|
2024-07-25 11:13:52 -04:00
|
|
|
} from './helpers/project'
|
2024-05-30 03:13:24 -04:00
|
|
|
|
|
|
|
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',
|
|
|
|
}
|
|
|
|
|
2024-08-30 08:02:23 -04:00
|
|
|
const gitBridgePublicHost = new URL(Cypress.config().baseUrl!).host
|
2024-06-17 07:05:06 -04:00
|
|
|
|
2024-05-30 03:13:24 -04:00
|
|
|
describe('enabled in Server Pro', function () {
|
2024-07-10 10:00:41 -04:00
|
|
|
if (isExcludedBySharding('PRO_CUSTOM_1')) return
|
2024-05-30 03:13:24 -04:00
|
|
|
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()
|
|
|
|
})
|
|
|
|
}
|
2024-07-25 11:13:52 -04:00
|
|
|
|
2024-05-30 03:13:24 -04:00
|
|
|
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', () => {
|
2024-07-25 11:13:52 -04:00
|
|
|
maybeClearAllTokens()
|
2024-05-30 03:13:24 -04:00
|
|
|
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 () {
|
2024-07-25 11:13:52 -04:00
|
|
|
maybeClearAllTokens()
|
2024-05-30 03:13:24 -04:00
|
|
|
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 => {
|
2024-06-17 07:05:06 -04:00
|
|
|
cy.get('code').contains(
|
|
|
|
`git clone http://git@${gitBridgePublicHost}/git/${id}`
|
|
|
|
)
|
2024-05-30 03:13:24 -04:00
|
|
|
})
|
|
|
|
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 => {
|
2024-06-17 07:05:06 -04:00
|
|
|
cy.get('code').contains(
|
|
|
|
`git clone http://git@${gitBridgePublicHost}/git/${id}`
|
|
|
|
)
|
2024-05-30 03:13:24 -04:00
|
|
|
})
|
|
|
|
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')
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2024-07-25 11:13:52 -04:00
|
|
|
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', () => {
|
2024-07-29 05:56:56 -04:00
|
|
|
shareProjectByEmailAndAcceptInviteViaDash(
|
2024-07-25 11:13:52 -04:00
|
|
|
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', () => {
|
2024-07-29 05:56:56 -04:00
|
|
|
shareProjectByEmailAndAcceptInviteViaDash(
|
2024-07-25 11:13:52 -04:00
|
|
|
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') {
|
2024-05-30 03:13:24 -04:00
|
|
|
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(
|
2024-06-17 07:05:06 -04:00
|
|
|
`git clone http://git@${gitBridgePublicHost}/git/${projectId}`
|
2024-05-30 03:13:24 -04:00
|
|
|
)
|
|
|
|
})
|
|
|
|
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}`
|
|
|
|
|
2024-07-25 11:13:52 -04:00
|
|
|
async function readFile(path: string): Promise<string> {
|
2024-05-30 03:13:24 -04:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
|
|
|
|
if (err) return reject(err)
|
2024-07-25 11:13:52 -04:00
|
|
|
resolve(blob as string)
|
2024-05-30 03:13:24 -04:00
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
2024-07-25 11:13:52 -04:00
|
|
|
|
2024-05-30 03:13:24 -04:00
|
|
|
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' },
|
|
|
|
}
|
2024-07-25 11:13:52 -04:00
|
|
|
const mainTex = `${dir}/main.tex`
|
2024-05-30 03:13:24 -04:00
|
|
|
|
|
|
|
// Clone
|
|
|
|
cy.then({ timeout: 10_000 }, async () => {
|
|
|
|
await git.clone({
|
|
|
|
...commonOptions,
|
|
|
|
...httpOptions,
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2024-07-25 11:13:52 -04:00
|
|
|
cy.findByText(/\\documentclass/)
|
|
|
|
.parent()
|
|
|
|
.parent()
|
|
|
|
.then(async editor => {
|
|
|
|
const onDisk = await readFile(mainTex)
|
|
|
|
expect(onDisk.replaceAll('\n', '')).to.equal(editor.text())
|
|
|
|
})
|
|
|
|
|
2024-05-30 03:13:24 -04:00
|
|
|
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',
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
2024-07-25 11:13:52 -04:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-05-30 03:13:24 -04:00
|
|
|
// 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')
|
|
|
|
})
|
|
|
|
})
|
|
|
|
})
|
2024-07-25 11:13:52 -04:00
|
|
|
}
|
2024-05-30 03:13:24 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
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()
|
2024-05-30 03:13:40 -04:00
|
|
|
cy.findByText('Word Count') // wait for lazy loading
|
2024-05-30 03:13:24 -04:00
|
|
|
cy.findByText('Sync').should('not.exist')
|
|
|
|
cy.findByText('Git').should('not.exist')
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
describe('disabled in Server Pro', () => {
|
2024-07-10 10:00:41 -04:00
|
|
|
if (isExcludedBySharding('PRO_DEFAULT_1')) return
|
2024-05-30 03:13:24 -04:00
|
|
|
startWith({
|
|
|
|
pro: true,
|
|
|
|
})
|
|
|
|
checkDisabled()
|
|
|
|
})
|
|
|
|
|
|
|
|
describe('unavailable in CE', () => {
|
2024-07-10 10:00:41 -04:00
|
|
|
if (isExcludedBySharding('CE_CUSTOM_1')) return
|
2024-05-30 03:13:24 -04:00
|
|
|
startWith({
|
|
|
|
pro: false,
|
|
|
|
vars: ENABLED_VARS,
|
|
|
|
})
|
|
|
|
checkDisabled()
|
|
|
|
})
|
|
|
|
})
|