git-bridge.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import { v4 as uuid } from 'uuid'
  2. import { isExcludedBySharding, startWith } from './helpers/config'
  3. import { ensureUserExists, login } from './helpers/login'
  4. import {
  5. createProject,
  6. enableLinkSharing,
  7. shareProjectByEmailAndAcceptInviteViaDash,
  8. } from './helpers/project'
  9. import git from 'isomorphic-git'
  10. import http from 'isomorphic-git/http/web'
  11. import LightningFS from '@isomorphic-git/lightning-fs'
  12. import { throttledRecompile } from './helpers/compile'
  13. describe('git-bridge', function () {
  14. const ENABLED_VARS = {
  15. GIT_BRIDGE_ENABLED: 'true',
  16. GIT_BRIDGE_HOST: 'git-bridge',
  17. GIT_BRIDGE_PORT: '8000',
  18. V1_HISTORY_URL: 'http://sharelatex:3100/api',
  19. }
  20. const gitBridgePublicHost = new URL(Cypress.config().baseUrl!).host
  21. describe('enabled in Server Pro', function () {
  22. if (isExcludedBySharding('PRO_CUSTOM_1')) return
  23. startWith({
  24. pro: true,
  25. vars: ENABLED_VARS,
  26. })
  27. ensureUserExists({ email: 'user@example.com' })
  28. function clearAllTokens() {
  29. cy.get('button.linking-git-bridge-revoke-button').each(el => {
  30. cy.wrap(el).click()
  31. cy.findByText('Delete token').click()
  32. })
  33. }
  34. function maybeClearAllTokens() {
  35. cy.visit('/user/settings')
  36. cy.findByText('Git Integration')
  37. cy.get('button')
  38. .contains(/Generate token|Add another token/)
  39. .then(btn => {
  40. if (btn.text() === 'Add another token') {
  41. clearAllTokens()
  42. }
  43. })
  44. }
  45. beforeEach(function () {
  46. login('user@example.com')
  47. })
  48. it('should render the git-bridge UI in the settings', () => {
  49. maybeClearAllTokens()
  50. cy.visit('/user/settings')
  51. cy.findByText('Git Integration')
  52. cy.get('button').contains('Generate token').click()
  53. cy.get('code')
  54. .contains(/olp_[a-zA-Z0-9]{16}/)
  55. .as('newToken')
  56. cy.findAllByText('Close').last().click()
  57. cy.get('@newToken').then(token => {
  58. // There can be more than one token with the same prefix when retrying
  59. cy.findAllByText(
  60. `${token.text().slice(0, 'olp_1234'.length)}${'*'.repeat(12)}`
  61. ).should('have.length.at.least', 1)
  62. })
  63. cy.get('button').contains('Generate token').should('not.exist')
  64. cy.get('button').contains('Add another token').should('exist')
  65. clearAllTokens()
  66. cy.get('button').contains('Generate token').should('exist')
  67. cy.get('button').contains('Add another token').should('not.exist')
  68. })
  69. it('should render the git-bridge UI in the editor', function () {
  70. maybeClearAllTokens()
  71. cy.visit('/project')
  72. createProject('git').as('projectId')
  73. cy.get('header').findByText('Menu').click()
  74. cy.findByText('Sync')
  75. cy.findByText('Git').click()
  76. cy.findByTestId('git-bridge-modal').within(() => {
  77. cy.get('@projectId').then(id => {
  78. cy.get('code').contains(
  79. `git clone http://git@${gitBridgePublicHost}/git/${id}`
  80. )
  81. })
  82. cy.findByRole('button', {
  83. name: 'Generate token',
  84. }).click()
  85. cy.get('code').contains(/olp_[a-zA-Z0-9]{16}/)
  86. })
  87. // Re-open
  88. cy.url().then(url => cy.visit(url))
  89. cy.get('header').findByText('Menu').click()
  90. cy.findByText('Git').click()
  91. cy.findByTestId('git-bridge-modal').within(() => {
  92. cy.get('@projectId').then(id => {
  93. cy.get('code').contains(
  94. `git clone http://git@${gitBridgePublicHost}/git/${id}`
  95. )
  96. })
  97. cy.findByText('Generate token').should('not.exist')
  98. cy.findByText(/generate a new one in Account Settings/)
  99. cy.findByText('Go to settings')
  100. .should('have.attr', 'target', '_blank')
  101. .and('have.attr', 'href', '/user/settings')
  102. })
  103. })
  104. describe('git access', () => {
  105. ensureUserExists({ email: 'collaborator-rw@example.com' })
  106. ensureUserExists({ email: 'collaborator-ro@example.com' })
  107. ensureUserExists({ email: 'collaborator-link-rw@example.com' })
  108. ensureUserExists({ email: 'collaborator-link-ro@example.com' })
  109. let projectName: string
  110. beforeEach(() => {
  111. cy.visit('/project')
  112. projectName = uuid()
  113. createProject(projectName).as('projectId')
  114. })
  115. it('should expose r/w interface to owner', () => {
  116. maybeClearAllTokens()
  117. cy.visit('/project')
  118. cy.findByText(projectName).click()
  119. checkGitAccess('readAndWrite')
  120. })
  121. it('should expose r/w interface to invited r/w collaborator', () => {
  122. shareProjectByEmailAndAcceptInviteViaDash(
  123. projectName,
  124. 'collaborator-rw@example.com',
  125. 'Can edit'
  126. )
  127. maybeClearAllTokens()
  128. cy.visit('/project')
  129. cy.findByText(projectName).click()
  130. checkGitAccess('readAndWrite')
  131. })
  132. it('should expose r/o interface to invited r/o collaborator', () => {
  133. shareProjectByEmailAndAcceptInviteViaDash(
  134. projectName,
  135. 'collaborator-ro@example.com',
  136. 'Read only'
  137. )
  138. maybeClearAllTokens()
  139. cy.visit('/project')
  140. cy.findByText(projectName).click()
  141. checkGitAccess('readOnly')
  142. })
  143. it('should expose r/w interface to link-sharing r/w collaborator', () => {
  144. enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
  145. login('collaborator-link-rw@example.com')
  146. maybeClearAllTokens()
  147. cy.visit(linkSharingReadAndWrite)
  148. cy.findByText(projectName) // wait for lazy loading
  149. cy.findByText('Join Project').click()
  150. checkGitAccess('readAndWrite')
  151. })
  152. })
  153. it('should expose r/o interface to link-sharing r/o collaborator', () => {
  154. enableLinkSharing().then(({ linkSharingReadOnly }) => {
  155. login('collaborator-link-ro@example.com')
  156. maybeClearAllTokens()
  157. cy.visit(linkSharingReadOnly)
  158. cy.findByText(projectName) // wait for lazy loading
  159. cy.findByText('Join Project').click()
  160. checkGitAccess('readOnly')
  161. })
  162. })
  163. })
  164. function checkGitAccess(access: 'readOnly' | 'readAndWrite') {
  165. const recompile = throttledRecompile()
  166. cy.get('header').findByText('Menu').click()
  167. cy.findByText('Sync')
  168. cy.findByText('Git').click()
  169. cy.get('@projectId').then(projectId => {
  170. cy.findByTestId('git-bridge-modal').within(() => {
  171. cy.get('code').contains(
  172. `git clone http://git@${gitBridgePublicHost}/git/${projectId}`
  173. )
  174. })
  175. cy.findByRole('button', {
  176. name: 'Generate token',
  177. }).click()
  178. cy.get('code')
  179. .contains(/olp_[a-zA-Z0-9]{16}/)
  180. .then(async tokenEl => {
  181. const token = tokenEl.text()
  182. // close Git modal
  183. cy.findAllByText('Close').last().click()
  184. // close editor menu
  185. cy.get('.left-menu-modal-backdrop').click()
  186. const fs = new LightningFS('fs')
  187. const dir = `/${projectId}`
  188. async function readFile(path: string): Promise<string> {
  189. return new Promise((resolve, reject) => {
  190. fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
  191. if (err) return reject(err)
  192. resolve(blob as string)
  193. })
  194. })
  195. }
  196. async function writeFile(path: string, data: string) {
  197. return new Promise<void>((resolve, reject) => {
  198. fs.writeFile(path, data, undefined, err => {
  199. if (err) return reject(err)
  200. resolve()
  201. })
  202. })
  203. }
  204. const commonOptions = {
  205. dir,
  206. fs,
  207. }
  208. const httpOptions = {
  209. http,
  210. url: `http://sharelatex/git/${projectId}`,
  211. headers: {
  212. Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`,
  213. },
  214. }
  215. const authorOptions = {
  216. author: { name: 'user', email: 'user@example.com' },
  217. committer: { name: 'user', email: 'user@example.com' },
  218. }
  219. const mainTex = `${dir}/main.tex`
  220. // Clone
  221. cy.then({ timeout: 10_000 }, async () => {
  222. await git.clone({
  223. ...commonOptions,
  224. ...httpOptions,
  225. })
  226. })
  227. cy.findByText(/\\documentclass/)
  228. .parent()
  229. .parent()
  230. .then(async editor => {
  231. const onDisk = await readFile(mainTex)
  232. expect(onDisk.replaceAll('\n', '')).to.equal(editor.text())
  233. })
  234. const text = `
  235. \\documentclass{article}
  236. \\begin{document}
  237. Hello world
  238. \\end{document}
  239. `
  240. // Make a change
  241. cy.then(async () => {
  242. await writeFile(mainTex, text)
  243. await git.add({
  244. ...commonOptions,
  245. filepath: 'main.tex',
  246. })
  247. await git.commit({
  248. ...commonOptions,
  249. ...authorOptions,
  250. message: 'Swap main.tex',
  251. })
  252. })
  253. if (access === 'readAndWrite') {
  254. // check history before push
  255. cy.findAllByText('History').last().click()
  256. cy.findByText('(via Git)').should('not.exist')
  257. cy.findAllByText('Back to editor').last().click()
  258. cy.then(async () => {
  259. await git.push({
  260. ...commonOptions,
  261. ...httpOptions,
  262. })
  263. })
  264. } else {
  265. cy.then(async () => {
  266. try {
  267. await git.push({
  268. ...commonOptions,
  269. ...httpOptions,
  270. })
  271. expect.fail('push should have failed')
  272. } catch (err) {
  273. expect(err).to.match(/branches were not updated/)
  274. expect(err).to.match(/forbidden/)
  275. }
  276. })
  277. return // return early, below are write access bits
  278. }
  279. // check push in editor
  280. cy.findByText(/\\documentclass/)
  281. .parent()
  282. .parent()
  283. .should('have.text', text.replaceAll('\n', ''))
  284. // Wait for history sync - trigger flush by toggling the UI
  285. cy.findAllByText('History').last().click()
  286. cy.findAllByText('Back to editor').last().click()
  287. // check push in history
  288. cy.findAllByText('History').last().click()
  289. cy.findByText(/Hello world/)
  290. cy.findByText('(via Git)').should('exist')
  291. // Back to the editor
  292. cy.findAllByText('Back to editor').last().click()
  293. cy.findByText(/\\documentclass/)
  294. .parent()
  295. .parent()
  296. .click()
  297. .type('% via editor{enter}')
  298. // Trigger flush via compile
  299. recompile()
  300. // Back into the history, check what we just added
  301. cy.findAllByText('History').last().click()
  302. cy.findByText(/% via editor/)
  303. // Pull the change
  304. cy.then(async () => {
  305. await git.pull({
  306. ...commonOptions,
  307. ...httpOptions,
  308. ...authorOptions,
  309. })
  310. expect(await readFile(mainTex)).to.equal(text + '% via editor\n')
  311. })
  312. })
  313. })
  314. }
  315. })
  316. function checkDisabled() {
  317. ensureUserExists({ email: 'user@example.com' })
  318. it('should not render the git-bridge UI in the settings', () => {
  319. login('user@example.com')
  320. cy.visit('/user/settings')
  321. cy.findByText('Git Integration').should('not.exist')
  322. })
  323. it('should not render the git-bridge UI in the editor', function () {
  324. login('user@example.com')
  325. cy.visit('/project')
  326. createProject('maybe git')
  327. cy.get('header').findByText('Menu').click()
  328. cy.findByText('Word Count') // wait for lazy loading
  329. cy.findByText('Sync').should('not.exist')
  330. cy.findByText('Git').should('not.exist')
  331. })
  332. }
  333. describe('disabled in Server Pro', () => {
  334. if (isExcludedBySharding('PRO_DEFAULT_1')) return
  335. startWith({
  336. pro: true,
  337. })
  338. checkDisabled()
  339. })
  340. describe('unavailable in CE', () => {
  341. if (isExcludedBySharding('CE_CUSTOM_1')) return
  342. startWith({
  343. pro: false,
  344. vars: ENABLED_VARS,
  345. })
  346. checkDisabled()
  347. })
  348. })