backupVerifier.test.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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 {
  23. Change,
  24. File,
  25. Operation,
  26. Snapshot,
  27. History,
  28. Chunk,
  29. } from 'overleaf-editor-core'
  30. import Crypto from 'node:crypto'
  31. import path from 'node:path'
  32. import projectKey from '../../../../storage/lib/project_key.js'
  33. import { historyStore } from '../../../../storage/lib/history_store.js'
  34. /**
  35. * @typedef {import("node-fetch").Response} Response
  36. * @typedef {import("overleaf-editor-core").Blob} Blob
  37. */
  38. // Timeout for script execution, increased to avoid flaky tests
  39. const SCRIPT_TIMEOUT = 15_000
  40. async function verifyProjectScript(historyId, expectFail = true) {
  41. try {
  42. const result = await promisify(execFile)(
  43. process.argv0,
  44. ['storage/scripts/verify_project.mjs', `--historyId=${historyId}`],
  45. {
  46. encoding: 'utf-8',
  47. timeout: SCRIPT_TIMEOUT,
  48. env: {
  49. ...process.env,
  50. LOG_LEVEL: 'warn',
  51. },
  52. }
  53. )
  54. return { status: 0, stdout: result.stdout, stderr: result.stderr }
  55. } catch (err) {
  56. if (
  57. err &&
  58. typeof err === 'object' &&
  59. 'stdout' in err &&
  60. 'code' in err &&
  61. 'stderr' in err
  62. ) {
  63. if (!expectFail) {
  64. console.log(err)
  65. }
  66. return {
  67. stdout: typeof err.stdout === 'string' ? err.stdout : '',
  68. status: typeof err.code === 'number' ? err.code : -1,
  69. stderr: typeof err.stdout === 'string' ? err.stderr : '',
  70. }
  71. }
  72. throw err
  73. }
  74. }
  75. /**
  76. * @param {string} historyId
  77. * @param {string} hash
  78. * @return {Promise<{stdout: string, status:number }>}
  79. */
  80. async function verifyBlobScript(historyId, hash, expectFail = true) {
  81. try {
  82. const result = await promisify(execFile)(
  83. process.argv0,
  84. [
  85. 'storage/scripts/verify_backup_blob.mjs',
  86. `--historyId=${historyId}`,
  87. hash,
  88. ],
  89. {
  90. encoding: 'utf-8',
  91. timeout: SCRIPT_TIMEOUT,
  92. env: {
  93. ...process.env,
  94. LOG_LEVEL: 'warn',
  95. },
  96. }
  97. )
  98. return { status: 0, stdout: result.stdout }
  99. } catch (err) {
  100. if (err && typeof err === 'object' && 'stdout' in err && 'code' in err) {
  101. if (!expectFail) {
  102. console.log(err)
  103. }
  104. return {
  105. stdout: typeof err.stdout === 'string' ? err.stdout : '',
  106. status: typeof err.code === 'number' ? err.code : -1,
  107. }
  108. }
  109. throw err
  110. }
  111. }
  112. /**
  113. * @param {string} historyId
  114. * @param {string} hash
  115. * @return {Promise<Response>}
  116. */
  117. async function verifyBlobHTTP(historyId, hash) {
  118. return await fetch(
  119. testServer.testUrl(`/history/${historyId}/blob/${hash}/verify`),
  120. { method: 'GET' }
  121. )
  122. }
  123. async function backupChunk(historyId) {
  124. const newChunkMetadata = await chunkStore.getLatestChunkMetadata(historyId)
  125. const { buffer: chunkBuffer } = await historyStore.loadRawWithBuffer(
  126. historyId,
  127. newChunkMetadata.id
  128. )
  129. const md5 = Crypto.createHash('md5').update(chunkBuffer)
  130. await backupPersistor.sendStream(
  131. chunksBucket,
  132. path.join(
  133. projectKey.format(historyId),
  134. projectKey.pad(newChunkMetadata.startVersion)
  135. ),
  136. Stream.Readable.from([chunkBuffer]),
  137. {
  138. contentType: 'application/json',
  139. contentEncoding: 'gzip',
  140. contentLength: chunkBuffer.byteLength,
  141. sourceMd5: md5.digest('hex'),
  142. }
  143. )
  144. }
  145. const FIFTEEN_MINUTES_IN_MS = 900_000
  146. async function addFileInNewChunk(
  147. fileContents,
  148. filePath,
  149. historyId,
  150. { creationDate = new Date() }
  151. ) {
  152. const chunk = await chunkStore.loadLatest(historyId, { persistedOnly: true })
  153. const operation = Operation.addFile(filePath, File.fromString(fileContents))
  154. const changes = [new Change([operation], creationDate, [])]
  155. chunk.pushChanges(changes)
  156. await chunkStore.update(historyId, chunk)
  157. }
  158. /**
  159. * @param {string} historyId
  160. * @param {Object} [backup]
  161. * @return {Promise<string>}
  162. */
  163. async function prepareProjectAndBlob(
  164. historyId,
  165. {
  166. shouldBackupBlob = true,
  167. shouldBackupChunk = true,
  168. shouldCreateChunk = true,
  169. extraChunks = 0, // number of additional chunks to create after the first one
  170. skipBackupForLastChunk = false, // when true, do not back up the last created chunk (simulates missing last chunk backup)
  171. } = {}
  172. ) {
  173. await testProjects.createEmptyProject(historyId)
  174. const blobStore = new BlobStore(historyId)
  175. const fileContents = historyId
  176. const initialBlob = await blobStore.putString(fileContents)
  177. const now = Date.now()
  178. const creationTime = now - FIFTEEN_MINUTES_IN_MS
  179. // Create first (updated) chunk if requested
  180. if (shouldCreateChunk) {
  181. await addFileInNewChunk(fileContents, `${historyId}.txt`, historyId, {
  182. creationDate: new Date(creationTime),
  183. })
  184. }
  185. // Backup the initial blob if requested
  186. if (shouldBackupBlob) {
  187. const gzipped = zlib.gzipSync(Buffer.from(fileContents))
  188. await backupPersistor.sendStream(
  189. projectBlobsBucket,
  190. makeProjectKey(historyId, initialBlob.getHash()),
  191. Stream.Readable.from([gzipped]),
  192. { contentLength: gzipped.byteLength, contentEncoding: 'gzip' }
  193. )
  194. await checkDEKExists(historyId)
  195. }
  196. // Backup first chunk if requested
  197. if (shouldCreateChunk && shouldBackupChunk) {
  198. await backupChunk(historyId)
  199. }
  200. for (let index = 0; index < extraChunks; index++) {
  201. // Create an additional chunk starting at current endVersion
  202. const latestMeta = await chunkStore.getLatestChunkMetadata(historyId)
  203. const startVersion = latestMeta.endVersion
  204. const snapshot = Snapshot.fromRaw({ files: {} })
  205. const history = new History(snapshot, [])
  206. const newChunk = new Chunk(history, startVersion)
  207. const extraContent = `${fileContents}-extra-${index + 1}`
  208. const extraBlob = await blobStore.putString(extraContent)
  209. // ensure strictly increasing timestamps
  210. const changeTimestamp = new Date(creationTime + (index + 1) * 60_000)
  211. const change = new Change(
  212. [
  213. Operation.addFile(
  214. `${historyId}-extra-${index + 1}.txt`,
  215. File.createLazyFromBlobs(extraBlob)
  216. ),
  217. ],
  218. changeTimestamp,
  219. []
  220. )
  221. newChunk.pushChanges([change])
  222. await chunkStore.create(historyId, newChunk)
  223. // Backup blob for this chunk if requested
  224. if (shouldBackupBlob) {
  225. const gzipped = zlib.gzipSync(Buffer.from(extraContent))
  226. await backupPersistor.sendStream(
  227. projectBlobsBucket,
  228. makeProjectKey(historyId, extraBlob.getHash()),
  229. Stream.Readable.from([gzipped]),
  230. { contentLength: gzipped.byteLength, contentEncoding: 'gzip' }
  231. )
  232. }
  233. // Backup the chunk unless we're intentionally skipping the last one
  234. const isLast = index === extraChunks - 1
  235. if (shouldBackupChunk && (!isLast || !skipBackupForLastChunk)) {
  236. await backupChunk(historyId)
  237. }
  238. }
  239. return initialBlob.getHash()
  240. }
  241. /**
  242. * @param {string} historyId
  243. * @return {Promise<void>}
  244. */
  245. async function checkDEKExists(historyId) {
  246. await backupPersistor.forProjectRO(
  247. projectBlobsBucket,
  248. makeProjectKey(historyId, '')
  249. )
  250. }
  251. describe('backupVerifier', function () {
  252. this.timeout(5_000 + SCRIPT_TIMEOUT) // allow time for external scripts to run
  253. const historyIdPostgres = '42'
  254. const historyIdMongo = '000000000000000000000042'
  255. let blobHashPG, blobHashMongo, blobPathPG
  256. beforeEach(cleanup.everything)
  257. beforeEach('create health check projects', async function () {
  258. ;[blobHashPG, blobHashMongo] = await Promise.all([
  259. prepareProjectAndBlob('42'),
  260. prepareProjectAndBlob('000000000000000000000042'),
  261. ])
  262. blobPathPG = makeProjectKey(historyIdPostgres, blobHashPG)
  263. })
  264. beforeEach(testServer.listenOnRandomPort)
  265. it('renders 200 on /status', async function () {
  266. const response = await fetch(testServer.testUrl('/status'))
  267. expect(response.status).to.equal(200)
  268. })
  269. it('renders 200 on /health_check', async function () {
  270. const response = await fetch(testServer.testUrl('/health_check'))
  271. expect(response.status).to.equal(200)
  272. })
  273. describe('storage/scripts/verify_project.mjs', function () {
  274. describe('when the project is appropriately backed up', function () {
  275. it('should return 0', async function () {
  276. const response = await verifyProjectScript(historyIdPostgres, false)
  277. expect(response.status).to.equal(0)
  278. })
  279. })
  280. describe('when the project chunk is not backed up', function () {
  281. let response
  282. beforeEach(async function () {
  283. await prepareProjectAndBlob('000000000000000000000043', {
  284. shouldBackupChunk: false,
  285. shouldBackupBlob: true,
  286. shouldCreateChunk: true,
  287. })
  288. response = await verifyProjectScript('000000000000000000000043')
  289. })
  290. it('should return 1', async function () {
  291. expect(response.status).to.equal(1)
  292. })
  293. it('should emit an error message referring to a missing chunk', async function () {
  294. const stderr = response.stderr
  295. expect(stderr).to.include('BackupRPOViolationChunkNotBackedUpError')
  296. })
  297. })
  298. describe('when a project blob is not backed up', function () {
  299. let response
  300. beforeEach(async function () {
  301. await prepareProjectAndBlob('43', {
  302. shouldBackupChunk: true,
  303. shouldBackupBlob: false,
  304. shouldCreateChunk: true,
  305. })
  306. response = await verifyProjectScript('43')
  307. })
  308. it('should return 1', function () {
  309. expect(response.status).to.equal(1)
  310. })
  311. it('includes a BackupCorruptedError in stderr', function () {
  312. expect(response.stderr).to.include(
  313. 'BackupCorruptedMissingBlobError: missing blob'
  314. )
  315. })
  316. })
  317. describe('for a project with multiple chunks', function () {
  318. const multiHistoryIdOk = '100'
  319. const multiHistoryIdMissingLast = '101'
  320. let okResponse
  321. let missingLastResponse
  322. describe('when multiple chunks are fully backed up', function () {
  323. beforeEach(async function () {
  324. await prepareProjectAndBlob(multiHistoryIdOk, {
  325. shouldBackupBlob: true,
  326. shouldBackupChunk: true,
  327. shouldCreateChunk: true,
  328. extraChunks: 2, // total 3 chunks including first
  329. })
  330. okResponse = await verifyProjectScript(multiHistoryIdOk, false)
  331. })
  332. it('returns 0', function () {
  333. expect(okResponse.status).to.equal(0)
  334. })
  335. })
  336. describe('when the last chunk backup is missing', function () {
  337. beforeEach(async function () {
  338. await prepareProjectAndBlob(multiHistoryIdMissingLast, {
  339. shouldBackupBlob: true,
  340. shouldBackupChunk: true,
  341. shouldCreateChunk: true,
  342. extraChunks: 2,
  343. skipBackupForLastChunk: true, // simulate missing backup for last chunk
  344. })
  345. missingLastResponse = await verifyProjectScript(
  346. multiHistoryIdMissingLast
  347. )
  348. })
  349. it('returns 1', function () {
  350. expect(missingLastResponse.status).to.equal(1)
  351. })
  352. it('emits a BackupRPOViolationChunkNotBackedUpError', function () {
  353. expect(missingLastResponse.stderr).to.include(
  354. 'BackupRPOViolationChunkNotBackedUpError'
  355. )
  356. })
  357. })
  358. })
  359. })
  360. describe('storage/scripts/verify_backup_blob.mjs', function () {
  361. it('throws and does not create DEK if missing', async function () {
  362. const historyId = '404'
  363. const hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  364. const response = await verifyBlobScript(historyId, hash)
  365. expect(response.status).to.equal(1)
  366. expect(response.stdout).to.include('dek does not exist')
  367. await expect(checkDEKExists(historyId)).to.be.rejectedWith(NotFoundError)
  368. })
  369. it('throws when deleted in db', async function () {
  370. const blobStore = new BlobStore(historyIdPostgres)
  371. await blobStore.deleteBlobs()
  372. const response = await verifyBlobScript(historyIdPostgres, blobHashPG)
  373. expect(response.status).to.equal(1)
  374. expect(response.stdout).to.include(`blob ${blobHashPG} not found`)
  375. })
  376. it('throws when not existing', async function () {
  377. await backupPersistor.deleteObject(projectBlobsBucket, blobPathPG)
  378. const result = await verifyBlobScript(historyIdPostgres, blobHashPG)
  379. expect(result.status).to.equal(1)
  380. expect(result.stdout).to.include('missing blob')
  381. })
  382. it('throws when corrupted', async function () {
  383. await backupPersistor.sendStream(
  384. projectBlobsBucket,
  385. blobPathPG,
  386. Stream.Readable.from(['something else']),
  387. { contentLength: 14 }
  388. )
  389. const result = await verifyBlobScript(historyIdPostgres, blobHashPG)
  390. expect(result.status).to.equal(1)
  391. expect(result.stdout).to.include('hash mismatch for backed up blob')
  392. })
  393. it('should successfully verify from postgres', async function () {
  394. const result = await verifyBlobScript(
  395. historyIdPostgres,
  396. blobHashPG,
  397. false
  398. )
  399. expect(result.status).to.equal(0)
  400. expect(result.stdout.split('\n')).to.include('OK')
  401. })
  402. it('should successfully verify from mongo', async function () {
  403. const result = await verifyBlobScript(
  404. historyIdMongo,
  405. blobHashMongo,
  406. false
  407. )
  408. expect(result.status).to.equal(0)
  409. expect(result.stdout.split('\n')).to.include('OK')
  410. })
  411. })
  412. describe('GET /history/:historyId/blob/:hash/verify', function () {
  413. it('returns 404 when deleted in db', async function () {
  414. const blobStore = new BlobStore(historyIdPostgres)
  415. await blobStore.deleteBlobs()
  416. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  417. expect(response.status).to.equal(404)
  418. expect(await response.text()).to.equal(`blob ${blobHashPG} not found`)
  419. })
  420. it('returns 422 and does not create DEK if missing', async function () {
  421. const historyId = '404'
  422. const hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  423. const response = await verifyBlobHTTP(historyId, hash)
  424. expect(response.status).to.equal(422)
  425. expect(await response.text()).to.equal('dek does not exist')
  426. await expect(checkDEKExists(historyId)).to.be.rejectedWith(NotFoundError)
  427. })
  428. it('returns 422 when not existing', async function () {
  429. await backupPersistor.deleteObject(projectBlobsBucket, blobPathPG)
  430. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  431. expect(response.status).to.equal(422)
  432. expect(await response.text()).to.equal('missing blob')
  433. })
  434. it('returns 422 when corrupted', async function () {
  435. await backupPersistor.sendStream(
  436. projectBlobsBucket,
  437. blobPathPG,
  438. Stream.Readable.from(['something else']),
  439. { contentLength: 14 }
  440. )
  441. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  442. expect(response.status).to.equal(422)
  443. expect(await response.text()).to.equal('hash mismatch for backed up blob')
  444. })
  445. it('should successfully verify from postgres', async function () {
  446. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  447. expect(response.status).to.equal(200)
  448. })
  449. it('should successfully verify from mongo', async function () {
  450. const response = await verifyBlobHTTP(historyIdMongo, blobHashMongo)
  451. expect(response.status).to.equal(200)
  452. })
  453. })
  454. })