backupVerifier.test.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. // @ts-check
  2. import cleanup from '../storage/support/cleanup.js'
  3. import fetch from 'node-fetch'
  4. import testServer from './support/test_backup_verifier_server.mjs'
  5. import { expect } from 'chai'
  6. import testProjects from './support/test_projects.js'
  7. import {
  8. backupPersistor,
  9. chunksBucket,
  10. projectBlobsBucket,
  11. } from '../../../../storage/lib/backupPersistor.mjs'
  12. import {
  13. BlobStore,
  14. makeProjectKey,
  15. } from '../../../../storage/lib/blob_store/index.js'
  16. import Stream from 'node:stream'
  17. import * as zlib from 'node:zlib'
  18. import { promisify } from 'node:util'
  19. import { execFile } from 'node:child_process'
  20. import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js'
  21. import { chunkStore } from '../../../../storage/index.js'
  22. import { Change, File, Operation } from 'overleaf-editor-core'
  23. import Crypto from 'node:crypto'
  24. import path from 'node:path'
  25. import projectKey from '../../../../storage/lib/project_key.js'
  26. import { historyStore } from '../../../../storage/lib/history_store.js'
  27. /**
  28. * @typedef {import("node-fetch").Response} Response
  29. * @typedef {import("overleaf-editor-core").Blob} Blob
  30. */
  31. // Timeout for script execution, increased to avoid flaky tests
  32. const SCRIPT_TIMEOUT = 15_000
  33. async function verifyProjectScript(historyId, expectFail = true) {
  34. try {
  35. const result = await promisify(execFile)(
  36. process.argv0,
  37. ['storage/scripts/verify_project.mjs', `--historyId=${historyId}`],
  38. {
  39. encoding: 'utf-8',
  40. timeout: SCRIPT_TIMEOUT,
  41. env: {
  42. ...process.env,
  43. LOG_LEVEL: 'warn',
  44. },
  45. }
  46. )
  47. return { status: 0, stdout: result.stdout, stderr: result.stderr }
  48. } catch (err) {
  49. if (
  50. err &&
  51. typeof err === 'object' &&
  52. 'stdout' in err &&
  53. 'code' in err &&
  54. 'stderr' in err
  55. ) {
  56. if (!expectFail) {
  57. console.log(err)
  58. }
  59. return {
  60. stdout: typeof err.stdout === 'string' ? err.stdout : '',
  61. status: typeof err.code === 'number' ? err.code : -1,
  62. stderr: typeof err.stdout === 'string' ? err.stderr : '',
  63. }
  64. }
  65. throw err
  66. }
  67. }
  68. /**
  69. * @param {string} historyId
  70. * @param {string} hash
  71. * @return {Promise<{stdout: string, status:number }>}
  72. */
  73. async function verifyBlobScript(historyId, hash, expectFail = true) {
  74. try {
  75. const result = await promisify(execFile)(
  76. process.argv0,
  77. [
  78. 'storage/scripts/verify_backup_blob.mjs',
  79. `--historyId=${historyId}`,
  80. hash,
  81. ],
  82. {
  83. encoding: 'utf-8',
  84. timeout: SCRIPT_TIMEOUT,
  85. env: {
  86. ...process.env,
  87. LOG_LEVEL: 'warn',
  88. },
  89. }
  90. )
  91. return { status: 0, stdout: result.stdout }
  92. } catch (err) {
  93. if (err && typeof err === 'object' && 'stdout' in err && 'code' in err) {
  94. if (!expectFail) {
  95. console.log(err)
  96. }
  97. return {
  98. stdout: typeof err.stdout === 'string' ? err.stdout : '',
  99. status: typeof err.code === 'number' ? err.code : -1,
  100. }
  101. }
  102. throw err
  103. }
  104. }
  105. /**
  106. * @param {string} historyId
  107. * @param {string} hash
  108. * @return {Promise<Response>}
  109. */
  110. async function verifyBlobHTTP(historyId, hash) {
  111. return await fetch(
  112. testServer.testUrl(`/history/${historyId}/blob/${hash}/verify`),
  113. { method: 'GET' }
  114. )
  115. }
  116. async function backupChunk(historyId) {
  117. const newChunk = await chunkStore.loadLatestRaw(historyId)
  118. const { buffer: chunkBuffer } = await historyStore.loadRawWithBuffer(
  119. historyId,
  120. newChunk.id
  121. )
  122. const md5 = Crypto.createHash('md5').update(chunkBuffer)
  123. await backupPersistor.sendStream(
  124. chunksBucket,
  125. path.join(
  126. projectKey.format(historyId),
  127. projectKey.pad(newChunk.startVersion)
  128. ),
  129. Stream.Readable.from([chunkBuffer]),
  130. {
  131. contentType: 'application/json',
  132. contentEncoding: 'gzip',
  133. contentLength: chunkBuffer.byteLength,
  134. sourceMd5: md5.digest('hex'),
  135. }
  136. )
  137. }
  138. const FIFTEEN_MINUTES_IN_MS = 900_000
  139. async function addFileInNewChunk(
  140. fileContents,
  141. filePath,
  142. historyId,
  143. { creationDate = new Date() }
  144. ) {
  145. const chunk = await chunkStore.loadLatest(historyId, { persistedOnly: true })
  146. const operation = Operation.addFile(
  147. `${historyId}.txt`,
  148. File.fromString(fileContents)
  149. )
  150. const changes = [new Change([operation], creationDate, [])]
  151. chunk.pushChanges(changes)
  152. await chunkStore.update(historyId, 0, chunk)
  153. }
  154. /**
  155. * @param {string} historyId
  156. * @param {Object} [backup]
  157. * @return {Promise<string>}
  158. */
  159. async function prepareProjectAndBlob(
  160. historyId,
  161. { shouldBackupBlob, shouldBackupChunk, shouldCreateChunk } = {
  162. shouldBackupBlob: true,
  163. shouldBackupChunk: true,
  164. shouldCreateChunk: true,
  165. }
  166. ) {
  167. await testProjects.createEmptyProject(historyId)
  168. const blobStore = new BlobStore(historyId)
  169. const fileContents = historyId
  170. const blob = await blobStore.putString(fileContents)
  171. if (shouldCreateChunk) {
  172. await addFileInNewChunk(fileContents, `${historyId}.txt`, historyId, {
  173. creationDate: new Date(new Date().getTime() - FIFTEEN_MINUTES_IN_MS),
  174. })
  175. }
  176. if (shouldBackupBlob) {
  177. const gzipped = zlib.gzipSync(Buffer.from(historyId))
  178. await backupPersistor.sendStream(
  179. projectBlobsBucket,
  180. makeProjectKey(historyId, blob.getHash()),
  181. Stream.Readable.from([gzipped]),
  182. { contentLength: gzipped.byteLength, contentEncoding: 'gzip' }
  183. )
  184. await checkDEKExists(historyId)
  185. }
  186. if (shouldCreateChunk && shouldBackupChunk) {
  187. await backupChunk(historyId)
  188. }
  189. return blob.getHash()
  190. }
  191. /**
  192. * @param {string} historyId
  193. * @return {Promise<void>}
  194. */
  195. async function checkDEKExists(historyId) {
  196. await backupPersistor.forProjectRO(
  197. projectBlobsBucket,
  198. makeProjectKey(historyId, '')
  199. )
  200. }
  201. describe('backupVerifier', function () {
  202. this.timeout(5_000 + SCRIPT_TIMEOUT) // allow time for external scripts to run
  203. const historyIdPostgres = '42'
  204. const historyIdMongo = '000000000000000000000042'
  205. let blobHashPG, blobHashMongo, blobPathPG
  206. beforeEach(cleanup.everything)
  207. beforeEach('create health check projects', async function () {
  208. ;[blobHashPG, blobHashMongo] = await Promise.all([
  209. prepareProjectAndBlob('42'),
  210. prepareProjectAndBlob('000000000000000000000042'),
  211. ])
  212. blobPathPG = makeProjectKey(historyIdPostgres, blobHashPG)
  213. })
  214. beforeEach(testServer.listenOnRandomPort)
  215. it('renders 200 on /status', async function () {
  216. const response = await fetch(testServer.testUrl('/status'))
  217. expect(response.status).to.equal(200)
  218. })
  219. it('renders 200 on /health_check', async function () {
  220. const response = await fetch(testServer.testUrl('/health_check'))
  221. expect(response.status).to.equal(200)
  222. })
  223. describe('storage/scripts/verify_project.mjs', function () {
  224. describe('when the project is appropriately backed up', function () {
  225. it('should return 0', async function () {
  226. const response = await verifyProjectScript(historyIdPostgres, false)
  227. expect(response.status).to.equal(0)
  228. })
  229. })
  230. describe('when the project chunk is not backed up', function () {
  231. let response
  232. beforeEach(async function () {
  233. await prepareProjectAndBlob('000000000000000000000043', {
  234. shouldBackupChunk: false,
  235. shouldBackupBlob: true,
  236. shouldCreateChunk: true,
  237. })
  238. response = await verifyProjectScript('000000000000000000000043')
  239. })
  240. it('should return 1', async function () {
  241. expect(response.status).to.equal(1)
  242. })
  243. it('should emit an error message referring to a missing chunk', async function () {
  244. const stderr = response.stderr
  245. expect(stderr).to.include('BackupRPOViolationChunkNotBackedUpError')
  246. })
  247. })
  248. describe('when a project blob is not backed up', function () {
  249. let response
  250. beforeEach(async function () {
  251. await prepareProjectAndBlob('43', {
  252. shouldBackupChunk: true,
  253. shouldBackupBlob: false,
  254. shouldCreateChunk: true,
  255. })
  256. response = await verifyProjectScript('43')
  257. })
  258. it('should return 1', function () {
  259. expect(response.status).to.equal(1)
  260. })
  261. it('includes a BackupCorruptedError in stderr', function () {
  262. expect(response.stderr).to.include(
  263. 'BackupCorruptedMissingBlobError: missing blob'
  264. )
  265. })
  266. })
  267. })
  268. describe('storage/scripts/verify_backup_blob.mjs', function () {
  269. it('throws and does not create DEK if missing', async function () {
  270. const historyId = '404'
  271. const hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  272. const response = await verifyBlobScript(historyId, hash)
  273. expect(response.status).to.equal(1)
  274. expect(response.stdout).to.include('dek does not exist')
  275. await expect(checkDEKExists(historyId)).to.be.rejectedWith(NotFoundError)
  276. })
  277. it('throws when deleted in db', async function () {
  278. const blobStore = new BlobStore(historyIdPostgres)
  279. await blobStore.deleteBlobs()
  280. const response = await verifyBlobScript(historyIdPostgres, blobHashPG)
  281. expect(response.status).to.equal(1)
  282. expect(response.stdout).to.include(`blob ${blobHashPG} not found`)
  283. })
  284. it('throws when not existing', async function () {
  285. await backupPersistor.deleteObject(projectBlobsBucket, blobPathPG)
  286. const result = await verifyBlobScript(historyIdPostgres, blobHashPG)
  287. expect(result.status).to.equal(1)
  288. expect(result.stdout).to.include('missing blob')
  289. })
  290. it('throws when corrupted', async function () {
  291. await backupPersistor.sendStream(
  292. projectBlobsBucket,
  293. blobPathPG,
  294. Stream.Readable.from(['something else']),
  295. { contentLength: 14 }
  296. )
  297. const result = await verifyBlobScript(historyIdPostgres, blobHashPG)
  298. expect(result.status).to.equal(1)
  299. expect(result.stdout).to.include('hash mismatch for backed up blob')
  300. })
  301. it('should successfully verify from postgres', async function () {
  302. const result = await verifyBlobScript(
  303. historyIdPostgres,
  304. blobHashPG,
  305. false
  306. )
  307. expect(result.status).to.equal(0)
  308. expect(result.stdout.split('\n')).to.include('OK')
  309. })
  310. it('should successfully verify from mongo', async function () {
  311. const result = await verifyBlobScript(
  312. historyIdMongo,
  313. blobHashMongo,
  314. false
  315. )
  316. expect(result.status).to.equal(0)
  317. expect(result.stdout.split('\n')).to.include('OK')
  318. })
  319. })
  320. describe('GET /history/:historyId/blob/:hash/verify', function () {
  321. it('returns 404 when deleted in db', async function () {
  322. const blobStore = new BlobStore(historyIdPostgres)
  323. await blobStore.deleteBlobs()
  324. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  325. expect(response.status).to.equal(404)
  326. expect(await response.text()).to.equal(`blob ${blobHashPG} not found`)
  327. })
  328. it('returns 422 and does not create DEK if missing', async function () {
  329. const historyId = '404'
  330. const hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  331. const response = await verifyBlobHTTP(historyId, hash)
  332. expect(response.status).to.equal(422)
  333. expect(await response.text()).to.equal('dek does not exist')
  334. await expect(checkDEKExists(historyId)).to.be.rejectedWith(NotFoundError)
  335. })
  336. it('returns 422 when not existing', async function () {
  337. await backupPersistor.deleteObject(projectBlobsBucket, blobPathPG)
  338. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  339. expect(response.status).to.equal(422)
  340. expect(await response.text()).to.equal('missing blob')
  341. })
  342. it('returns 422 when corrupted', async function () {
  343. await backupPersistor.sendStream(
  344. projectBlobsBucket,
  345. blobPathPG,
  346. Stream.Readable.from(['something else']),
  347. { contentLength: 14 }
  348. )
  349. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  350. expect(response.status).to.equal(422)
  351. expect(await response.text()).to.equal('hash mismatch for backed up blob')
  352. })
  353. it('should successfully verify from postgres', async function () {
  354. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  355. expect(response.status).to.equal(200)
  356. })
  357. it('should successfully verify from mongo', async function () {
  358. const response = await verifyBlobHTTP(historyIdMongo, blobHashMongo)
  359. expect(response.status).to.equal(200)
  360. })
  361. })
  362. })