backupVerifier.test.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  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. await backupPersistor.sendStream(
  130. chunksBucket,
  131. path.join(
  132. projectKey.format(historyId),
  133. projectKey.pad(newChunkMetadata.startVersion)
  134. ),
  135. Stream.Readable.from([chunkBuffer]),
  136. {
  137. contentType: 'application/json',
  138. contentEncoding: 'gzip',
  139. contentLength: chunkBuffer.byteLength,
  140. }
  141. )
  142. }
  143. const FIFTEEN_MINUTES_IN_MS = 900_000
  144. async function addFileInNewChunk(
  145. fileContents,
  146. filePath,
  147. historyId,
  148. { creationDate = new Date() }
  149. ) {
  150. const chunk = await chunkStore.loadLatest(historyId, { persistedOnly: true })
  151. const operation = Operation.addFile(filePath, File.fromString(fileContents))
  152. const changes = [new Change([operation], creationDate, [])]
  153. chunk.pushChanges(changes)
  154. await chunkStore.update(historyId, chunk)
  155. }
  156. /**
  157. * @param {string} historyId
  158. * @param {Object} [backup]
  159. * @return {Promise<string>}
  160. */
  161. async function prepareProjectAndBlob(
  162. historyId,
  163. {
  164. shouldBackupBlob = true,
  165. shouldBackupChunk = true,
  166. shouldCreateChunk = true,
  167. extraChunks = 0, // number of additional chunks to create after the first one
  168. skipBackupForLastChunk = false, // when true, do not back up the last created chunk (simulates missing last chunk backup)
  169. } = {}
  170. ) {
  171. await testProjects.createEmptyProject(historyId)
  172. const blobStore = new BlobStore(historyId)
  173. const fileContents = historyId
  174. const initialBlob = await blobStore.putString(fileContents)
  175. const now = Date.now()
  176. const creationTime = now - FIFTEEN_MINUTES_IN_MS
  177. // Create first (updated) chunk if requested
  178. if (shouldCreateChunk) {
  179. await addFileInNewChunk(fileContents, `${historyId}.txt`, historyId, {
  180. creationDate: new Date(creationTime),
  181. })
  182. }
  183. // Backup the initial blob if requested
  184. if (shouldBackupBlob) {
  185. const gzipped = zlib.gzipSync(Buffer.from(fileContents))
  186. await backupPersistor.sendStream(
  187. projectBlobsBucket,
  188. makeProjectKey(historyId, initialBlob.getHash()),
  189. Stream.Readable.from([gzipped]),
  190. { contentLength: gzipped.byteLength, contentEncoding: 'gzip' }
  191. )
  192. await checkDEKExists(historyId)
  193. }
  194. // Backup first chunk if requested
  195. if (shouldCreateChunk && shouldBackupChunk) {
  196. await backupChunk(historyId)
  197. }
  198. for (let index = 0; index < extraChunks; index++) {
  199. // Create an additional chunk starting at current endVersion
  200. const latestMeta = await chunkStore.getLatestChunkMetadata(historyId)
  201. const startVersion = latestMeta.endVersion
  202. const snapshot = Snapshot.fromRaw({ files: {} })
  203. const history = new History(snapshot, [])
  204. const newChunk = new Chunk(history, startVersion)
  205. const extraContent = `${fileContents}-extra-${index + 1}`
  206. const extraBlob = await blobStore.putString(extraContent)
  207. // ensure strictly increasing timestamps
  208. const changeTimestamp = new Date(creationTime + (index + 1) * 60_000)
  209. const change = new Change(
  210. [
  211. Operation.addFile(
  212. `${historyId}-extra-${index + 1}.txt`,
  213. File.createLazyFromBlobs(extraBlob)
  214. ),
  215. ],
  216. changeTimestamp,
  217. []
  218. )
  219. newChunk.pushChanges([change])
  220. await chunkStore.create(historyId, newChunk)
  221. // Backup blob for this chunk if requested
  222. if (shouldBackupBlob) {
  223. const gzipped = zlib.gzipSync(Buffer.from(extraContent))
  224. await backupPersistor.sendStream(
  225. projectBlobsBucket,
  226. makeProjectKey(historyId, extraBlob.getHash()),
  227. Stream.Readable.from([gzipped]),
  228. { contentLength: gzipped.byteLength, contentEncoding: 'gzip' }
  229. )
  230. }
  231. // Backup the chunk unless we're intentionally skipping the last one
  232. const isLast = index === extraChunks - 1
  233. if (shouldBackupChunk && (!isLast || !skipBackupForLastChunk)) {
  234. await backupChunk(historyId)
  235. }
  236. }
  237. return initialBlob.getHash()
  238. }
  239. /**
  240. * @param {string} historyId
  241. * @return {Promise<void>}
  242. */
  243. async function checkDEKExists(historyId) {
  244. await backupPersistor.forProjectRO(
  245. projectBlobsBucket,
  246. makeProjectKey(historyId, '')
  247. )
  248. }
  249. describe('backupVerifier', function () {
  250. this.timeout(5_000 + SCRIPT_TIMEOUT) // allow time for external scripts to run
  251. const historyIdPostgres = '42'
  252. const historyIdMongo = '000000000000000000000042'
  253. let blobHashPG, blobHashMongo, blobPathPG
  254. beforeEach(cleanup.everything)
  255. beforeEach('create health check projects', async function () {
  256. ;[blobHashPG, blobHashMongo] = await Promise.all([
  257. prepareProjectAndBlob('42'),
  258. prepareProjectAndBlob('000000000000000000000042'),
  259. ])
  260. blobPathPG = makeProjectKey(historyIdPostgres, blobHashPG)
  261. })
  262. beforeEach(testServer.listenOnRandomPort)
  263. it('renders 200 on /status', async function () {
  264. const response = await fetch(testServer.testUrl('/status'))
  265. expect(response.status).to.equal(200)
  266. })
  267. it('renders 200 on /health_check', async function () {
  268. const response = await fetch(testServer.testUrl('/health_check'))
  269. expect(response.status).to.equal(200)
  270. })
  271. describe('storage/scripts/verify_project.mjs', function () {
  272. describe('when the project is appropriately backed up', function () {
  273. it('should return 0', async function () {
  274. const response = await verifyProjectScript(historyIdPostgres, false)
  275. expect(response.status).to.equal(0)
  276. })
  277. })
  278. describe('when the project chunk is not backed up', function () {
  279. let response
  280. beforeEach(async function () {
  281. await prepareProjectAndBlob('000000000000000000000043', {
  282. shouldBackupChunk: false,
  283. shouldBackupBlob: true,
  284. shouldCreateChunk: true,
  285. })
  286. response = await verifyProjectScript('000000000000000000000043')
  287. })
  288. it('should return 1', async function () {
  289. expect(response.status).to.equal(1)
  290. })
  291. it('should emit an error message referring to a missing chunk', async function () {
  292. const stderr = response.stderr
  293. expect(stderr).to.include('BackupRPOViolationChunkNotBackedUpError')
  294. })
  295. })
  296. describe('when a project blob is not backed up', function () {
  297. let response
  298. beforeEach(async function () {
  299. await prepareProjectAndBlob('43', {
  300. shouldBackupChunk: true,
  301. shouldBackupBlob: false,
  302. shouldCreateChunk: true,
  303. })
  304. response = await verifyProjectScript('43')
  305. })
  306. it('should return 1', function () {
  307. expect(response.status).to.equal(1)
  308. })
  309. it('includes a BackupCorruptedError in stderr', function () {
  310. expect(response.stderr).to.include(
  311. 'BackupCorruptedMissingBlobError: missing blob'
  312. )
  313. })
  314. })
  315. describe('for a project with multiple chunks', function () {
  316. const multiHistoryIdOk = '100'
  317. const multiHistoryIdMissingLast = '101'
  318. let okResponse
  319. let missingLastResponse
  320. describe('when multiple chunks are fully backed up', function () {
  321. beforeEach(async function () {
  322. await prepareProjectAndBlob(multiHistoryIdOk, {
  323. shouldBackupBlob: true,
  324. shouldBackupChunk: true,
  325. shouldCreateChunk: true,
  326. extraChunks: 2, // total 3 chunks including first
  327. })
  328. okResponse = await verifyProjectScript(multiHistoryIdOk, false)
  329. })
  330. it('returns 0', function () {
  331. expect(okResponse.status).to.equal(0)
  332. })
  333. })
  334. describe('when the last chunk backup is missing', function () {
  335. beforeEach(async function () {
  336. await prepareProjectAndBlob(multiHistoryIdMissingLast, {
  337. shouldBackupBlob: true,
  338. shouldBackupChunk: true,
  339. shouldCreateChunk: true,
  340. extraChunks: 2,
  341. skipBackupForLastChunk: true, // simulate missing backup for last chunk
  342. })
  343. missingLastResponse = await verifyProjectScript(
  344. multiHistoryIdMissingLast
  345. )
  346. })
  347. it('returns 1', function () {
  348. expect(missingLastResponse.status).to.equal(1)
  349. })
  350. it('emits a BackupRPOViolationChunkNotBackedUpError', function () {
  351. expect(missingLastResponse.stderr).to.include(
  352. 'BackupRPOViolationChunkNotBackedUpError'
  353. )
  354. })
  355. })
  356. })
  357. })
  358. describe('storage/scripts/verify_backup_blob.mjs', function () {
  359. it('throws and does not create DEK if missing', async function () {
  360. const historyId = '404'
  361. const hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  362. const response = await verifyBlobScript(historyId, hash)
  363. expect(response.status).to.equal(1)
  364. expect(response.stdout).to.include('dek does not exist')
  365. await expect(checkDEKExists(historyId)).to.be.rejectedWith(NotFoundError)
  366. })
  367. it('throws when deleted in db', async function () {
  368. const blobStore = new BlobStore(historyIdPostgres)
  369. await blobStore.deleteBlobs()
  370. const response = await verifyBlobScript(historyIdPostgres, blobHashPG)
  371. expect(response.status).to.equal(1)
  372. expect(response.stdout).to.include(`blob ${blobHashPG} not found`)
  373. })
  374. it('throws when not existing', async function () {
  375. await backupPersistor.deleteObject(projectBlobsBucket, blobPathPG)
  376. const result = await verifyBlobScript(historyIdPostgres, blobHashPG)
  377. expect(result.status).to.equal(1)
  378. expect(result.stdout).to.include('missing blob')
  379. })
  380. it('throws when corrupted', async function () {
  381. await backupPersistor.sendStream(
  382. projectBlobsBucket,
  383. blobPathPG,
  384. Stream.Readable.from(['something else']),
  385. { contentLength: 14 }
  386. )
  387. const result = await verifyBlobScript(historyIdPostgres, blobHashPG)
  388. expect(result.status).to.equal(1)
  389. expect(result.stdout).to.include('hash mismatch for backed up blob')
  390. })
  391. it('should successfully verify from postgres', async function () {
  392. const result = await verifyBlobScript(
  393. historyIdPostgres,
  394. blobHashPG,
  395. false
  396. )
  397. expect(result.status).to.equal(0)
  398. expect(result.stdout.split('\n')).to.include('OK')
  399. })
  400. it('should successfully verify from mongo', async function () {
  401. const result = await verifyBlobScript(
  402. historyIdMongo,
  403. blobHashMongo,
  404. false
  405. )
  406. expect(result.status).to.equal(0)
  407. expect(result.stdout.split('\n')).to.include('OK')
  408. })
  409. })
  410. describe('GET /history/:historyId/blob/:hash/verify', function () {
  411. it('returns 404 when deleted in db', async function () {
  412. const blobStore = new BlobStore(historyIdPostgres)
  413. await blobStore.deleteBlobs()
  414. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  415. expect(response.status).to.equal(404)
  416. expect(await response.text()).to.equal(`blob ${blobHashPG} not found`)
  417. })
  418. it('returns 422 and does not create DEK if missing', async function () {
  419. const historyId = '404'
  420. const hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  421. const response = await verifyBlobHTTP(historyId, hash)
  422. expect(response.status).to.equal(422)
  423. expect(await response.text()).to.equal('dek does not exist')
  424. await expect(checkDEKExists(historyId)).to.be.rejectedWith(NotFoundError)
  425. })
  426. it('returns 422 when not existing', async function () {
  427. await backupPersistor.deleteObject(projectBlobsBucket, blobPathPG)
  428. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  429. expect(response.status).to.equal(422)
  430. expect(await response.text()).to.equal('missing blob')
  431. })
  432. it('returns 422 when corrupted', async function () {
  433. await backupPersistor.sendStream(
  434. projectBlobsBucket,
  435. blobPathPG,
  436. Stream.Readable.from(['something else']),
  437. { contentLength: 14 }
  438. )
  439. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  440. expect(response.status).to.equal(422)
  441. expect(await response.text()).to.equal('hash mismatch for backed up blob')
  442. })
  443. it('should successfully verify from postgres', async function () {
  444. const response = await verifyBlobHTTP(historyIdPostgres, blobHashPG)
  445. expect(response.status).to.equal(200)
  446. })
  447. it('should successfully verify from mongo', async function () {
  448. const response = await verifyBlobHTTP(historyIdMongo, blobHashMongo)
  449. expect(response.status).to.equal(200)
  450. })
  451. })
  452. })