recover_zip.test.mjs 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import { expect } from 'chai'
  2. import config from 'config'
  3. import { execFile } from 'node:child_process'
  4. import fs from 'node:fs'
  5. import { promisify } from 'node:util'
  6. import { Change, Operation, File, TextOperation } from 'overleaf-editor-core'
  7. // We depend on this via object-persistor.
  8. // eslint-disable-next-line import/no-extraneous-dependencies
  9. import { Storage } from '@google-cloud/storage'
  10. import {
  11. loadGlobalBlobs,
  12. BlobStore,
  13. } from '../../../../storage/lib/blob_store/index.js'
  14. import ChunkStore from '../../../../storage/lib/chunk_store/index.js'
  15. import persistChanges from '../../../../storage/lib/persist_changes.js'
  16. import testFiles from '../storage/support/test_files.js'
  17. import cleanup from './support/cleanup.js'
  18. import { getZipEntries } from './support/unzip.js'
  19. describe('recover_zip script', function () {
  20. let projectId
  21. let limitsToPersistImmediately
  22. before(async function () {
  23. const farFuture = new Date()
  24. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  25. limitsToPersistImmediately = {
  26. minChangeTimestamp: farFuture,
  27. maxChangeTimestamp: farFuture,
  28. maxChanges: 10,
  29. maxChunkChanges: 10,
  30. }
  31. const gcsEndpoint = config.get('persistor.gcs.endpoint')
  32. const storage = new Storage({
  33. apiEndpoint: gcsEndpoint.apiEndpoint,
  34. projectId: gcsEndpoint.projectId,
  35. })
  36. const bucketName = config.get('zipStore.bucket')
  37. try {
  38. const [exists] = await storage.bucket(bucketName).exists()
  39. if (!exists) {
  40. await storage.createBucket(bucketName)
  41. }
  42. } catch (err) {
  43. if (err.code !== 409) throw err
  44. }
  45. })
  46. beforeEach(cleanup.everything)
  47. beforeEach(async function () {
  48. await loadGlobalBlobs()
  49. projectId = '123'
  50. // Initialize the project in the chunk store
  51. await ChunkStore.initializeProject(projectId)
  52. const blobStore = new BlobStore(projectId)
  53. // Upload binary file blob
  54. await blobStore.putFile(testFiles.path('graph.png'))
  55. // Create initial snapshot with text and binary files
  56. const addMainTex = Operation.addFile(
  57. 'main.tex',
  58. File.fromString('hello world')
  59. )
  60. const addGraphPng = Operation.addFile(
  61. 'graph.png',
  62. File.fromHash(testFiles.GRAPH_PNG_HASH)
  63. )
  64. const change1 = new Change([addMainTex, addGraphPng], new Date(), [])
  65. await persistChanges(projectId, [change1], limitsToPersistImmediately, 0)
  66. // Add a text edit
  67. const textOp = TextOperation.fromJSON({
  68. textOperation: ['hello world'.length, ' more'],
  69. })
  70. const editOp = Operation.editFile('main.tex', textOp)
  71. const change2 = new Change([editOp], new Date(), [])
  72. await persistChanges(projectId, [change2], limitsToPersistImmediately, 1)
  73. })
  74. it('creates a valid zip from GCS data', async function () {
  75. this.timeout(30 * 1000)
  76. const zipPath = `/tmp/test-recover-zip-${projectId}.zip`
  77. try {
  78. const { stdout } = await runRecoverZipScript([projectId])
  79. // The script logs the signed URL to stdout
  80. const urlMatch = stdout.match(/(https?:\/\/[^\s]+)/)
  81. expect(urlMatch).to.not.be.null
  82. const signedUrl = urlMatch[1]
  83. // Download the zip via fetch
  84. const res = await fetch(signedUrl)
  85. expect(res.ok).to.be.true
  86. const buffer = await res.arrayBuffer()
  87. await fs.promises.writeFile(zipPath, Buffer.from(buffer))
  88. const zipEntries = await getZipEntries(zipPath)
  89. const fileNames = zipEntries.map(e => e.fileName).sort()
  90. expect(fileNames).to.deep.equal(['graph.png', 'main.tex'])
  91. // Verify text content size (after edit)
  92. const mainTexEntry = zipEntries.find(e => e.fileName === 'main.tex')
  93. expect(mainTexEntry.uncompressedSize).to.equal('hello world more'.length)
  94. // Verify binary content size
  95. const graphEntry = zipEntries.find(e => e.fileName === 'graph.png')
  96. expect(graphEntry.uncompressedSize).to.equal(
  97. testFiles.GRAPH_PNG_BYTE_LENGTH
  98. )
  99. } finally {
  100. await fs.promises.unlink(zipPath).catch(() => {})
  101. }
  102. })
  103. it('supports the --verbose flag', async function () {
  104. this.timeout(30 * 1000)
  105. const { stdout } = await runRecoverZipScript(['--verbose', projectId])
  106. // Verbose mode logs each file as it's added
  107. expect(stdout).to.include('main.tex added')
  108. expect(stdout).to.include('graph.png added')
  109. })
  110. })
  111. /**
  112. * Run the recover_zip.js script with given arguments
  113. * @param {string[]} args
  114. */
  115. async function runRecoverZipScript(args) {
  116. const TIMEOUT = 30 * 1000
  117. let result
  118. try {
  119. result = await promisify(execFile)(
  120. 'node',
  121. ['storage/scripts/recover_zip.js', ...args],
  122. {
  123. encoding: 'utf-8',
  124. timeout: TIMEOUT,
  125. env: {
  126. ...process.env,
  127. LOG_LEVEL: 'debug',
  128. },
  129. }
  130. )
  131. result.status = 0
  132. } catch (err) {
  133. const { stdout, stderr, code } = err
  134. if (typeof code !== 'number') {
  135. console.log(err)
  136. }
  137. result = { stdout, stderr, status: code }
  138. }
  139. if (result.status !== 0 || result.stderr) {
  140. throw new Error(
  141. `recover_zip failed (exit ${result.status}):\n` +
  142. `stdout: ${result.stdout}\n` +
  143. `stderr: ${result.stderr}`
  144. )
  145. }
  146. return result
  147. }