git-bridge.spec.ts 15 KB

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