| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681 |
- import { vi, expect } from 'vitest'
- import sinon from 'sinon'
- import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
- import Errors from '../../../../app/src/Features/Errors/Errors.js'
- import mongodb from 'mongodb-legacy'
- const { ObjectId } = mongodb
- const MODULE_PATH =
- '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'
- describe('OwnershipTransferHandler', function () {
- beforeEach(async function (ctx) {
- ctx.user = { _id: new ObjectId(), email: 'owner@example.com' }
- ctx.collaborator = {
- _id: new ObjectId(),
- email: 'collaborator@example.com',
- }
- ctx.readOnlyCollaborator = {
- _id: new ObjectId(),
- email: 'readonly@example.com',
- }
- ctx.reviewer = {
- _id: new ObjectId(),
- email: 'reviewer@example.com',
- }
- ctx.project = {
- _id: new ObjectId(),
- name: 'project',
- owner_ref: ctx.user._id,
- collaberator_refs: [ctx.collaborator._id],
- readOnly_refs: [ctx.readOnlyCollaborator._id],
- reviewer_refs: [ctx.reviewer._id],
- }
- ctx.ProjectGetter = {
- promises: {
- getProject: sinon.stub().resolves(ctx.project),
- },
- }
- ctx.ProjectModel = {
- find: sinon.stub().resolves([]),
- updateOne: sinon.stub().returns({
- exec: sinon.stub().resolves(),
- }),
- }
- ctx.UserGetter = {
- promises: {
- getUser: sinon.stub().resolves(),
- },
- }
- ctx.TpdsUpdateSender = {
- promises: {
- moveEntity: sinon.stub().resolves(),
- },
- }
- ctx.TpdsProjectFlusher = {
- promises: {
- flushProjectToTpds: sinon.stub().resolves(),
- },
- }
- ctx.CollaboratorsHandler = {
- promises: {
- removeUserFromProject: sinon.stub().resolves(),
- addUserIdToProject: sinon.stub().resolves(),
- },
- }
- ctx.EmailHandler = {
- promises: {
- sendEmail: sinon.stub().resolves(),
- },
- }
- ctx.ProjectAuditLogHandler = {
- promises: {
- addEntry: sinon.stub().resolves(),
- },
- }
- ctx.TagsHandler = {
- promises: {
- createTag: sinon.stub().resolves(),
- addProjectsToTag: sinon.stub().resolves(),
- },
- }
- ctx.LimitationsManager = {
- promises: {
- canAddXEditCollaborators: sinon.stub().resolves(true),
- },
- }
- vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
- vi.importActual('../../../../app/src/Features/Errors/Errors.js')
- )
- vi.doMock('../../../../app/src/Features/Project/ProjectGetter.mjs', () => ({
- default: ctx.ProjectGetter,
- }))
- vi.doMock('../../../../app/src/models/Project.mjs', () => ({
- Project: ctx.ProjectModel,
- }))
- vi.doMock('../../../../app/src/Features/Tags/TagsHandler.mjs', () => ({
- default: ctx.TagsHandler,
- }))
- vi.doMock('../../../../app/src/Features/User/UserGetter.mjs', () => ({
- default: ctx.UserGetter,
- }))
- vi.doMock(
- '../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher.mjs',
- () => ({
- default: ctx.TpdsProjectFlusher,
- })
- )
- vi.doMock(
- '../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
- () => ({
- default: ctx.ProjectAuditLogHandler,
- })
- )
- vi.doMock('../../../../app/src/Features/Email/EmailHandler.mjs', () => ({
- default: ctx.EmailHandler,
- }))
- vi.doMock(
- '../../../../app/src/Features/Collaborators/CollaboratorsHandler.mjs',
- () => ({
- default: ctx.CollaboratorsHandler,
- })
- )
- vi.doMock(
- '../../../../app/src/Features/Analytics/AnalyticsManager',
- () => ({
- default: {
- recordEventForUserInBackground: (ctx.recordEventForUserInBackground =
- sinon.stub()),
- },
- })
- )
- vi.doMock(
- '../../../../app/src/Features/Subscription/LimitationsManager.mjs',
- () => ({
- default: ctx.LimitationsManager,
- })
- )
- ctx.handler = (await import(MODULE_PATH)).default
- })
- describe('transferOwnership', function () {
- beforeEach(function (ctx) {
- ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
- ctx.UserGetter.promises.getUser
- .withArgs(ctx.collaborator._id)
- .resolves(ctx.collaborator)
- ctx.UserGetter.promises.getUser
- .withArgs(ctx.readOnlyCollaborator._id)
- .resolves(ctx.readOnlyCollaborator)
- ctx.UserGetter.promises.getUser
- .withArgs(ctx.reviewer._id)
- .resolves(ctx.reviewer)
- })
- it("should return a not found error if the project can't be found", async function (ctx) {
- ctx.ProjectGetter.promises.getProject.resolves(null)
- await expect(
- ctx.handler.promises.transferOwnership('abc', ctx.collaborator._id)
- ).to.be.rejectedWith(Errors.ProjectNotFoundError)
- })
- it("should return a not found error if the user can't be found", async function (ctx) {
- ctx.UserGetter.promises.getUser
- .withArgs(ctx.collaborator._id)
- .resolves(null)
- await expect(
- ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- ).to.be.rejectedWith(Errors.UserNotFoundError)
- })
- it('should return an error if user cannot be removed as collaborator ', async function (ctx) {
- ctx.CollaboratorsHandler.promises.removeUserFromProject.rejects(
- new Error('user-cannot-be-removed')
- )
- await expect(
- ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- ).to.be.rejected
- })
- it('should transfer ownership of the project', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
- { _id: ctx.project._id },
- sinon.match({ $set: { owner_ref: ctx.collaborator._id } })
- )
- })
- it('should check collaborator limits after ownership transfer', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- expect(ctx.ProjectModel.updateOne).to.have.been.calledBefore(
- ctx.LimitationsManager.promises.canAddXEditCollaborators
- )
- expect(
- ctx.LimitationsManager.promises.canAddXEditCollaborators
- ).to.have.been.calledBefore(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- )
- })
- describe('when there are edit collaborator slots available', function () {
- beforeEach(function (ctx) {
- ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(true)
- })
- it('should give old owner read/write permissions when transferring to a read-only collaborator', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.readOnlyCollaborator._id
- )
- expect(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- ).to.have.been.calledWith(
- ctx.project._id,
- ctx.readOnlyCollaborator._id,
- ctx.user._id,
- PrivilegeLevels.READ_AND_WRITE,
- undefined
- )
- expect(
- ctx.LimitationsManager.promises.canAddXEditCollaborators
- ).to.have.been.calledWith(ctx.project._id, 1)
- })
- it('should give old owner read/write permissions when transferring to an editor', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- expect(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- ).to.have.been.calledWith(
- ctx.project._id,
- ctx.collaborator._id,
- ctx.user._id,
- PrivilegeLevels.READ_AND_WRITE,
- undefined
- )
- expect(
- ctx.LimitationsManager.promises.canAddXEditCollaborators
- ).to.have.been.calledWith(ctx.project._id, 1)
- })
- it('should give old owner read/write permissions when transferring to a reviewer', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.reviewer._id
- )
- expect(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- ).to.have.been.calledWith(
- ctx.project._id,
- ctx.reviewer._id,
- ctx.user._id,
- PrivilegeLevels.READ_AND_WRITE,
- undefined
- )
- expect(
- ctx.LimitationsManager.promises.canAddXEditCollaborators
- ).to.have.been.calledWith(ctx.project._id, 1)
- })
- it('should give old owner read/write permissions when transferring to a non-collaborator', async function (ctx) {
- ctx.project.collaberator_refs = []
- ctx.project.readOnly_refs = []
- ctx.project.reviewer_refs = []
- const newOwner = {
- _id: new ObjectId(),
- email: 'admin@example.com',
- }
- ctx.UserGetter.promises.getUser
- .withArgs(newOwner._id)
- .resolves(newOwner)
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- newOwner._id,
- { allowTransferToNonCollaborators: true }
- )
- expect(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- ).to.have.been.calledWith(
- ctx.project._id,
- newOwner._id,
- ctx.user._id,
- PrivilegeLevels.READ_AND_WRITE,
- undefined
- )
- expect(
- ctx.LimitationsManager.promises.canAddXEditCollaborators
- ).to.have.been.calledWith(ctx.project._id, 1)
- })
- })
- describe('when there are no edit collaborator slots available', function () {
- beforeEach(function (ctx) {
- ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(false)
- })
- it('should give old owner read-only with pending editor flag when transferring to an editor', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- expect(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- ).to.have.been.calledWith(
- ctx.project._id,
- ctx.collaborator._id,
- ctx.user._id,
- PrivilegeLevels.READ_ONLY,
- { pendingEditor: true }
- )
- })
- it('should give old owner read-only with pending editor flag when transferring to a reviewer', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.reviewer._id
- )
- expect(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- ).to.have.been.calledWith(
- ctx.project._id,
- ctx.reviewer._id,
- ctx.user._id,
- PrivilegeLevels.READ_ONLY,
- { pendingEditor: true }
- )
- })
- it('should give old owner read-only with pending editor flag when transferring to a read-only collaborator', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.readOnlyCollaborator._id
- )
- expect(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- ).to.have.been.calledWith(
- ctx.project._id,
- ctx.readOnlyCollaborator._id,
- ctx.user._id,
- PrivilegeLevels.READ_ONLY,
- { pendingEditor: true }
- )
- })
- it('should give old owner read-only with pending editor flag when transferring to a non-collaborator', async function (ctx) {
- ctx.project.collaberator_refs = []
- ctx.project.readOnly_refs = []
- ctx.project.reviewer_refs = []
- const newOwner = {
- _id: new ObjectId(),
- email: 'newowner@example.com',
- }
- ctx.UserGetter.promises.getUser
- .withArgs(newOwner._id)
- .resolves(newOwner)
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- newOwner._id,
- { allowTransferToNonCollaborators: true }
- )
- expect(
- ctx.CollaboratorsHandler.promises.addUserIdToProject
- ).to.have.been.calledWith(
- ctx.project._id,
- newOwner._id,
- ctx.user._id,
- PrivilegeLevels.READ_ONLY,
- { pendingEditor: true }
- )
- })
- })
- it('should do nothing if transferring back to the owner', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.user._id
- )
- expect(ctx.ProjectModel.updateOne).not.to.have.been.called
- })
- it("should remove the user from the project's collaborators", async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- expect(
- ctx.CollaboratorsHandler.promises.removeUserFromProject
- ).to.have.been.calledWith(ctx.project._id, ctx.collaborator._id)
- })
- it('should transfer ownership of the project to a reviewer', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.reviewer._id
- )
- expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
- { _id: ctx.project._id },
- sinon.match({ $set: { owner_ref: ctx.reviewer._id } })
- )
- })
- it('should flush the project to tpds', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- expect(
- ctx.TpdsProjectFlusher.promises.flushProjectToTpds
- ).to.have.been.calledWith(ctx.project._id)
- })
- it('should send an email notification', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
- 'ownershipTransferConfirmationPreviousOwner',
- {
- to: ctx.user.email,
- project: ctx.project,
- newOwner: ctx.collaborator,
- }
- )
- expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
- 'ownershipTransferConfirmationNewOwner',
- {
- to: ctx.collaborator.email,
- project: ctx.project,
- previousOwner: ctx.user,
- }
- )
- })
- it('should not send an email notification with the skipEmails option', async function (ctx) {
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id,
- { skipEmails: true }
- )
- expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
- })
- it('should track the change in BigQuery', async function (ctx) {
- const sessionUserId = new ObjectId()
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id,
- { sessionUserId }
- )
- expect(ctx.recordEventForUserInBackground).to.have.been.calledWith(
- ctx.user._id,
- 'project-ownership-transfer',
- {
- projectId: ctx.project._id,
- newOwnerId: ctx.collaborator._id,
- }
- )
- })
- it('should write an entry in the audit log', async function (ctx) {
- const sessionUserId = new ObjectId()
- const ipAddress = '1.2.3.4'
- await ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id,
- { sessionUserId, ipAddress }
- )
- expect(
- ctx.ProjectAuditLogHandler.promises.addEntry
- ).to.have.been.calledWith(
- ctx.project._id,
- 'transfer-ownership',
- sessionUserId,
- ipAddress,
- {
- previousOwnerId: ctx.user._id,
- newOwnerId: ctx.collaborator._id,
- }
- )
- })
- it('should decline to transfer ownership to a non-collaborator', async function (ctx) {
- ctx.project.collaberator_refs = []
- ctx.project.readOnly_refs = []
- await expect(
- ctx.handler.promises.transferOwnership(
- ctx.project._id,
- ctx.collaborator._id
- )
- ).to.be.rejectedWith(Errors.UserNotCollaboratorError)
- })
- })
- describe('transferAllProjectsToUser', function () {
- const fromUserEmail = 'user.one@example.com'
- const ipAddress = '1.2.3.4'
- let fromUserId, toUserId
- beforeEach(function () {
- fromUserId = new ObjectId().toString()
- toUserId = new ObjectId().toString()
- })
- describe('with missing user', function () {
- it('should throw an error', async function (ctx) {
- ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null)
- ctx.UserGetter.promises.getUser
- .withArgs(toUserId)
- .resolves({ _id: new ObjectId(toUserId) })
- await expect(
- ctx.handler.promises.transferAllProjectsToUser({
- toUserId,
- fromUserId,
- ipAddress,
- })
- ).to.be.rejectedWith(/missing source user/)
- ctx.UserGetter.promises.getUser
- .withArgs(fromUserId)
- .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
- ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves(null)
- await expect(
- ctx.handler.promises.transferAllProjectsToUser({
- fromUserId,
- toUserId,
- ipAddress,
- })
- ).to.be.rejectedWith(/missing destination user/)
- })
- })
- describe('with the same id', function () {
- it('should throw an error', async function (ctx) {
- ctx.UserGetter.promises.getUser
- .withArgs(fromUserId)
- .resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
- await expect(
- ctx.handler.promises.transferAllProjectsToUser({
- fromUserId,
- toUserId: fromUserId,
- ipAddress,
- })
- ).to.be.rejectedWith(/rejecting transfer between identical users/)
- })
- })
- describe('happy path', function () {
- let tag, fromUserEmail, projects
- beforeEach(function (ctx) {
- tag = {
- _id: new ObjectId(),
- name: 'some-tag-name',
- }
- projects = [
- { _id: 'project-1' },
- { _id: 'project-2' },
- { _id: 'project-3' },
- ]
- ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves({
- _id: new ObjectId(fromUserId),
- email: fromUserEmail,
- })
- ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves({
- _id: new ObjectId(toUserId),
- })
- ctx.ProjectModel.find.resolves(projects)
- ctx.TagsHandler.promises.createTag.resolves({
- _id: tag._id,
- name: 'some-tag-name',
- })
- ctx.TagsHandler.promises.addProjectsToTag.resolves()
- })
- it('creates a tag', async function (ctx) {
- await ctx.handler.promises.transferAllProjectsToUser({
- fromUserId,
- toUserId,
- ipAddress,
- })
- expect(ctx.TagsHandler.promises.createTag).to.have.been.calledWith(
- toUserId,
- `transferred-from-${fromUserEmail}`,
- '#434AF0',
- { truncate: true }
- )
- })
- it('returns a projectCount, and tag name', async function (ctx) {
- const result = await ctx.handler.promises.transferAllProjectsToUser({
- fromUserId,
- toUserId,
- ipAddress,
- })
- expect(result.projectCount).to.equal(projects.length)
- expect(result.newTagName).to.equal('some-tag-name')
- })
- it('gets the user records', async function (ctx) {
- await ctx.handler.promises.transferAllProjectsToUser({
- fromUserId,
- toUserId,
- ipAddress,
- })
- expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
- fromUserId
- )
- expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
- toUserId
- )
- })
- it('gets the list of affected projects', async function (ctx) {
- await ctx.handler.promises.transferAllProjectsToUser({
- fromUserId,
- toUserId,
- ipAddress,
- })
- expect(ctx.ProjectModel.find).to.have.been.calledWith({
- owner_ref: fromUserId,
- })
- })
- it('transfers all of the projects', async function (ctx) {
- await ctx.handler.promises.transferAllProjectsToUser({
- fromUserId,
- toUserId,
- ipAddress,
- })
- expect(ctx.ProjectModel.updateOne.callCount).to.equal(3)
- expect(ctx.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1)
- for (const project of projects) {
- expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
- { _id: project._id },
- sinon.match({ $set: { owner_ref: toUserId } })
- )
- }
- expect(
- ctx.TagsHandler.promises.addProjectsToTag
- ).to.have.been.calledWith(
- toUserId,
- tag._id,
- projects.map(p => p._id)
- )
- })
- })
- })
- })
|