diff --git a/server-ce/test/git-bridge.spec.ts b/server-ce/test/git-bridge.spec.ts index cb254edeca..2025128e35 100644 --- a/server-ce/test/git-bridge.spec.ts +++ b/server-ce/test/git-bridge.spec.ts @@ -1,6 +1,11 @@ +import { v4 as uuid } from 'uuid' import { isExcludedBySharding, startWith } from './helpers/config' import { ensureUserExists, login } from './helpers/login' -import { createProject } from './helpers/project' +import { + createProject, + enableLinkSharing, + shareProjectByEmailAndAcceptInvite, +} from './helpers/project' import git from 'isomorphic-git' import http from 'isomorphic-git/http/web' @@ -32,6 +37,7 @@ describe('git-bridge', function () { cy.findByText('Delete token').click() }) } + function maybeClearAllTokens() { cy.visit('/user/settings') cy.findByText('Git Integration') @@ -46,10 +52,10 @@ describe('git-bridge', function () { beforeEach(function () { login('user@example.com') - maybeClearAllTokens() }) 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() @@ -71,6 +77,7 @@ describe('git-bridge', function () { }) 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() @@ -106,9 +113,74 @@ describe('git-bridge', function () { }) }) - it('should expose interface for git', () => { - cy.visit('/project') - createProject('git').as('projectId') + 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', () => { + shareProjectByEmailAndAcceptInvite( + 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', () => { + shareProjectByEmailAndAcceptInvite( + 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() @@ -133,22 +205,18 @@ describe('git-bridge', function () { // close editor menu cy.get('#left-menu-modal').click() - // check history - cy.findAllByText('History').last().click() - cy.findByText('(via Git)').should('not.exist') - cy.findAllByText('Back to editor').last().click() - const fs = new LightningFS('fs') const dir = `/${projectId}` - async function readFile(path: string) { + async function readFile(path: string): Promise { return new Promise((resolve, reject) => { fs.readFile(path, { encoding: 'utf8' }, (err, blob) => { if (err) return reject(err) - resolve(blob) + resolve(blob as string) }) }) } + async function writeFile(path: string, data: string) { return new Promise((resolve, reject) => { fs.writeFile(path, data, undefined, err => { @@ -173,6 +241,7 @@ describe('git-bridge', function () { 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 () => { @@ -182,7 +251,14 @@ describe('git-bridge', function () { }) }) - const mainTex = `${dir}/main.tex` + 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} @@ -202,12 +278,37 @@ Hello world ...authorOptions, message: 'Swap main.tex', }) - await git.push({ - ...commonOptions, - ...httpOptions, - }) }) + 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() @@ -250,7 +351,7 @@ Hello world }) }) }) - }) + } }) function checkDisabled() { diff --git a/server-ce/test/helpers/project.ts b/server-ce/test/helpers/project.ts index 4eeb673859..eb8c4c86d6 100644 --- a/server-ce/test/helpers/project.ts +++ b/server-ce/test/helpers/project.ts @@ -1,3 +1,5 @@ +import { login } from './login' + export function createProject( name: string, { @@ -20,3 +22,53 @@ export function createProject( .should('match', /\/project\/[a-fA-F0-9]{24}/) .then(url => url.split('/').pop()) } + +export function shareProjectByEmailAndAcceptInvite( + projectName: string, + email: string, + level: 'Read only' | 'Can edit' +) { + cy.visit('/project') + cy.findByText(projectName).click() + cy.findByText('Share').click() + cy.findByRole('dialog').within(() => { + cy.get('input').type(`${email},`) + cy.get('input') + .parents('form') + .within(() => cy.findByText('Can edit').parent().select(level)) + cy.findByText('Share').click({ force: true }) + }) + + login(email) + cy.visit('/project') + cy.findByText(new RegExp(projectName)) + .parent() + .parent() + .within(() => { + cy.findByText('Join Project').click() + }) +} + +export function enableLinkSharing() { + let linkSharingReadOnly: string + let linkSharingReadAndWrite: string + + 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', 'http://sharelatex/') + .then(el => { + linkSharingReadOnly = el.text() + }) + cy.findByText('Anyone with this link can edit this project') + .next() + .should('contain.text', 'http://sharelatex/') + .then(el => { + linkSharingReadAndWrite = el.text() + }) + + return cy.then(() => { + return { linkSharingReadOnly, linkSharingReadAndWrite } + }) +} diff --git a/server-ce/test/project-sharing.spec.ts b/server-ce/test/project-sharing.spec.ts index 3f2ec7d553..ac7f1e6442 100644 --- a/server-ce/test/project-sharing.spec.ts +++ b/server-ce/test/project-sharing.spec.ts @@ -1,7 +1,11 @@ import { v4 as uuid } from 'uuid' import { isExcludedBySharding, startWith } from './helpers/config' import { ensureUserExists, login } from './helpers/login' -import { createProject } from './helpers/project' +import { + createProject, + enableLinkSharing, + shareProjectByEmailAndAcceptInvite, +} from './helpers/project' import { throttledRecompile } from './helpers/compile' import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' @@ -36,46 +40,12 @@ describe('Project Sharing', function () { ).type('New Chat Message{enter}') // Get link sharing links - 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', 'http://sharelatex/') - .then(el => { - linkSharingReadOnly = el.text() - }) - cy.findByText('Anyone with this link can edit this project') - .next() - .should('contain.text', 'http://sharelatex/') - .then(el => { - linkSharingReadAndWrite = el.text() - }) - } - - function shareProjectByEmailAndAcceptInvite( - email: string, - level: 'Read only' | 'Can edit' - ) { - login('user@example.com') - cy.visit('/project') - cy.findByText(projectName).click() - cy.findByText('Share').click() - cy.findByRole('dialog').within(() => { - cy.get('input').type(`${email},`) - cy.get('input') - .parents('form') - .within(() => cy.findByText('Can edit').parent().select(level)) - cy.findByText('Share').click({ force: true }) - }) - - login(email) - cy.visit('/project') - cy.findByText(new RegExp(projectName)) - .parent() - .parent() - .within(() => { - cy.findByText('Join Project').click() - }) + enableLinkSharing().then( + ({ linkSharingReadOnly: ro, linkSharingReadAndWrite: rw }) => { + linkSharingReadAndWrite = rw + linkSharingReadOnly = ro + } + ) } function expectContentReadOnlyAccess() { @@ -178,7 +148,8 @@ describe('Project Sharing', function () { ensureUserExists({ email }) beforeWithReRunOnTestRetry(function () { - shareProjectByEmailAndAcceptInvite(email, 'Read only') + login('user@example.com') + shareProjectByEmailAndAcceptInvite(projectName, email, 'Read only') }) it('should grant the collaborator read access', () => { @@ -195,7 +166,8 @@ describe('Project Sharing', function () { ensureUserExists({ email }) beforeWithReRunOnTestRetry(function () { - shareProjectByEmailAndAcceptInvite(email, 'Can edit') + login('user@example.com') + shareProjectByEmailAndAcceptInvite(projectName, email, 'Can edit') }) it('should grant the collaborator write access', () => {