Files
overleaf-cep/services/web/test/unit/src/Project/ProjectDeleter.test.mjs
Domagoj Kriskovic 6486ef3e1e [web] Add deletedReason parameter to project deletion methods (#32221)
* [web] Add deletedReason parameter to project deletion methods

* revert sinon.match.any in ProjectDuplicator negative assertion

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
GitOrigin-RevId: d1595eefe0e36150231ee9646fe5eba0786fd1f5
2026-03-23 09:06:04 +00:00

831 lines
25 KiB
JavaScript

import { vi, expect } from 'vitest'
import sinon from 'sinon'
import tk from 'timekeeper'
import moment from 'moment'
import { Project } from '../../../../app/src/models/Project.mjs'
import { DeletedProject } from '../../../../app/src/models/DeletedProject.mjs'
import mongodb from 'mongodb-legacy'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
const modulePath = '../../../../app/src/Features/Project/ProjectDeleter'
const { ObjectId, ReadPreference } = mongodb
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
describe('ProjectDeleter', function () {
beforeEach(async function (ctx) {
tk.freeze(Date.now())
ctx.ip = '192.170.18.1'
ctx.project = dummyProject()
ctx.user = {
_id: '588f3ddae8ebc1bac07c9fa4',
first_name: 'bjkdsjfk',
features: {},
}
ctx.doc = {
_id: '5bd975f54f62e803cb8a8fec',
lines: ['a bunch of lines', 'for a sunny day', 'in London town'],
ranges: {},
project_id: '5cf9270b4eff6e186cf8b05e',
}
ctx.deletedProjects = [
{
_id: '5cf7f145c1401f0ca0eb1aaa',
deleterData: {
_id: '5cf7f145c1401f0ca0eb1aac',
deletedAt: moment().subtract(95, 'days').toDate(),
deleterId: '588f3ddae8ebc1bac07c9fa4',
deleterIpAddress: '172.19.0.1',
deletedProjectId: '5cf9270b4eff6e186cf8b05e',
deletedProjectOwnerId: ctx.user._id,
},
project: {
_id: '5cf9270b4eff6e186cf8b05e',
overleaf: {
history: {
id: new ObjectId(),
},
},
},
},
{
_id: '5cf8eb11c1401f0ca0eb1ad7',
deleterData: {
_id: '5b74360c0fbe57011ae9938f',
deletedAt: moment().subtract(95, 'days').toDate(),
deleterId: '588f3ddae8ebc1bac07c9fa4',
deleterIpAddress: '172.20.0.1',
deletedProjectId: '5cf8f95a0c87371362c23919',
},
project: {
_id: '5cf8f95a0c87371362c23919',
},
},
]
ctx.DocumentUpdaterHandler = {
promises: {
flushProjectToMongoAndDelete: sinon.stub().resolves(),
},
}
ctx.EditorRealTimeController = {
emitToRoom: sinon.stub(),
}
ctx.TagsHandler = {
promises: {
removeProjectFromAllTags: sinon.stub().resolves(),
},
}
ctx.CollaboratorsHandler = {
promises: {
removeUserFromAllProjects: sinon.stub().resolves(),
},
}
ctx.CollaboratorsGetter = {
promises: {
getMemberIds: sinon
.stub()
.withArgs(ctx.project._id)
.resolves(['member-id-1', 'member-id-2']),
},
}
ctx.ProjectDetailsHandler = {
promises: {
generateUniqueName: sinon.stub().resolves(ctx.project.name),
},
}
ctx.db = {
projects: {
insertOne: sinon.stub().resolves(),
},
}
ctx.DocstoreManager = {
promises: {
archiveProject: sinon.stub().resolves(),
destroyProject: sinon.stub().resolves(),
},
}
ctx.HistoryManager = {
promises: {
deleteProject: sinon.stub().resolves(),
},
}
ctx.ProjectMock = sinon.mock(Project)
ctx.DeletedProjectMock = sinon.mock(DeletedProject)
ctx.Features = {
hasFeature: sinon.stub().returns(true),
}
ctx.ChatApiHandler = {
promises: {
destroyProject: sinon.stub().resolves(),
},
}
ctx.ProjectAuditLogEntry = {
deleteMany: sinon.stub().returns({ exec: sinon.stub().resolves() }),
}
vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
default: {
promises: { hooks: { fire: sinon.stub().resolves() } },
},
}))
vi.doMock('../../../../app/src/infrastructure/Features', () => ({
default: ctx.Features,
}))
vi.doMock(
'../../../../app/src/Features/Editor/EditorRealTimeController',
() => ({
default: ctx.EditorRealTimeController,
})
)
vi.doMock('../../../../app/src/models/Project', () => ({
Project,
}))
vi.doMock('../../../../app/src/models/DeletedProject', () => ({
DeletedProject,
}))
vi.doMock(
'../../../../app/src/Features/DocumentUpdater/DocumentUpdaterHandler',
() => ({
default: ctx.DocumentUpdaterHandler,
})
)
vi.doMock('../../../../app/src/Features/Tags/TagsHandler', () => ({
default: ctx.TagsHandler,
}))
vi.doMock('../../../../app/src/Features/Chat/ChatApiHandler', () => ({
default: ctx.ChatApiHandler,
}))
vi.doMock(
'../../../../app/src/Features/Collaborators/CollaboratorsHandler',
() => ({
default: ctx.CollaboratorsHandler,
})
)
vi.doMock(
'../../../../app/src/Features/Collaborators/CollaboratorsGetter',
() => ({
default: ctx.CollaboratorsGetter,
})
)
vi.doMock('../../../../app/src/Features/Docstore/DocstoreManager', () => ({
default: ctx.DocstoreManager,
}))
vi.doMock(
'../../../../app/src/Features/Project/ProjectDetailsHandler',
() => ({
default: ctx.ProjectDetailsHandler,
})
)
vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
db: ctx.db,
ObjectId,
READ_PREFERENCE_SECONDARY: ReadPreference.secondaryPreferred.mode,
}))
vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({
default: ctx.HistoryManager,
}))
vi.doMock('../../../../app/src/models/ProjectAuditLogEntry', () => ({
ProjectAuditLogEntry: ctx.ProjectAuditLogEntry,
}))
ctx.ProjectDeleter = (await import(modulePath)).default
})
afterEach(function (ctx) {
tk.reset()
ctx.DeletedProjectMock.restore()
ctx.ProjectMock.restore()
})
describe('mark as deleted by external source', function () {
beforeEach(function (ctx) {
ctx.ProjectMock.expects('updateOne')
.withArgs(
{ _id: ctx.project._id },
{ deletedByExternalDataSource: true }
)
.chain('exec')
.resolves()
})
it('should update the project with the flag set to true', async function (ctx) {
await ctx.ProjectDeleter.promises.markAsDeletedByExternalSource(
ctx.project._id
)
ctx.ProjectMock.verify()
})
it('should tell the editor controler so users are notified', async function (ctx) {
await ctx.ProjectDeleter.promises.markAsDeletedByExternalSource(
ctx.project._id
)
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
ctx.project._id,
'projectRenamedOrDeletedByExternalSource'
)
})
})
describe('unmarkAsDeletedByExternalSource', function () {
beforeEach(async function (ctx) {
ctx.ProjectMock.expects('updateOne')
.withArgs(
{ _id: ctx.project._id },
{ deletedByExternalDataSource: false }
)
.chain('exec')
.resolves()
await ctx.ProjectDeleter.promises.unmarkAsDeletedByExternalSource(
ctx.project._id
)
})
it('should remove the flag from the project', function (ctx) {
ctx.ProjectMock.verify()
})
})
describe('deleteUsersProjects', function () {
beforeEach(function (ctx) {
ctx.projects = [dummyProject(), dummyProject()]
ctx.ProjectMock.expects('find')
.withArgs({ owner_ref: ctx.user._id })
.chain('exec')
.resolves(ctx.projects)
for (const project of ctx.projects) {
ctx.ProjectMock.expects('findOne')
.withArgs({ _id: project._id })
.chain('exec')
.resolves(project)
ctx.ProjectMock.expects('deleteOne')
.withArgs({ _id: project._id })
.chain('exec')
.resolves()
ctx.DeletedProjectMock.expects('updateOne')
.withArgs(
{ 'deleterData.deletedProjectId': project._id },
{
project,
deleterData: sinon.match.has('deletedReason', 'account-deletion'),
},
{ upsert: true }
)
.resolves()
}
})
it('should delete all projects owned by the user', async function (ctx) {
await ctx.ProjectDeleter.promises.deleteUsersProjects(ctx.user._id)
ctx.ProjectMock.verify()
ctx.DeletedProjectMock.verify()
})
it('should remove any collaboration from this user', async function (ctx) {
await ctx.ProjectDeleter.promises.deleteUsersProjects(ctx.user._id)
sinon.assert.calledWith(
ctx.CollaboratorsHandler.promises.removeUserFromAllProjects,
ctx.user._id
)
sinon.assert.calledOnce(
ctx.CollaboratorsHandler.promises.removeUserFromAllProjects
)
})
})
describe('deleteProject', function () {
beforeEach(function (ctx) {
ctx.deleterData = {
deletedAt: new Date(),
deletedProjectId: ctx.project._id,
deletedProjectOwnerId: ctx.project.owner_ref,
deletedProjectCollaboratorIds: ctx.project.collaberator_refs,
deletedProjectReadOnlyIds: ctx.project.readOnly_refs,
deletedProjectReviewerIds: ctx.project.reviewer_refs,
deletedProjectReadWriteTokenAccessIds:
ctx.project.tokenAccessReadAndWrite_refs,
deletedProjectReadOnlyTokenAccessIds:
ctx.project.tokenAccessReadOnly_refs,
deletedProjectReadWriteToken: ctx.project.tokens.readAndWrite,
deletedProjectReadOnlyToken: ctx.project.tokens.readOnly,
deletedProjectOverleafId: ctx.project.overleaf.id,
deletedProjectOverleafHistoryId: ctx.project.overleaf.history.id,
deletedProjectLastUpdatedAt: ctx.project.lastUpdated,
}
ctx.ProjectMock.expects('findOne')
.withArgs({ _id: ctx.project._id })
.chain('exec')
.resolves(ctx.project)
})
it('should save a DeletedProject with additional deleterData', async function (ctx) {
ctx.deleterData.deleterIpAddress = ctx.ip
ctx.deleterData.deleterId = ctx.user._id
ctx.deleterData.deletedReason = 'user'
ctx.ProjectMock.expects('deleteOne').chain('exec').resolves()
ctx.DeletedProjectMock.expects('updateOne')
.withArgs(
{ 'deleterData.deletedProjectId': ctx.project._id },
{
project: ctx.project,
deleterData: ctx.deleterData,
},
{ upsert: true }
)
.resolves()
await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, {
deleterUser: ctx.user,
ipAddress: ctx.ip,
deletedReason: 'user',
})
ctx.DeletedProjectMock.verify()
})
it('should flushProjectToMongoAndDelete in doc updater', async function (ctx) {
ctx.ProjectMock.expects('deleteOne').chain('exec').resolves()
ctx.DeletedProjectMock.expects('updateOne').resolves()
await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, {
deleterUser: ctx.user,
ipAddress: ctx.ip,
})
ctx.DocumentUpdaterHandler.promises.flushProjectToMongoAndDelete
.calledWith(ctx.project._id)
.should.equal(true)
})
it('should flush docs out of mongo', async function (ctx) {
ctx.ProjectMock.expects('deleteOne').chain('exec').resolves()
ctx.DeletedProjectMock.expects('updateOne').resolves()
await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, {
deleterUser: ctx.user,
ipAddress: ctx.ip,
})
expect(
ctx.DocstoreManager.promises.archiveProject
).to.have.been.calledWith(ctx.project._id)
})
it('should flush docs out of mongo and ignore errors', async function (ctx) {
ctx.ProjectMock.expects('deleteOne').chain('exec').resolves()
ctx.DeletedProjectMock.expects('updateOne').resolves()
ctx.DocstoreManager.promises.archiveProject.rejects(new Error('foo'))
await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id, {
deleterUser: ctx.user,
ipAddress: ctx.ip,
})
})
it('should removeProjectFromAllTags', async function (ctx) {
ctx.ProjectMock.expects('deleteOne').chain('exec').resolves()
ctx.DeletedProjectMock.expects('updateOne').resolves()
await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id)
sinon.assert.calledWith(
ctx.TagsHandler.promises.removeProjectFromAllTags,
'member-id-1',
ctx.project._id
)
sinon.assert.calledWith(
ctx.TagsHandler.promises.removeProjectFromAllTags,
'member-id-2',
ctx.project._id
)
})
it('should remove the project from Mongo', async function (ctx) {
ctx.ProjectMock.expects('deleteOne')
.withArgs({ _id: ctx.project._id })
.chain('exec')
.resolves()
ctx.DeletedProjectMock.expects('updateOne').resolves()
await ctx.ProjectDeleter.promises.deleteProject(ctx.project._id)
ctx.ProjectMock.verify()
})
})
describe('expireDeletedProjectsAfterDuration', function () {
beforeEach(async function (ctx) {
for (const deletedProject of ctx.deletedProjects) {
ctx.ProjectMock.expects('findById')
.withArgs(deletedProject.deleterData.deletedProjectId)
.chain('exec')
.resolves(null)
}
ctx.DeletedProjectMock.expects('find')
.withArgs({
'deleterData.deletedAt': {
$lt: new Date(moment().subtract(90, 'days')),
},
project: {
$type: 'object',
},
})
.chain('exec')
.resolves(ctx.deletedProjects)
for (const deletedProject of ctx.deletedProjects) {
ctx.DeletedProjectMock.expects('findOne')
.withArgs({
'deleterData.deletedProjectId': deletedProject.project._id,
})
.chain('exec')
.resolves(deletedProject)
ctx.DeletedProjectMock.expects('updateOne')
.withArgs(
{
_id: deletedProject._id,
},
{
$set: {
'deleterData.deleterIpAddress': null,
project: null,
},
}
)
.chain('exec')
.resolves()
}
await ctx.ProjectDeleter.promises.expireDeletedProjectsAfterDuration()
})
it('should expire projects older than 90 days', function (ctx) {
ctx.DeletedProjectMock.verify()
})
})
describe('expireDeletedProject', function () {
describe('on an inactive project', function () {
beforeEach(async function (ctx) {
ctx.ProjectMock.expects('findById')
.withArgs(ctx.deletedProjects[0].deleterData.deletedProjectId)
.chain('exec')
.resolves(null)
ctx.DeletedProjectMock.expects('updateOne')
.withArgs(
{
_id: ctx.deletedProjects[0]._id,
},
{
$set: {
'deleterData.deleterIpAddress': null,
project: null,
},
}
)
.chain('exec')
.resolves()
ctx.DeletedProjectMock.expects('findOne')
.withArgs({
'deleterData.deletedProjectId': ctx.deletedProjects[0].project._id,
})
.chain('exec')
.resolves(ctx.deletedProjects[0])
await ctx.ProjectDeleter.promises.expireDeletedProject(
ctx.deletedProjects[0].project._id
)
})
it('should find the specified deletedProject and remove its project and ip address', function (ctx) {
ctx.DeletedProjectMock.verify()
})
it('should destroy the docs in docstore', function (ctx) {
expect(
ctx.DocstoreManager.promises.destroyProject
).to.have.been.calledWith(ctx.deletedProjects[0].project._id)
})
it('should delete the project in history', function (ctx) {
expect(
ctx.HistoryManager.promises.deleteProject
).to.have.been.calledWith(
ctx.deletedProjects[0].project._id,
ctx.deletedProjects[0].project.overleaf.history.id
)
})
it('should destroy the chat threads and messages', function (ctx) {
expect(
ctx.ChatApiHandler.promises.destroyProject
).to.have.been.calledWith(ctx.deletedProjects[0].project._id)
})
it('should delete audit logs', async function (ctx) {
expect(ctx.ProjectAuditLogEntry.deleteMany).to.have.been.calledWith({
projectId: ctx.deletedProjects[0].project._id,
})
})
it('should log a completed deletion', async function (ctx) {
expect(ctx.logger.info).toHaveBeenCalledWith(
{
projectId: ctx.deletedProjects[0].project._id,
userId: ctx.user._id,
},
'expired deleted project successfully'
)
})
})
describe('on an active project (from an incomplete delete)', function () {
beforeEach(async function (ctx) {
ctx.ProjectMock.expects('findById')
.withArgs(ctx.deletedProjects[0].deleterData.deletedProjectId)
.chain('exec')
.resolves(ctx.deletedProjects[0].project)
ctx.DeletedProjectMock.expects('deleteOne')
.withArgs({
'deleterData.deletedProjectId': ctx.deletedProjects[0].project._id,
})
.chain('exec')
.resolves()
await ctx.ProjectDeleter.promises.expireDeletedProject(
ctx.deletedProjects[0].project._id
)
})
it('should delete the spurious deleted project record', function (ctx) {
ctx.DeletedProjectMock.verify()
})
it('should not destroy the docs in docstore', function (ctx) {
expect(ctx.DocstoreManager.promises.destroyProject).to.not.have.been
.called
})
it('should not delete the project in history', function (ctx) {
expect(ctx.HistoryManager.promises.deleteProject).to.not.have.been
.called
})
it('should not destroy the chat threads and messages', function (ctx) {
expect(ctx.ChatApiHandler.promises.destroyProject).to.not.have.been
.called
})
})
})
describe('archiveProject', function () {
beforeEach(function (ctx) {
ctx.ProjectMock.expects('updateOne')
.withArgs(
{ _id: ctx.project._id },
{
$addToSet: { archived: new ObjectId(ctx.user._id) },
$pull: { trashed: new ObjectId(ctx.user._id) },
}
)
.resolves()
})
it('should update the project', async function (ctx) {
await ctx.ProjectDeleter.promises.archiveProject(
ctx.project._id,
ctx.user._id
)
ctx.ProjectMock.verify()
})
})
describe('unarchiveProject', function () {
beforeEach(function (ctx) {
ctx.ProjectMock.expects('updateOne')
.withArgs(
{ _id: ctx.project._id },
{ $pull: { archived: new ObjectId(ctx.user._id) } }
)
.resolves()
})
it('should update the project', async function (ctx) {
await ctx.ProjectDeleter.promises.unarchiveProject(
ctx.project._id,
ctx.user._id
)
ctx.ProjectMock.verify()
})
})
describe('trashProject', function () {
beforeEach(function (ctx) {
ctx.ProjectMock.expects('updateOne')
.withArgs(
{ _id: ctx.project._id },
{
$addToSet: { trashed: new ObjectId(ctx.user._id) },
$pull: { archived: new ObjectId(ctx.user._id) },
}
)
.resolves()
})
it('should update the project', async function (ctx) {
await ctx.ProjectDeleter.promises.trashProject(
ctx.project._id,
ctx.user._id
)
ctx.ProjectMock.verify()
})
})
describe('untrashProject', function () {
beforeEach(function (ctx) {
ctx.ProjectMock.expects('updateOne')
.withArgs(
{ _id: ctx.project._id },
{ $pull: { trashed: new ObjectId(ctx.user._id) } }
)
.resolves()
})
it('should update the project', async function (ctx) {
await ctx.ProjectDeleter.promises.untrashProject(
ctx.project._id,
ctx.user._id
)
ctx.ProjectMock.verify()
})
})
describe('restoreProject', function () {
beforeEach(function (ctx) {
ctx.ProjectMock.expects('updateOne')
.withArgs(
{
_id: ctx.project._id,
},
{
$unset: { archived: true },
}
)
.chain('exec')
.resolves()
})
it('should unset the archive attribute', async function (ctx) {
await ctx.ProjectDeleter.promises.restoreProject(ctx.project._id)
})
})
describe('undeleteProject', function () {
beforeEach(function (ctx) {
ctx.unknownProjectId = new ObjectId()
ctx.purgedProjectId = new ObjectId()
ctx.deletedProject = {
_id: 'deleted',
project: ctx.project,
deleterData: {
deletedProjectId: ctx.project._id,
deletedProjectOwnerId: ctx.project.owner_ref,
},
}
ctx.purgedProject = {
_id: 'purged',
deleterData: {
deletedProjectId: ctx.purgedProjectId,
deletedProjectOwnerId: 'potato',
},
}
ctx.DeletedProjectMock.expects('findOne')
.withArgs({ 'deleterData.deletedProjectId': ctx.project._id })
.chain('exec')
.resolves(ctx.deletedProject)
ctx.DeletedProjectMock.expects('findOne')
.withArgs({ 'deleterData.deletedProjectId': ctx.purgedProjectId })
.chain('exec')
.resolves(ctx.purgedProject)
ctx.DeletedProjectMock.expects('findOne')
.withArgs({ 'deleterData.deletedProjectId': ctx.unknownProjectId })
.chain('exec')
.resolves(null)
ctx.DeletedProjectMock.expects('deleteOne').chain('exec').resolves()
})
it('should return not found if the project does not exist', async function (ctx) {
await expect(
ctx.ProjectDeleter.promises.undeleteProject(
ctx.unknownProjectId.toString()
)
).to.be.rejectedWith(Errors.NotFoundError, 'project_not_found')
})
it('should return not found if the project has been expired', async function (ctx) {
await expect(
ctx.ProjectDeleter.promises.undeleteProject(
ctx.purgedProjectId.toString()
)
).to.be.rejectedWith(Errors.NotFoundError, 'project_too_old_to_restore')
})
it('should insert the project into the collection', async function (ctx) {
await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id)
sinon.assert.calledWith(
ctx.db.projects.insertOne,
sinon.match({
_id: ctx.project._id,
name: ctx.project.name,
})
)
})
it('should clear the archive bit', async function (ctx) {
ctx.project.archived = true
await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id)
sinon.assert.calledWith(
ctx.db.projects.insertOne,
sinon.match({ archived: undefined })
)
})
it('should generate a unique name for the project', async function (ctx) {
await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id)
sinon.assert.calledWith(
ctx.ProjectDetailsHandler.promises.generateUniqueName,
ctx.project.owner_ref
)
})
it('should add a suffix to the project name', async function (ctx) {
await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id)
sinon.assert.calledWith(
ctx.ProjectDetailsHandler.promises.generateUniqueName,
ctx.project.owner_ref,
ctx.project.name + ' (Restored)'
)
})
it('should remove the DeletedProject', async function (ctx) {
// need to change the mock just to include the methods we want
ctx.DeletedProjectMock.restore()
ctx.DeletedProjectMock = sinon.mock(DeletedProject)
ctx.DeletedProjectMock.expects('findOne')
.withArgs({ 'deleterData.deletedProjectId': ctx.project._id })
.chain('exec')
.resolves(ctx.deletedProject)
ctx.DeletedProjectMock.expects('deleteOne')
.withArgs({ _id: 'deleted' })
.chain('exec')
.resolves()
await ctx.ProjectDeleter.promises.undeleteProject(ctx.project._id)
ctx.DeletedProjectMock.verify()
})
})
})
function dummyProject() {
return {
_id: new ObjectId(),
lastUpdated: new Date(),
rootFolder: [],
collaberator_refs: [new ObjectId(), new ObjectId()],
readOnly_refs: [new ObjectId(), new ObjectId()],
reviewer_refs: [new ObjectId()],
tokenAccessReadAndWrite_refs: [new ObjectId(), new ObjectId()],
tokenAccessReadOnly_refs: [new ObjectId(), new ObjectId()],
owner_ref: new ObjectId(),
tokens: {
readOnly: 'wombat',
readAndWrite: 'potato',
},
overleaf: {
id: 1234,
history: {
id: 5678,
},
},
name: 'a very scientific analysis of spooky ghosts',
}
}