git-bridge.spec.ts 16 KB

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