filestore-migration.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import { DEFAULT_PASSWORD, ensureUserExists, login } from './helpers/login'
  2. import {
  3. createProject,
  4. expectFileExists,
  5. openProjectById,
  6. prepareFileUploadTest,
  7. } from './helpers/project'
  8. import { isExcludedBySharding, startWith } from './helpers/config'
  9. import { prepareWaitForNextCompileSlot } from './helpers/compile'
  10. import { v4 as uuid } from 'uuid'
  11. import {
  12. purgeFilestoreData,
  13. runGruntTask,
  14. runScript,
  15. setMongoFeatureCompatibilityVersion,
  16. } from './helpers/hostAdminClient'
  17. function activateUserVersion1x(url: string, password = DEFAULT_PASSWORD) {
  18. cy.session(url, () => {
  19. cy.visit(url)
  20. cy.url().then(url => {
  21. if (url.includes('/login')) return
  22. cy.url().should('contain', '/user/password/set')
  23. cy.get('input[type="password"]').type(password)
  24. cy.findByRole('button', { name: 'Set new password' }).click()
  25. })
  26. })
  27. }
  28. describe('filestore migration', function () {
  29. if (isExcludedBySharding('PRO_CUSTOM_5')) return
  30. const email = 'user@example.com'
  31. // Branding of env vars changed in 5.x
  32. const sharelatexBrandedVars = {
  33. SHARELATEX_SITE_URL: 'http://sharelatex',
  34. SHARELATEX_MONGO_URL: 'mongodb://mongo/sharelatex',
  35. SHARELATEX_REDIS_HOST: 'redis',
  36. }
  37. const projectName = `project-${uuid()}`
  38. let defaultImage: string
  39. let projectId: string
  40. let waitForCompile: (fn: () => void) => void
  41. const previousBinaryFiles: (() => void)[] = []
  42. function avoid502() {
  43. // The next step will likely restart the instance and any following
  44. // requests will fail with a 502/bad gateway. Avoid this by navigating
  45. // away from the editor, which will reload upon receiving a
  46. // 'forceDisconnect' socket.io message.
  47. cy.visit('/project')
  48. }
  49. function addNewBinaryFileAndCheckPrevious(
  50. universeSelector = `img[alt="${defaultImage}"]`
  51. ) {
  52. before(function () {
  53. login(email)
  54. waitForCompile(() => {
  55. cy.visit(`/project/${projectId}`)
  56. })
  57. previousBinaryFiles.push(prepareFileUploadTest(true))
  58. cy.log('check binary files')
  59. for (const check of previousBinaryFiles) {
  60. check()
  61. }
  62. cy.findByRole('treeitem', { name: defaultImage }).click()
  63. cy.get(universeSelector)
  64. .should('be.visible')
  65. .and('have.prop', 'naturalWidth')
  66. .should('be.greaterThan', 0)
  67. avoid502()
  68. })
  69. }
  70. if (Cypress.env('FULL_FILESTORE_MIGRATION')) {
  71. // --------------
  72. // Server Pro 1.x
  73. startWith({
  74. pro: true,
  75. resetData: true,
  76. withDataDir: true,
  77. vars: sharelatexBrandedVars,
  78. version: '1.2.4',
  79. mongoVersion: '5.0',
  80. })
  81. defaultImage = 'universe.jpg'
  82. let activateURL: string
  83. before(async function () {
  84. const { stdout } = await runGruntTask({
  85. task: 'user:create-admin',
  86. args: ['--email', email],
  87. })
  88. ;[activateURL] = stdout.match(
  89. /http:\/\/.+\/user\/password\/set\?passwordResetToken=\S+/
  90. )!
  91. })
  92. before(function () {
  93. activateUserVersion1x(activateURL)
  94. login(email)
  95. cy.visit('/project')
  96. // Legacy angular based UI uses links instead of buttons
  97. cy.findByRole('link', {
  98. name: /Create First Project|New Project/,
  99. }).click()
  100. cy.findByRole('link', { name: 'Example Project' }).click()
  101. cy.findByLabelText('Project name').type(projectName)
  102. cy.findByRole('button', { name: 'Create' }).click()
  103. cy.url()
  104. .should('match', /\/project\/[a-fA-F0-9]{24}/)
  105. .then(url => (projectId = url.split('/').pop()!))
  106. let queueReset
  107. ;({ waitForCompile, queueReset } = prepareWaitForNextCompileSlot())
  108. queueReset()
  109. // Create a new binary file
  110. cy.get(`a[tooltip="Upload"]`).click()
  111. const name = `${uuid()}.txt`
  112. // Binary file detection is not sophisticated in version 1.x
  113. const binName = name.replace('.txt', '.bin')
  114. const content = `Test File Content ${name} \x00`
  115. cy.get('input[type=file]')
  116. .first()
  117. .selectFile(
  118. {
  119. contents: Cypress.Buffer.from(content),
  120. fileName: binName,
  121. lastModified: Date.now(),
  122. },
  123. { force: true }
  124. )
  125. // Rename back to .txt to enable preview
  126. cy.findByText(binName).click()
  127. cy.findByText(binName).dblclick()
  128. cy.focused().type(name + '{del}'.repeat('.bin'.length) + '{enter}')
  129. // Switch back and forth
  130. cy.findByText('universe.jpg').click()
  131. cy.findByText(name).click()
  132. cy.findByText(content)
  133. .parent()
  134. .parent()
  135. .should('have.class', 'text-preview')
  136. previousBinaryFiles.push(() => expectFileExists(name, true, content))
  137. avoid502()
  138. })
  139. // --------------
  140. // Server Pro 2.x
  141. startWith({
  142. pro: true,
  143. withDataDir: true,
  144. vars: sharelatexBrandedVars,
  145. version: '2.7.1',
  146. mongoVersion: '5.0',
  147. })
  148. before(function () {
  149. // Cypress strips the Content-Length header: https://github.com/cypress-io/cypress/issues/16469
  150. // Server Pro 2.x does not gracefully handle a missing value.
  151. cy.intercept(
  152. {
  153. method: 'HEAD',
  154. url: `http://sharelatex/project/${projectId}/file/*`,
  155. times: previousBinaryFiles.length + 1,
  156. },
  157. req => {
  158. req.continue(res => {
  159. res.headers['Content-Length'] = '60'
  160. })
  161. }
  162. )
  163. })
  164. // Server Pro 2.x does not have alt tags on images.
  165. addNewBinaryFileAndCheckPrevious('img')
  166. // ----------------------------------
  167. // Server Pro 3.x + history migration
  168. startWith({
  169. pro: true,
  170. withDataDir: true,
  171. vars: sharelatexBrandedVars,
  172. version: '3.5.13',
  173. mongoVersion: '5.0',
  174. })
  175. addNewBinaryFileAndCheckPrevious() // before history migration
  176. before(async function () {
  177. await runScript({
  178. cwd: 'services/web',
  179. script: 'scripts/history/migrate_history.js',
  180. args: [
  181. '--force-clean',
  182. '--fix-invalid-characters',
  183. '--convert-large-docs-to-file',
  184. ],
  185. hasOverleafEnv: false,
  186. user: 'root',
  187. })
  188. })
  189. before(async function () {
  190. await runScript({
  191. cwd: 'services/web',
  192. script: 'scripts/history/clean_sl_history_data.js',
  193. hasOverleafEnv: false,
  194. })
  195. })
  196. addNewBinaryFileAndCheckPrevious() // after history migration
  197. // ------------------------------
  198. // Server Pro 4.x + mongo upgrade
  199. startWith({
  200. pro: true,
  201. withDataDir: true,
  202. vars: sharelatexBrandedVars,
  203. version: '4.2.9',
  204. mongoVersion: '5.0',
  205. })
  206. startWith({
  207. pro: true,
  208. withDataDir: true,
  209. vars: sharelatexBrandedVars,
  210. version: '4.2.9',
  211. mongoVersion: '6.0',
  212. })
  213. before(async function () {
  214. await setMongoFeatureCompatibilityVersion('6.0')
  215. })
  216. addNewBinaryFileAndCheckPrevious()
  217. // ------------------------------------------
  218. // Server Pro 5.x + mongo upgrade 6 -> 7 -> 8
  219. startWith({
  220. version: '5.5.5',
  221. pro: true,
  222. withDataDir: true,
  223. mongoVersion: '6.0',
  224. })
  225. startWith({
  226. version: '5.5.5',
  227. pro: true,
  228. withDataDir: true,
  229. mongoVersion: '7.0',
  230. })
  231. before(async function () {
  232. await setMongoFeatureCompatibilityVersion('7.0')
  233. })
  234. startWith({
  235. version: '5.5.5',
  236. pro: true,
  237. withDataDir: true,
  238. // implicit mongo upgrade to 8.0
  239. })
  240. before(async function () {
  241. await setMongoFeatureCompatibilityVersion('8.0')
  242. })
  243. } else {
  244. // 5.x
  245. startWith({
  246. version: '5.5.5',
  247. pro: true,
  248. withDataDir: true,
  249. resetData: true,
  250. })
  251. defaultImage = 'frog.jpg'
  252. ensureUserExists({ email })
  253. before(function () {
  254. login(email)
  255. createProject(projectName, { type: 'Example project', open: false }).then(
  256. id => (projectId = id)
  257. )
  258. ;({ waitForCompile } = prepareWaitForNextCompileSlot())
  259. })
  260. }
  261. addNewBinaryFileAndCheckPrevious()
  262. function ensureStopOnFirstErrorIsActive() {
  263. cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
  264. cy.findByRole('menuitem', {
  265. name: 'Stop on first error',
  266. }).then(el => {
  267. // NOTE: THIS IS BAD, but the selected option is otherwise not accessible :/
  268. if (
  269. el.get()[0]?.querySelector('.material-symbol')?.textContent !== 'check'
  270. ) {
  271. cy.findByRole('menuitem', {
  272. name: 'Stop on first error',
  273. }).click()
  274. // Clicking on "Stop on first error" closes the mode. Open it again.
  275. cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
  276. }
  277. })
  278. cy.findByRole('menuitem', {
  279. name: 'Stop on first error',
  280. }).within(() => {
  281. cy.findByText('check').should('be.visible')
  282. })
  283. cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
  284. }
  285. // -------------------
  286. // filestore-migration
  287. beforeEach(function () {
  288. login(email)
  289. waitForCompile(() => {
  290. openProjectById(projectId)
  291. })
  292. ensureStopOnFirstErrorIsActive()
  293. })
  294. function checkFilesAreAccessible() {
  295. it('can upload new binary file and read previous uploads', function () {
  296. previousBinaryFiles.push(prepareFileUploadTest(true))
  297. for (const check of previousBinaryFiles) {
  298. check()
  299. }
  300. })
  301. it('renders image of example project', function () {
  302. cy.findByTestId('file-tree').findByText(defaultImage).click()
  303. cy.get(`[alt="${defaultImage}"]`)
  304. .should('be.visible')
  305. .and('have.prop', 'naturalWidth')
  306. .should('be.greaterThan', 0)
  307. })
  308. it('can recompile from scratch', function () {
  309. const id = uuid()
  310. cy.findByText('\\maketitle').parent().click()
  311. cy.findByText('\\maketitle')
  312. .parent()
  313. .type(`\n\\section{{}Test Section ${id}}`)
  314. waitForCompile(() => {
  315. cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
  316. cy.findByRole('menuitem', {
  317. name: 'Recompile from scratch',
  318. }).trigger('click')
  319. })
  320. cy.get('.pdf-viewer').should('contain.text', `Test Section ${id}`)
  321. })
  322. }
  323. describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL not set', function () {
  324. startWith({ version: '5.5.5', pro: true, withDataDir: true, vars: {} })
  325. checkFilesAreAccessible()
  326. })
  327. describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=0', function () {
  328. startWith({
  329. version: '5.5.5',
  330. pro: true,
  331. withDataDir: true,
  332. vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '0' },
  333. })
  334. checkFilesAreAccessible()
  335. describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=1', function () {
  336. startWith({
  337. version: '5.5.5',
  338. pro: true,
  339. withDataDir: true,
  340. vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' },
  341. })
  342. checkFilesAreAccessible()
  343. describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=2', function () {
  344. startWith({
  345. version: '5.5.5',
  346. pro: true,
  347. withDataDir: true,
  348. vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' },
  349. })
  350. before(async function () {
  351. await runScript({
  352. cwd: 'services/history-v1',
  353. script: 'storage/scripts/back_fill_file_hash.mjs',
  354. args: ['--all'],
  355. })
  356. })
  357. startWith({
  358. version: '5.5.5',
  359. pro: true,
  360. withDataDir: true,
  361. vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2' },
  362. })
  363. checkFilesAreAccessible()
  364. describe('purge filestore data', function () {
  365. before(async function () {
  366. const deleted = await purgeFilestoreData()
  367. expect(deleted).to.have.length.greaterThan(
  368. previousBinaryFiles.length
  369. )
  370. expect(deleted).to.include(
  371. "removed directory '/var/lib/overleaf/data/user_files'"
  372. )
  373. })
  374. checkFilesAreAccessible()
  375. describe('after next restart', function () {
  376. startWith({
  377. version: '5.5.5',
  378. pro: true,
  379. withDataDir: true,
  380. vars: {
  381. OVERLEAF_APP_NAME: 'change-config',
  382. OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2',
  383. },
  384. })
  385. it('should not recreate the user_files folder', async function () {
  386. expect(await purgeFilestoreData()).to.deep.equal([])
  387. })
  388. })
  389. describe('latest', function () {
  390. startWith({
  391. pro: true,
  392. withDataDir: true,
  393. vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2' },
  394. })
  395. it('should not recreate the user_files folder', async function () {
  396. expect(await purgeFilestoreData()).to.deep.equal([])
  397. })
  398. checkFilesAreAccessible()
  399. })
  400. })
  401. })
  402. })
  403. })
  404. })