git-bridge.spec.ts 12 KB

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