diff --git a/services/history-v1/api/app/security.js b/services/history-v1/api/app/security.js index c82c3d2683..08d6f030dc 100644 --- a/services/history-v1/api/app/security.js +++ b/services/history-v1/api/app/security.js @@ -105,6 +105,8 @@ function handleJWTAuth(req, authOrSecDef, scopesOrApiKey, next) { next() } +exports.hasValidBasicAuthCredentials = hasValidBasicAuthCredentials + /** * Verify and decode the given JSON Web Token */ diff --git a/services/history-v1/backup-deletion-app.mjs b/services/history-v1/backup-deletion-app.mjs new file mode 100644 index 0000000000..81b2b5b8b9 --- /dev/null +++ b/services/history-v1/backup-deletion-app.mjs @@ -0,0 +1,81 @@ +// @ts-check +// Metrics must be initialized before importing anything else +import '@overleaf/metrics/initialize.js' +import http from 'node:http' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import express from 'express' +import logger from '@overleaf/logger' +import Metrics from '@overleaf/metrics' +import { hasValidBasicAuthCredentials } from './api/app/security.js' +import { + deleteProjectBackupCb, + healthCheck, + healthCheckCb, + NotReadyToDelete, +} from './storage/lib/backupDeletion.mjs' +import { mongodb } from './storage/index.js' + +const app = express() + +logger.initialize('history-v1-backup-deletion') +Metrics.open_sockets.monitor() +Metrics.injectMetricsRoute(app) +app.use(Metrics.http.monitor(logger)) +Metrics.leaked_sockets.monitor(logger) +Metrics.event_loop.monitor(logger) +Metrics.memory.monitor(logger) + +function basicAuth(req, res, next) { + if (hasValidBasicAuthCredentials(req)) return next() + res.setHeader('WWW-Authenticate', 'Basic realm="Application"') + res.sendStatus(401) +} + +app.delete('/project/:projectId/backup', basicAuth, (req, res, next) => { + deleteProjectBackupCb(req.params.projectId, err => { + if (err) { + return next(err) + } + res.sendStatus(204) + }) +}) + +app.get('/status', (req, res) => { + res.send('history-v1-backup-deletion is up') +}) + +app.get('/health_check', (req, res, next) => { + healthCheckCb(err => { + if (err) return next(err) + res.sendStatus(200) + }) +}) + +app.use((err, req, res, next) => { + req.logger.addFields({ err }) + if (err instanceof NotReadyToDelete) { + req.logger.setLevel('warn') + return res.status(422).send(err.message) + } + req.logger.setLevel('error') + next(err) +}) + +/** + * @param {number} port + * @return {Promise} + */ +export async function startApp(port) { + await mongodb.client.connect() + await healthCheck() + const server = http.createServer(app) + await promisify(server.listen.bind(server, port))() + return server +} + +// Run this if we're called directly +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const PORT = parseInt(process.env.PORT || '3101', 10) + await startApp(PORT) +} diff --git a/services/history-v1/buildscript.txt b/services/history-v1/buildscript.txt index 09b2c7f137..32019c60c8 100644 --- a/services/history-v1/buildscript.txt +++ b/services/history-v1/buildscript.txt @@ -7,4 +7,4 @@ history-v1 --node-version=20.18.0 --public-repo=False --script-version=4.5.0 ---tsconfig-extra-includes=api/**/*,migrations/**/*,storage/**/* +--tsconfig-extra-includes=backup-deletion-app.mjs,api/**/*,migrations/**/*,storage/**/* diff --git a/services/history-v1/config/custom-environment-variables.json b/services/history-v1/config/custom-environment-variables.json index 6adeb78c4f..d12d82d9f5 100644 --- a/services/history-v1/config/custom-environment-variables.json +++ b/services/history-v1/config/custom-environment-variables.json @@ -63,6 +63,8 @@ "globalBlobsBucket":"BACKUP_OVERLEAF_EDITOR_GLOBAL_BLOBS_BUCKET", "projectBlobsBucket":"BACKUP_OVERLEAF_EDITOR_PROJECT_BLOBS_BUCKET" }, + "healthCheckProjects": "HEALTH_CHECK_PROJECTS", + "minSoftDeletionPeriodDays": "MIN_SOFT_DELETION_PERIOD_DAYS", "mongo": { "uri": "MONGO_CONNECTION_STRING" }, diff --git a/services/history-v1/config/default.json b/services/history-v1/config/default.json index 87e8e8ab3a..690e376c67 100644 --- a/services/history-v1/config/default.json +++ b/services/history-v1/config/default.json @@ -29,6 +29,7 @@ "zipStore": { "zipTimeoutMs": "360000" }, + "minSoftDeletionPeriodDays": "90", "maxDeleteKeys": "1000", "useDeleteObjects": "true", "clusterWorkers": "1", diff --git a/services/history-v1/config/test.json b/services/history-v1/config/test.json index 3550fcc0fd..89ede8bbb8 100644 --- a/services/history-v1/config/test.json +++ b/services/history-v1/config/test.json @@ -33,6 +33,7 @@ }, "tieringStorageClass": "REDUCED_REDUNDANCY" }, + "healthCheckProjects": "[\"42\",\"000000000000000000000042\"]", "maxDeleteKeys": "3", "useDeleteObjects": "false", "mongo": { diff --git a/services/history-v1/storage/lib/backupDeletion.mjs b/services/history-v1/storage/lib/backupDeletion.mjs new file mode 100644 index 0000000000..dfbc178281 --- /dev/null +++ b/services/history-v1/storage/lib/backupDeletion.mjs @@ -0,0 +1,86 @@ +// @ts-check +import { callbackify } from 'util' +import { ObjectId } from 'mongodb' +import config from 'config' +import OError from '@overleaf/o-error' +import { db } from './mongodb.js' +import projectKey from './project_key.js' +import chunkStore from '../lib/chunk_store/index.js' +import { + backupPersistor, + chunksBucket, + projectBlobsBucket, +} from './backupPersistor.mjs' + +const MS_PER_DAY = 24 * 60 * 60 * 1000 +const EXPIRE_PROJECTS_AFTER_MS = + parseInt(config.get('minSoftDeletionPeriodDays'), 10) * MS_PER_DAY +const deletedProjectsCollection = db.collection('deletedProjects') + +/** + * @param {string} historyId + * @return {Promise} + */ +async function projectHasLatestChunk(historyId) { + const chunk = await chunkStore.getBackend(historyId).getLatestChunk(historyId) + return chunk != null +} + +export class NotReadyToDelete extends OError {} + +/** + * @param {string} projectId + * @return {Promise} + */ +async function deleteProjectBackup(projectId) { + const deletedProject = await deletedProjectsCollection.findOne( + { 'deleterData.deletedProjectId': new ObjectId(projectId) }, + { + projection: { + 'project.overleaf.history.id': 1, + 'deleterData.deletedAt': 1, + }, + } + ) + if (!deletedProject) { + throw new NotReadyToDelete('refusing to delete non-deleted project') + } + const expiresAt = + deletedProject.deleterData.deletedAt.getTime() + EXPIRE_PROJECTS_AFTER_MS + if (expiresAt > Date.now()) { + throw new NotReadyToDelete('refusing to delete non-expired project') + } + + const historyId = deletedProject.project.overleaf.history.id + if (await projectHasLatestChunk(historyId)) { + throw new NotReadyToDelete( + 'refusing to delete project with remaining chunks' + ) + } + + const prefix = projectKey.format(historyId) + '/' + await backupPersistor.deleteDirectory(chunksBucket, prefix) + await backupPersistor.deleteDirectory(projectBlobsBucket, prefix) +} + +export async function healthCheck() { + const HEALTH_CHECK_PROJECTS = JSON.parse(config.get('healthCheckProjects')) + if (HEALTH_CHECK_PROJECTS.length !== 2) { + throw new Error('expected 2 healthCheckProjects') + } + if (!HEALTH_CHECK_PROJECTS.some(id => id.length === 24)) { + throw new Error('expected mongo id in healthCheckProjects') + } + if (!HEALTH_CHECK_PROJECTS.some(id => id.length < 24)) { + throw new Error('expected postgres id in healthCheckProjects') + } + + for (const historyId of HEALTH_CHECK_PROJECTS) { + if (!(await projectHasLatestChunk(historyId))) { + throw new Error(`project has no history: ${historyId}`) + } + } +} + +export const healthCheckCb = callbackify(healthCheck) +export const deleteProjectBackupCb = callbackify(deleteProjectBackup) diff --git a/services/history-v1/storage/lib/chunk_store/index.js b/services/history-v1/storage/lib/chunk_store/index.js index eb3c8ba48d..806bb81f60 100644 --- a/services/history-v1/storage/lib/chunk_store/index.js +++ b/services/history-v1/storage/lib/chunk_store/index.js @@ -81,7 +81,7 @@ async function lazyLoadHistoryFiles(history, batchBlobStore) { /** * Load the latest Chunk stored for a project, including blob metadata. * - * @param {number} projectId + * @param {number|string} projectId * @return {Promise.} */ async function loadLatest(projectId) { @@ -315,6 +315,7 @@ class AlreadyInitialized extends OError { } module.exports = { + getBackend, initializeProject, loadLatest, loadAtVersion, diff --git a/services/history-v1/test/acceptance/js/api/backupDeletion.test.mjs b/services/history-v1/test/acceptance/js/api/backupDeletion.test.mjs new file mode 100644 index 0000000000..c701753c33 --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/backupDeletion.test.mjs @@ -0,0 +1,242 @@ +// @ts-check +import cleanup from '../storage/support/cleanup.js' +import fetch from 'node-fetch' +import testServer from './support/test_backup_server.mjs' +import { expect } from 'chai' +import testProjects from './support/test_projects.js' +import { db } from '../../../../storage/lib/mongodb.js' +import { ObjectId } from 'mongodb' +import { + backupPersistor, + projectBlobsBucket, + chunksBucket, +} from '../../../../storage/lib/backupPersistor.mjs' +import { makeProjectKey } from '../../../../storage/lib/blob_store/index.js' +import config from 'config' +import Stream from 'stream' +import projectKey from '../../../../storage/lib/project_key.js' + +/** + * @typedef {import("node-fetch").Response} Response + */ + +const { deksBucket } = config.get('backupStore') + +const deletedProjectsCollection = db.collection('deletedProjects') + +/** + * @param {string} bucket + * @param {string} prefix + * @return {Promise>} + */ +async function listS3Bucket(bucket, prefix) { + // @ts-ignore access to internal library helper + const client = backupPersistor._getClientForBucket(bucket) + const response = await client + .listObjectsV2({ Bucket: bucket, Prefix: prefix }) + .promise() + return (response.Contents || []).map(item => item.Key || '') +} + +/** + * @param {ObjectId} projectId + * @return {Promise} + */ +async function deleteProject(projectId) { + return await fetch(testServer.testUrl(`/project/${projectId}/backup`), { + method: 'DELETE', + headers: { Authorization: testServer.basicAuthHeader }, + }) +} + +/** + * @param {string|ObjectId} historyId + * @return {Promise} + */ +async function expectToHaveBackup(historyId) { + const prefix = projectKey.format(historyId.toString()) + '/' + expect(await listS3Bucket(deksBucket, prefix)).to.have.length(1) + expect(await listS3Bucket(chunksBucket, prefix)).to.have.length(2) + expect(await listS3Bucket(projectBlobsBucket, prefix)).to.have.length(2) +} + +/** + * @param {string|ObjectId} historyId + * @return {Promise} + */ +async function expectToHaveNoBackup(historyId) { + const prefix = projectKey.format(historyId.toString()) + '/' + expect(await listS3Bucket(deksBucket, prefix)).to.have.length(0) + expect(await listS3Bucket(chunksBucket, prefix)).to.have.length(0) + expect(await listS3Bucket(projectBlobsBucket, prefix)).to.have.length(0) +} + +describe('backupDeletion', function () { + beforeEach(cleanup.everything) + beforeEach('create health check projects', async function () { + await testProjects.createEmptyProject('42') + await testProjects.createEmptyProject('000000000000000000000042') + }) + beforeEach(testServer.listenOnRandomPort) + + it('renders 200 on /status', async function () { + const response = await fetch(testServer.testUrl('/status')) + expect(response.status).to.equal(200) + }) + + it('renders 200 on /health_check', async function () { + const response = await fetch(testServer.testUrl('/health_check')) + expect(response.status).to.equal(200) + }) + + describe('DELETE /project/:projectId', function () { + const postgresHistoryId = '1' + const projectIdPostgres = new ObjectId('000000000000000000000001') + const projectIdMongoDB = new ObjectId('000000000000000000000002') + const projectIdNonDeleted = new ObjectId('000000000000000000000003') + const projectIdNonExpired = new ObjectId('000000000000000000000004') + const projectIdWithChunks = new ObjectId('000000000000000000000005') + + beforeEach('cleanup s3 buckets', async function () { + await backupPersistor.deleteDirectory(deksBucket, '') + await backupPersistor.deleteDirectory(chunksBucket, '') + await backupPersistor.deleteDirectory(projectBlobsBucket, '') + }) + + beforeEach('populate mongo', async function () { + await deletedProjectsCollection.insertMany([ + { + _id: new ObjectId(), + project: { + _id: projectIdPostgres, + overleaf: { history: { id: postgresHistoryId } }, + }, + deleterData: { + deletedProjectId: projectIdPostgres, + deletedAt: new Date('2024-01-01T00:00:00Z'), + }, + }, + { + _id: new ObjectId(), + project: { + _id: projectIdNonExpired, + overleaf: { history: { id: projectIdNonExpired.toString() } }, + }, + deleterData: { + deletedProjectId: projectIdNonExpired, + deletedAt: new Date(), + }, + }, + ...[projectIdMongoDB, projectIdWithChunks].map(projectId => { + return { + _id: new ObjectId(), + project: { + _id: projectId, + overleaf: { history: { id: projectId.toString() } }, + }, + deleterData: { + deletedProjectId: projectId, + deletedAt: new Date('2024-01-01T00:00:00Z'), + }, + } + }), + ]) + }) + + beforeEach('initialize history', async function () { + await testProjects.createEmptyProject(projectIdWithChunks.toString()) + }) + + beforeEach('create a file in s3', async function () { + const historyIds = [ + postgresHistoryId, + projectIdMongoDB, + projectIdNonDeleted, + projectIdNonExpired, + projectIdWithChunks, + ] + const jobs = [] + for (const historyId of historyIds) { + jobs.push( + backupPersistor.sendStream( + projectBlobsBucket, + makeProjectKey(historyId, 'a'.repeat(40)), + Stream.Readable.from(['blob a']), + { contentLength: 6 } + ) + ) + jobs.push( + backupPersistor.sendStream( + projectBlobsBucket, + makeProjectKey(historyId, 'b'.repeat(40)), + Stream.Readable.from(['blob b']), + { contentLength: 6 } + ) + ) + jobs.push( + backupPersistor.sendStream( + chunksBucket, + projectKey.format(historyId) + '/111', + Stream.Readable.from(['chunk 1']), + { contentLength: 7 } + ) + ) + jobs.push( + backupPersistor.sendStream( + chunksBucket, + projectKey.format(historyId) + '/222', + Stream.Readable.from(['chunk 2']), + { contentLength: 7 } + ) + ) + } + await Promise.all(jobs) + }) + + it('renders 401 without auth', async function () { + const response = await fetch( + testServer.testUrl('/project/000000000000000000000042/backup'), + { method: 'DELETE' } + ) + expect(response.status).to.equal(401) + expect(response.headers.get('www-authenticate')).to.match(/^Basic/) + }) + + it('returns 422 when not deleted', async function () { + const response = await deleteProject(projectIdNonDeleted) + expect(response.status).to.equal(422) + expect(await response.text()).to.equal( + 'refusing to delete non-deleted project' + ) + await expectToHaveBackup(projectIdNonDeleted) + }) + it('returns 422 when not expired', async function () { + const response = await deleteProject(projectIdNonExpired) + expect(response.status).to.equal(422) + expect(await response.text()).to.equal( + 'refusing to delete non-expired project' + ) + await expectToHaveBackup(projectIdNonExpired) + }) + it('returns 422 when live-history not deleted', async function () { + const response = await deleteProject(projectIdWithChunks) + expect(response.status).to.equal(422) + expect(await response.text()).to.equal( + 'refusing to delete project with remaining chunks' + ) + await expectToHaveBackup(projectIdWithChunks) + }) + it('should successfully delete postgres id', async function () { + await expectToHaveBackup(postgresHistoryId) + const response = await deleteProject(projectIdPostgres) + expect(response.status).to.equal(204) + await expectToHaveNoBackup(postgresHistoryId) + }) + it('should successfully delete mongo id', async function () { + await expectToHaveBackup(projectIdMongoDB) + const response = await deleteProject(projectIdMongoDB) + expect(response.status).to.equal(204) + await expectToHaveNoBackup(projectIdMongoDB) + }) + }) +}) diff --git a/services/history-v1/test/acceptance/js/api/support/test_backup_server.mjs b/services/history-v1/test/acceptance/js/api/support/test_backup_server.mjs new file mode 100644 index 0000000000..335eb0b3fd --- /dev/null +++ b/services/history-v1/test/acceptance/js/api/support/test_backup_server.mjs @@ -0,0 +1,51 @@ +// @ts-check +import config from 'config' +import { startApp } from '../../../../../backup-deletion-app.mjs' + +/** @type {import("http").Server} */ +let server + +/** + * @param {string} pathname + * @return {string} + */ +function testUrl(pathname) { + const url = new URL('http://127.0.0.1') + const addr = server.address() + if (addr && typeof addr === 'object') { + url.port = addr.port.toString() + } + url.pathname = pathname + return url.toString() +} + +const basicAuthHeader = + 'Basic ' + + Buffer.from(`staging:${config.get('basicHttpAuth.password')}`).toString( + 'base64' + ) + +async function listenOnRandomPort() { + if (server) return // already running + for (let i = 0; i < 10; i++) { + try { + server = await startApp(0) + return + } catch {} + } + server = await startApp(0) +} + +after('close server', function (done) { + if (server) { + server.close(done) + } else { + done() + } +}) + +export default { + testUrl, + basicAuthHeader, + listenOnRandomPort, +} diff --git a/services/history-v1/tsconfig.json b/services/history-v1/tsconfig.json index f8812e8818..a0d23b3aab 100644 --- a/services/history-v1/tsconfig.json +++ b/services/history-v1/tsconfig.json @@ -4,6 +4,7 @@ "api/**/*", "app.js", "app/js/**/*", + "backup-deletion-app.mjs", "benchmarks/**/*", "config/**/*", "migrations/**/*", diff --git a/services/web/app/src/Features/History/HistoryBackupDeletionHandler.js b/services/web/app/src/Features/History/HistoryBackupDeletionHandler.js new file mode 100644 index 0000000000..8df1bcdad8 --- /dev/null +++ b/services/web/app/src/Features/History/HistoryBackupDeletionHandler.js @@ -0,0 +1,20 @@ +const { fetchNothing } = require('@overleaf/fetch-utils') +const Settings = require('@overleaf/settings') + +async function deleteProject(projectId) { + if (!Settings.apis.historyBackupDeletion.enabled) return + + const url = new URL(Settings.apis.historyBackupDeletion.url) + url.pathname += `project/${projectId}/backup` + await fetchNothing(url, { + method: 'DELETE', + basicAuth: { + user: Settings.apis.historyBackupDeletion.user, + password: Settings.apis.historyBackupDeletion.pass, + }, + }) +} + +module.exports = { + deleteProject, +} diff --git a/services/web/app/src/Features/History/HistoryManager.js b/services/web/app/src/Features/History/HistoryManager.js index a0ad348b9d..cbf34dd870 100644 --- a/services/web/app/src/Features/History/HistoryManager.js +++ b/services/web/app/src/Features/History/HistoryManager.js @@ -4,6 +4,7 @@ const settings = require('@overleaf/settings') const OError = require('@overleaf/o-error') const UserGetter = require('../User/UserGetter') const ProjectGetter = require('../Project/ProjectGetter') +const HistoryBackupDeletionHandler = require('./HistoryBackupDeletionHandler') async function initializeProject(projectId) { const body = await fetchJson(`${settings.apis.project_history.url}/project`, { @@ -77,6 +78,7 @@ async function deleteProject(projectId, historyId) { tasks.push(_deleteProjectInFullProjectHistory(historyId)) } await Promise.all(tasks) + await HistoryBackupDeletionHandler.deleteProject(projectId) } async function _deleteProjectInProjectHistory(projectId) { diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index e6012ae7f4..0625a31dfd 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -246,6 +246,12 @@ module.exports = { sendProjectStructureOps: true, url: `http://${process.env.PROJECT_HISTORY_HOST || '127.0.0.1'}:3054`, }, + historyBackupDeletion: { + enabled: false, + url: `http://${process.env.HISTORY_BACKUP_DELETION_HOST || '127.0.0.1'}:3101`, + user: process.env.HISTORY_BACKUP_DELETION_USER || 'staging', + pass: process.env.HISTORY_BACKUP_DELETION_PASS, + }, realTime: { url: `http://${process.env.REALTIME_HOST || '127.0.0.1'}:3026`, }, diff --git a/services/web/test/acceptance/config/settings.test.defaults.js b/services/web/test/acceptance/config/settings.test.defaults.js index 5df7f7e2bb..7ab66049f0 100644 --- a/services/web/test/acceptance/config/settings.test.defaults.js +++ b/services/web/test/acceptance/config/settings.test.defaults.js @@ -86,6 +86,11 @@ module.exports = { user: 'overleaf', pass: 'password', }, + historyBackupDeletion: { + url: `http://127.0.0.1:23101`, + user: 'overleaf', + pass: 'password', + }, webpack: { url: 'http://127.0.0.1:23808', }, diff --git a/services/web/test/acceptance/src/DeletionTests.mjs b/services/web/test/acceptance/src/DeletionTests.mjs index 95591b0f1c..43be97b194 100644 --- a/services/web/test/acceptance/src/DeletionTests.mjs +++ b/services/web/test/acceptance/src/DeletionTests.mjs @@ -10,14 +10,20 @@ import MockDocstoreApiClass from './mocks/MockDocstoreApi.js' import MockFilestoreApiClass from './mocks/MockFilestoreApi.js' import MockChatApiClass from './mocks/MockChatApi.mjs' import MockGitBridgeApiClass from './mocks/MockGitBridgeApi.mjs' +import MockHistoryBackupDeletionApiClass from './mocks/MockHistoryBackupDeletionApi.mjs' -let MockDocstoreApi, MockFilestoreApi, MockChatApi, MockGitBridgeApi +let MockDocstoreApi, + MockFilestoreApi, + MockChatApi, + MockGitBridgeApi, + MockHistoryBackupDeletionApi before(function () { MockDocstoreApi = MockDocstoreApiClass.instance() MockFilestoreApi = MockFilestoreApiClass.instance() MockChatApi = MockChatApiClass.instance() MockGitBridgeApi = MockGitBridgeApiClass.instance() + MockHistoryBackupDeletionApi = MockHistoryBackupDeletionApiClass.instance() }) describe('Deleting a user', function () { @@ -474,6 +480,66 @@ describe('Deleting a project', function () { } ) }) + + if (Features.hasFeature('saas')) { + it('Should destroy the history backup', function (done) { + MockHistoryBackupDeletionApi.prepareProject(this.projectId, 204) + + request.post( + `/internal/project/${this.projectId}/expire-deleted-project`, + { + auth: { + user: settings.apis.web.user, + pass: settings.apis.web.pass, + sendImmediately: true, + }, + }, + (error, res) => { + expect(error).not.to.exist + expect(res.statusCode).to.equal(200) + + expect( + MockHistoryBackupDeletionApi.projects[this.projectId.toString()] + ).not.to.exist + done() + } + ) + }) + + it('Should abort when the history backup cannot be deleted', function (done) { + MockHistoryBackupDeletionApi.prepareProject(this.projectId, 422) + + request.post( + `/internal/project/${this.projectId}/expire-deleted-project`, + { + auth: { + user: settings.apis.web.user, + pass: settings.apis.web.pass, + sendImmediately: true, + }, + }, + (error, res) => { + expect(error).not.to.exist + expect(res.statusCode).to.equal(500) + + expect( + MockHistoryBackupDeletionApi.projects[this.projectId.toString()] + ).to.exist + db.deletedProjects.findOne( + { + 'deleterData.deletedProjectId': new ObjectId(this.projectId), + }, + (error, deletedProject) => { + expect(error).not.to.exist + expect(deletedProject).to.exist + expect(deletedProject.project).to.exist + done() + } + ) + } + ) + }) + } }) }) diff --git a/services/web/test/acceptance/src/Init.mjs b/services/web/test/acceptance/src/Init.mjs index 65a0c6d990..678083848e 100644 --- a/services/web/test/acceptance/src/Init.mjs +++ b/services/web/test/acceptance/src/Init.mjs @@ -15,6 +15,7 @@ import MockV1Api from './mocks/MockV1Api.js' import MockV1HistoryApi from './mocks/MockV1HistoryApi.js' import MockHaveIBeenPwnedApi from './mocks/MockHaveIBeenPwnedApi.mjs' import MockThirdPartyDataStoreApi from './mocks/MockThirdPartyDataStoreApi.mjs' +import MockHistoryBackupDeletionApi from './mocks/MockHistoryBackupDeletionApi.mjs' const mockOpts = { debug: ['1', 'true', 'TRUE'].includes(process.env.DEBUG_MOCKS), @@ -30,6 +31,7 @@ MockSpellingApi.initialize(23005, mockOpts) MockHaveIBeenPwnedApi.initialize(1337, mockOpts) MockProjectHistoryApi.initialize(23054, mockOpts) MockV1HistoryApi.initialize(23100, mockOpts) +MockHistoryBackupDeletionApi.initialize(23101, mockOpts) if (Features.hasFeature('saas')) { MockAnalyticsApi.initialize(23050, mockOpts) diff --git a/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs b/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs new file mode 100644 index 0000000000..069a0e6a26 --- /dev/null +++ b/services/web/test/acceptance/src/mocks/MockHistoryBackupDeletionApi.mjs @@ -0,0 +1,37 @@ +import AbstractMockApi from './AbstractMockApi.js' + +class MockHistoryBackupDeletionApi extends AbstractMockApi { + reset() { + this.projects = {} + } + + prepareProject(projectId, status) { + this.projects[projectId.toString()] = status + } + + deleteProject(req, res) { + const projectId = req.params.project_id + const status = this.projects[projectId] + if (status === 422) { + return res.sendStatus(422) + } + delete this.projects[projectId] + res.sendStatus(204) + } + + applyRoutes() { + this.app.delete('/project/:project_id/backup', (req, res) => + this.deleteProject(req, res) + ) + } +} + +export default MockHistoryBackupDeletionApi + +// type hint for the inherited `instance` method +/** + * @function instance + * @memberOf MockHistoryBackupDeletionApi + * @static + * @returns {MockHistoryBackupDeletionApi} + */ diff --git a/services/web/test/unit/src/History/HistoryManagerTests.js b/services/web/test/unit/src/History/HistoryManagerTests.js index e424b14e1d..3d7266dd9d 100644 --- a/services/web/test/unit/src/History/HistoryManagerTests.js +++ b/services/web/test/unit/src/History/HistoryManagerTests.js @@ -54,12 +54,17 @@ describe('HistoryManager', function () { }, } + this.HistoryBackupDeletionHandler = { + deleteProject: sinon.stub().resolves(), + } + this.HistoryManager = SandboxedModule.require(MODULE_PATH, { requires: { '@overleaf/fetch-utils': this.FetchUtils, '@overleaf/settings': this.settings, '../User/UserGetter': this.UserGetter, '../Project/ProjectGetter': this.ProjectGetter, + './HistoryBackupDeletionHandler': this.HistoryBackupDeletionHandler, }, }) }) @@ -287,5 +292,11 @@ describe('HistoryManager', function () { } ) }) + + it('should call the history-backup-deletion service', async function () { + expect( + this.HistoryBackupDeletionHandler.deleteProject + ).to.have.been.calledWith(projectId) + }) }) })