overleaf/services/web/test/unit/src/Project/ProjectDetailsHandlerTests.js

692 lines
22 KiB
JavaScript
Raw Normal View History

const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const { ObjectId } = require('mongodb')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels')
const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDetailsHandler'
describe('ProjectDetailsHandler', function() {
beforeEach(function() {
this.user = {
_id: ObjectId('abcdefabcdefabcdefabcdef'),
email: 'user@example.com',
features: 'mock-features'
}
this.collaborator = {
_id: ObjectId('123456123456123456123456'),
email: 'collaborator@example.com'
}
this.project = {
_id: ObjectId('5d5dabdbb351de090cdff0b2'),
name: 'project',
description: 'this is a great project',
something: 'should not exist',
compiler: 'latexxxxxx',
owner_ref: this.user._id,
collaberator_refs: [this.collaborator._id]
}
this.ProjectGetter = {
promises: {
getProjectWithoutDocLines: sinon.stub().resolves(this.project),
getProject: sinon.stub().resolves(this.project),
findAllUsersProjects: sinon.stub().resolves({
owned: [],
readAndWrite: [],
readOnly: [],
tokenReadAndWrite: [],
tokenReadOnly: []
})
}
}
this.ProjectModel = {
update: sinon.stub().returns({
exec: sinon.stub().resolves()
})
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(this.user)
}
}
this.TpdsUpdateSender = {
promises: {
moveEntity: sinon.stub().resolves()
}
}
this.ProjectEntityHandler = {
promises: {
flushProjectToThirdPartyDataStore: sinon.stub().resolves()
}
}
this.CollaboratorsHandler = {
promises: {
removeUserFromProject: sinon.stub().resolves(),
addUserIdToProject: sinon.stub().resolves()
}
}
this.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves()
}
}
this.ProjectTokenGenerator = {
readAndWriteToken: sinon.stub(),
promises: {
generateUniqueReadOnlyToken: sinon.stub()
}
}
this.settings = {
defaultFeatures: 'default-features'
}
this.handler = SandboxedModule.require(MODULE_PATH, {
globals: {
console: console
},
requires: {
'./ProjectGetter': this.ProjectGetter,
'../../models/Project': {
Project: this.ProjectModel
},
'../User/UserGetter': this.UserGetter,
'../ThirdPartyDataStore/TpdsUpdateSender': this.TpdsUpdateSender,
'./ProjectEntityHandler': this.ProjectEntityHandler,
'../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
'../Email/EmailHandler': this.EmailHandler,
'logger-sharelatex': {
log() {},
warn() {},
err() {}
},
'./ProjectTokenGenerator': this.ProjectTokenGenerator,
'../Errors/Errors': Errors,
'settings-sharelatex': this.settings
}
})
})
describe('getDetails', function() {
it('should find the project and owner', async function() {
const details = await this.handler.promises.getDetails(this.project._id)
details.name.should.equal(this.project.name)
details.description.should.equal(this.project.description)
details.compiler.should.equal(this.project.compiler)
details.features.should.equal(this.user.features)
expect(details.something).to.be.undefined
})
it('should find overleaf metadata if it exists', async function() {
this.project.overleaf = { id: 'id' }
const details = await this.handler.promises.getDetails(this.project._id)
details.overleaf.should.equal(this.project.overleaf)
expect(details.something).to.be.undefined
})
it('should return an error for a non-existent project', async function() {
this.ProjectGetter.promises.getProject.resolves(null)
await expect(
this.handler.promises.getDetails('0123456789012345678901234')
).to.be.rejectedWith(Errors.NotFoundError)
})
it('should return the default features if no owner found', async function() {
this.UserGetter.promises.getUser.resolves(null)
const details = await this.handler.promises.getDetails(this.project._id)
details.features.should.equal(this.settings.defaultFeatures)
})
it('should rethrow any error', async function() {
this.ProjectGetter.promises.getProject.rejects(new Error('boom'))
await expect(this.handler.promises.getDetails(this.project._id)).to.be
.rejected
})
})
describe('transferOwnership', function() {
beforeEach(function() {
this.UserGetter.promises.getUser
.withArgs(this.user._id)
.resolves(this.user)
this.UserGetter.promises.getUser
.withArgs(this.collaborator._id)
.resolves(this.collaborator)
})
it("should return a not found error if the project can't be found", async function() {
this.ProjectGetter.promises.getProject.resolves(null)
await expect(
this.handler.promises.transferOwnership('abc', this.collaborator._id)
).to.be.rejectedWith(Errors.ProjectNotFoundError)
})
it("should return a not found error if the user can't be found", async function() {
this.UserGetter.promises.getUser
.withArgs(this.collaborator._id)
.resolves(null)
await expect(
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejectedWith(Errors.UserNotFoundError)
})
it('should return an error if user cannot be removed as collaborator ', async function() {
this.CollaboratorsHandler.promises.removeUserFromProject.rejects(
new Error('user-cannot-be-removed')
)
await expect(
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejected
})
it('should transfer ownership of the project', async function() {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project._id },
sinon.match({ $set: { owner_ref: this.collaborator._id } })
)
})
it('should do nothing if transferring back to the owner', async function() {
await this.handler.promises.transferOwnership(
this.project._id,
this.user._id
)
expect(this.ProjectModel.update).not.to.have.been.called
})
it("should remove the user from the project's collaborators", async function() {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.CollaboratorsHandler.promises.removeUserFromProject
).to.have.been.calledWith(this.project._id, this.collaborator._id)
})
it('should add the former project owner as a read/write collaborator', async function() {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
this.project._id,
this.collaborator._id,
this.user._id,
PrivilegeLevels.READ_AND_WRITE
)
})
it('should flush the project to tpds', async function() {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.ProjectEntityHandler.promises.flushProjectToThirdPartyDataStore
).to.have.been.calledWith(this.project._id)
})
it('should send an email notification', async function() {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'ownershipTransferConfirmationPreviousOwner',
{
to: this.user.email,
project: this.project,
newOwner: this.collaborator
}
)
expect(this.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'ownershipTransferConfirmationNewOwner',
{
to: this.collaborator.email,
project: this.project,
previousOwner: this.user
}
)
})
it('should decline to transfer ownership to a non-collaborator', async function() {
this.project.collaberator_refs = []
await expect(
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejectedWith(Errors.UserNotCollaboratorError)
})
})
describe('getProjectDescription', function() {
it('should make a call to mongo just for the description', async function() {
this.ProjectGetter.promises.getProject.resolves()
await this.handler.promises.getProjectDescription(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project._id,
{ description: true }
)
})
it('should return what the mongo call returns', async function() {
const expectedDescription = 'cool project'
this.ProjectGetter.promises.getProject.resolves({
description: expectedDescription
})
const description = await this.handler.promises.getProjectDescription(
this.project._id
)
expect(description).to.equal(expectedDescription)
})
})
describe('setProjectDescription', function() {
beforeEach(function() {
this.description = 'updated teh description'
})
it('should update the project detials', async function() {
await this.handler.promises.setProjectDescription(
this.project._id,
this.description
)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project._id },
{ description: this.description }
)
})
})
describe('renameProject', function() {
beforeEach(function() {
this.newName = 'new name here'
})
it('should update the project with the new name', async function() {
await this.handler.promises.renameProject(this.project._id, this.newName)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project._id },
{ name: this.newName }
)
})
it('should tell the TpdsUpdateSender', async function() {
await this.handler.promises.renameProject(this.project._id, this.newName)
expect(this.TpdsUpdateSender.promises.moveEntity).to.have.been.calledWith(
{
project_id: this.project._id,
project_name: this.project.name,
newProjectName: this.newName
}
)
})
it('should not do anything with an invalid name', async function() {
await expect(this.handler.promises.renameProject(this.project._id)).to.be
.rejected
expect(this.TpdsUpdateSender.promises.moveEntity).not.to.have.been.called
expect(this.ProjectModel.update).not.to.have.been.called
})
})
describe('validateProjectName', function() {
it('should reject undefined names', async function() {
await expect(this.handler.promises.validateProjectName(undefined)).to.be
.rejected
})
it('should reject empty names', async function() {
await expect(this.handler.promises.validateProjectName('')).to.be.rejected
})
it('should reject names with /s', async function() {
await expect(this.handler.promises.validateProjectName('foo/bar')).to.be
.rejected
})
it('should reject names with \\s', async function() {
await expect(this.handler.promises.validateProjectName('foo\\bar')).to.be
.rejected
})
it('should reject long names', async function() {
await expect(this.handler.promises.validateProjectName('a'.repeat(1000)))
.to.be.rejected
})
it('should accept normal names', async function() {
await expect(this.handler.promises.validateProjectName('foobar')).to.be
.resolved
})
})
describe('generateUniqueName', function() {
beforeEach(function() {
this.longName = 'x'.repeat(this.handler.MAX_PROJECT_NAME_LENGTH - 5)
const usersProjects = {
owned: [
{ _id: 1, name: 'name' },
{ _id: 2, name: 'name1' },
{ _id: 3, name: 'name11' },
{ _id: 100, name: 'numeric' },
{ _id: 101, name: 'numeric (1)' },
{ _id: 102, name: 'numeric (2)' },
{ _id: 103, name: 'numeric (3)' },
{ _id: 104, name: 'numeric (4)' },
{ _id: 105, name: 'numeric (5)' },
{ _id: 106, name: 'numeric (6)' },
{ _id: 107, name: 'numeric (7)' },
{ _id: 108, name: 'numeric (8)' },
{ _id: 109, name: 'numeric (9)' },
{ _id: 110, name: 'numeric (10)' },
{ _id: 111, name: 'numeric (11)' },
{ _id: 112, name: 'numeric (12)' },
{ _id: 113, name: 'numeric (13)' },
{ _id: 114, name: 'numeric (14)' },
{ _id: 115, name: 'numeric (15)' },
{ _id: 116, name: 'numeric (16)' },
{ _id: 117, name: 'numeric (17)' },
{ _id: 118, name: 'numeric (18)' },
{ _id: 119, name: 'numeric (19)' },
{ _id: 120, name: 'numeric (20)' },
{ _id: 130, name: 'numeric (30)' },
{ _id: 131, name: 'numeric (31)' },
{ _id: 132, name: 'numeric (32)' },
{ _id: 133, name: 'numeric (33)' },
{ _id: 134, name: 'numeric (34)' },
{ _id: 135, name: 'numeric (35)' },
{ _id: 136, name: 'numeric (36)' },
{ _id: 137, name: 'numeric (37)' },
{ _id: 138, name: 'numeric (38)' },
{ _id: 139, name: 'numeric (39)' },
{ _id: 140, name: 'numeric (40)' }
],
readAndWrite: [{ _id: 4, name: 'name2' }, { _id: 5, name: 'name22' }],
readOnly: [{ _id: 6, name: 'name3' }, { _id: 7, name: 'name33' }],
tokenReadAndWrite: [
{ _id: 8, name: 'name4' },
{ _id: 9, name: 'name44' }
],
tokenReadOnly: [
{ _id: 10, name: 'name5' },
{ _id: 11, name: 'name55' },
{ _id: 12, name: this.longName }
]
}
this.ProjectGetter.promises.findAllUsersProjects.resolves(usersProjects)
})
it('should leave a unique name unchanged', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'unique-name',
['-test-suffix']
)
expect(name).to.equal('unique-name')
})
it('should append a suffix to an existing name', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'name1',
['-test-suffix']
)
expect(name).to.equal('name1-test-suffix')
})
it('should fallback to a second suffix when needed', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'name1',
['1', '-test-suffix']
)
expect(name).to.equal('name1-test-suffix')
})
it('should truncate the name when append a suffix if the result is too long', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
this.longName,
['-test-suffix']
)
expect(name).to.equal(
this.longName.substr(0, this.handler.MAX_PROJECT_NAME_LENGTH - 12) +
'-test-suffix'
)
})
it('should use a numeric index if no suffix is supplied', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'name1',
[]
)
expect(name).to.equal('name1 (1)')
})
it('should use a numeric index if all suffixes are exhausted', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'name',
['1', '11']
)
expect(name).to.equal('name (1)')
})
it('should find the next lowest available numeric index for the base name', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'numeric',
[]
)
expect(name).to.equal('numeric (21)')
})
it('should find the next available numeric index when a numeric index is already present', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'numeric (5)',
[]
)
expect(name).to.equal('numeric (21)')
})
it('should not find a numeric index lower than the one already present', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user._id,
'numeric (31)',
[]
)
expect(name).to.equal('numeric (41)')
})
})
describe('fixProjectName', function() {
it('should change empty names to Untitled', function() {
expect(this.handler.fixProjectName('')).to.equal('Untitled')
})
it('should replace / with -', function() {
expect(this.handler.fixProjectName('foo/bar')).to.equal('foo-bar')
})
it("should replace \\ with ''", function() {
expect(this.handler.fixProjectName('foo \\ bar')).to.equal('foo bar')
})
it('should truncate long names', function() {
expect(this.handler.fixProjectName('a'.repeat(1000))).to.equal(
'a'.repeat(150)
)
})
it('should accept normal names', function() {
expect(this.handler.fixProjectName('foobar')).to.equal('foobar')
})
})
describe('setPublicAccessLevel', function() {
beforeEach(function() {
this.accessLevel = 'readOnly'
})
it('should update the project with the new level', async function() {
await this.handler.promises.setPublicAccessLevel(
this.project._id,
this.accessLevel
)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project._id },
{ publicAccesLevel: this.accessLevel }
)
})
it('should not produce an error', async function() {
await expect(
this.handler.promises.setPublicAccessLevel(
this.project._id,
this.accessLevel
)
).to.be.resolved
})
describe('when update produces an error', function() {
beforeEach(function() {
this.ProjectModel.update.rejects(new Error('woops'))
})
it('should produce an error', async function() {
await expect(
this.handler.promises.setPublicAccessLevel(
this.project._id,
this.accessLevel
)
).to.be.rejected
})
})
})
describe('ensureTokensArePresent', function() {
describe('when the project has tokens', function() {
beforeEach(function() {
this.project = {
_id: this.project._id,
tokens: {
readOnly: 'aaa',
readAndWrite: '42bbb',
readAndWritePrefix: '42'
}
}
this.ProjectGetter.promises.getProject.resolves(this.project)
})
it('should get the project', async function() {
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project._id,
{
tokens: 1
}
)
})
it('should not update the project with new tokens', async function() {
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectModel.update).not.to.have.been.called
})
it('should produce the tokens without error', async function() {
const tokens = await this.handler.promises.ensureTokensArePresent(
this.project._id
)
expect(tokens).to.deep.equal(this.project.tokens)
})
})
describe('when tokens are missing', function() {
beforeEach(function() {
this.project = { _id: this.project._id }
this.ProjectGetter.promises.getProject.resolves(this.project)
this.readOnlyToken = 'abc'
this.readAndWriteToken = '42def'
this.readAndWriteTokenPrefix = '42'
this.ProjectTokenGenerator.promises.generateUniqueReadOnlyToken.resolves(
this.readOnlyToken
)
this.ProjectTokenGenerator.readAndWriteToken.returns({
token: this.readAndWriteToken,
numericPrefix: this.readAndWriteTokenPrefix
})
})
it('should get the project', async function() {
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project._id,
{
tokens: 1
}
)
})
it('should update the project with new tokens', async function() {
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectTokenGenerator.promises.generateUniqueReadOnlyToken)
.to.have.been.calledOnce
expect(this.ProjectTokenGenerator.readAndWriteToken).to.have.been
.calledOnce
expect(this.ProjectModel.update).to.have.been.calledOnce
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project._id },
{
$set: {
tokens: {
readOnly: this.readOnlyToken,
readAndWrite: this.readAndWriteToken,
readAndWritePrefix: this.readAndWriteTokenPrefix
}
}
}
)
})
it('should produce the tokens without error', async function() {
const tokens = await this.handler.promises.ensureTokensArePresent(
this.project._id
)
expect(tokens).to.deep.equal({
readOnly: this.readOnlyToken,
readAndWrite: this.readAndWriteToken,
readAndWritePrefix: this.readAndWriteTokenPrefix
})
})
})
})
describe('clearTokens', function() {
it('clears the tokens from the project', async function() {
await this.handler.promises.clearTokens(this.project._id)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project._id },
{ $unset: { tokens: 1 }, $set: { publicAccesLevel: 'private' } }
)
})
})
})