git-bridge.spec.ts 15 KB

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