git-bridge.spec.ts 13 KB

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