backupDeletion.test.mjs 8.2 KB

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