backupDeletion.test.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. // @ts-check
  2. import cleanup from '../storage/support/cleanup.js'
  3. import fetch from 'node-fetch'
  4. import testServer from './support/test_backup_deletion_server.mjs'
  5. import { expect } from 'chai'
  6. import testProjects from './support/test_projects.js'
  7. import { db } from '../../../../storage/lib/mongodb.js'
  8. import { ObjectId } from 'mongodb'
  9. import {
  10. backupPersistor,
  11. projectBlobsBucket,
  12. chunksBucket,
  13. } from '../../../../storage/lib/backupPersistor.mjs'
  14. import { makeProjectKey } from '../../../../storage/lib/blob_store/index.js'
  15. import config from 'config'
  16. import Stream from 'node:stream'
  17. import projectKey from '@overleaf/object-persistor/src/ProjectKey.js'
  18. import { ListObjectsV2Command } from '@aws-sdk/client-s3'
  19. /**
  20. * @typedef {import("node-fetch").Response} Response
  21. */
  22. const { deksBucket } = config.get('backupStore')
  23. const deletedProjectsCollection = db.collection('deletedProjects')
  24. /**
  25. * @param {string} bucket
  26. * @param {string} prefix
  27. * @return {Promise<Array<string>>}
  28. */
  29. async function listS3Bucket(bucket, prefix) {
  30. // @ts-ignore access to internal library helper
  31. const client = backupPersistor._getClientForBucket(bucket)
  32. const response = await client.send(
  33. new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix })
  34. )
  35. return (response.Contents || []).map(item => item.Key || '')
  36. }
  37. /**
  38. * @param {ObjectId} projectId
  39. * @return {Promise<Response>}
  40. */
  41. async function deleteProject(projectId) {
  42. return await fetch(testServer.testUrl(`/project/${projectId}/backup`), {
  43. method: 'DELETE',
  44. headers: { Authorization: testServer.basicAuthHeader },
  45. })
  46. }
  47. /**
  48. * @param {number|ObjectId} historyId
  49. * @return {Promise<void>}
  50. */
  51. async function expectToHaveBackup(historyId) {
  52. const prefix = projectKey.format(historyId.toString()) + '/'
  53. expect(await listS3Bucket(deksBucket, prefix)).to.have.length(1)
  54. expect(await listS3Bucket(chunksBucket, prefix)).to.have.length(2)
  55. expect(await listS3Bucket(projectBlobsBucket, prefix)).to.have.length(2)
  56. }
  57. /**
  58. * @param {number|ObjectId} historyId
  59. * @return {Promise<void>}
  60. */
  61. async function expectToHaveNoBackup(historyId) {
  62. const prefix = projectKey.format(historyId.toString()) + '/'
  63. expect(await listS3Bucket(deksBucket, prefix)).to.have.length(0)
  64. expect(await listS3Bucket(chunksBucket, prefix)).to.have.length(0)
  65. expect(await listS3Bucket(projectBlobsBucket, prefix)).to.have.length(0)
  66. }
  67. describe('backupDeletion', function () {
  68. beforeEach(cleanup.everything)
  69. beforeEach('create health check projects', async function () {
  70. await testProjects.createEmptyProject('42')
  71. await testProjects.createEmptyProject('000000000000000000000042')
  72. })
  73. beforeEach(testServer.listenOnRandomPort)
  74. it('renders 200 on /status', async function () {
  75. const response = await fetch(testServer.testUrl('/status'))
  76. expect(response.status).to.equal(200)
  77. })
  78. it('renders 200 on /health_check', async function () {
  79. const response = await fetch(testServer.testUrl('/health_check'))
  80. expect(response.status).to.equal(200)
  81. })
  82. describe('DELETE /project/:projectId', function () {
  83. const postgresHistoryId = 1
  84. const projectIdPostgres = new ObjectId('000000000000000000000001')
  85. const projectIdMongoDB = new ObjectId('000000000000000000000002')
  86. const projectIdNonDeleted = new ObjectId('000000000000000000000003')
  87. const projectIdNonExpired = new ObjectId('000000000000000000000004')
  88. const projectIdWithChunks = new ObjectId('000000000000000000000005')
  89. const projectIdNoHistoryId = new ObjectId('000000000000000000000006')
  90. beforeEach('populate mongo', async function () {
  91. await deletedProjectsCollection.insertMany([
  92. {
  93. _id: new ObjectId(),
  94. deleterData: {
  95. deletedProjectId: projectIdPostgres,
  96. deletedAt: new Date('2024-01-01T00:00:00Z'),
  97. deletedProjectOverleafHistoryId: postgresHistoryId,
  98. },
  99. },
  100. {
  101. _id: new ObjectId(),
  102. deleterData: {
  103. deletedProjectId: projectIdNonExpired,
  104. deletedAt: new Date(),
  105. deletedProjectOverleafHistoryId: projectIdNonExpired.toString(),
  106. },
  107. },
  108. {
  109. _id: new ObjectId(),
  110. deleterData: {
  111. deletedProjectId: projectIdNoHistoryId,
  112. deletedAt: new Date('2024-01-01T00:00:00Z'),
  113. },
  114. },
  115. ...[projectIdMongoDB, projectIdWithChunks].map(projectId => {
  116. return {
  117. _id: new ObjectId(),
  118. deleterData: {
  119. deletedProjectId: projectId,
  120. deletedAt: new Date('2024-01-01T00:00:00Z'),
  121. deletedProjectOverleafHistoryId: projectId.toString(),
  122. },
  123. }
  124. }),
  125. ])
  126. })
  127. beforeEach('initialize history', async function () {
  128. await testProjects.createEmptyProject(projectIdWithChunks.toString())
  129. })
  130. beforeEach('create a file in s3', async function () {
  131. const historyIds = [
  132. postgresHistoryId,
  133. projectIdMongoDB,
  134. projectIdNonDeleted,
  135. projectIdNonExpired,
  136. projectIdWithChunks,
  137. projectIdNoHistoryId,
  138. ]
  139. const jobs = []
  140. for (const historyId of historyIds) {
  141. jobs.push(
  142. backupPersistor.sendStream(
  143. projectBlobsBucket,
  144. makeProjectKey(historyId, 'a'.repeat(40)),
  145. Stream.Readable.from(['blob a']),
  146. { contentLength: 6 }
  147. )
  148. )
  149. jobs.push(
  150. backupPersistor.sendStream(
  151. projectBlobsBucket,
  152. makeProjectKey(historyId, 'b'.repeat(40)),
  153. Stream.Readable.from(['blob b']),
  154. { contentLength: 6 }
  155. )
  156. )
  157. jobs.push(
  158. backupPersistor.sendStream(
  159. chunksBucket,
  160. projectKey.format(historyId) + '/111',
  161. Stream.Readable.from(['chunk 1']),
  162. { contentLength: 7 }
  163. )
  164. )
  165. jobs.push(
  166. backupPersistor.sendStream(
  167. chunksBucket,
  168. projectKey.format(historyId) + '/222',
  169. Stream.Readable.from(['chunk 2']),
  170. { contentLength: 7 }
  171. )
  172. )
  173. }
  174. await Promise.all(jobs)
  175. })
  176. it('renders 401 without auth', async function () {
  177. const response = await fetch(
  178. testServer.testUrl('/project/000000000000000000000042/backup'),
  179. { method: 'DELETE' }
  180. )
  181. expect(response.status).to.equal(401)
  182. expect(response.headers.get('www-authenticate')).to.match(/^Basic/)
  183. })
  184. it('returns 422 when not deleted', async function () {
  185. const response = await deleteProject(projectIdNonDeleted)
  186. expect(response.status).to.equal(422)
  187. expect(await response.text()).to.equal(
  188. 'refusing to delete non-deleted project'
  189. )
  190. await expectToHaveBackup(projectIdNonDeleted)
  191. })
  192. it('returns 422 when not expired', async function () {
  193. const response = await deleteProject(projectIdNonExpired)
  194. expect(response.status).to.equal(422)
  195. expect(await response.text()).to.equal(
  196. 'refusing to delete non-expired project'
  197. )
  198. await expectToHaveBackup(projectIdNonExpired)
  199. })
  200. it('returns 422 when live-history not deleted', async function () {
  201. const response = await deleteProject(projectIdWithChunks)
  202. expect(response.status).to.equal(422)
  203. expect(await response.text()).to.equal(
  204. 'refusing to delete project with remaining chunks'
  205. )
  206. await expectToHaveBackup(projectIdWithChunks)
  207. })
  208. it('returns 422 when historyId is unknown', async function () {
  209. const response = await deleteProject(projectIdNoHistoryId)
  210. expect(response.status).to.equal(422)
  211. expect(await response.text()).to.equal(
  212. 'refusing to delete project with unknown historyId'
  213. )
  214. await expectToHaveBackup(projectIdNoHistoryId)
  215. })
  216. it('should successfully delete postgres id', async function () {
  217. await expectToHaveBackup(postgresHistoryId)
  218. const response = await deleteProject(projectIdPostgres)
  219. expect(response.status).to.equal(204)
  220. await expectToHaveNoBackup(postgresHistoryId)
  221. })
  222. it('should successfully delete mongo id', async function () {
  223. await expectToHaveBackup(projectIdMongoDB)
  224. const response = await deleteProject(projectIdMongoDB)
  225. expect(response.status).to.equal(204)
  226. await expectToHaveNoBackup(projectIdMongoDB)
  227. })
  228. })
  229. })