| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412 |
- 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.findByRole('navigation', {
- name: /Project actions/i,
- })
- .findByRole('button', { name: /Menu/i })
- .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/i,
- }).click()
- cy.get('code').contains(/olp_[a-zA-Z0-9]{16}/)
- })
- // Re-open
- cy.url().then(url => cy.visit(url))
- cy.findByRole('navigation', {
- name: /Project actions/i,
- })
- .findByRole('button', { name: /Menu/i })
- .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.findByRole('navigation', {
- name: /Project actions/i,
- })
- .findByRole('button', { name: /Menu/i })
- .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/i,
- }).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.findByRole('navigation', {
- name: /Project actions/i,
- })
- .findByRole('button', { name: /Menu/i })
- .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()
- })
- })
|