Merge pull request #36222 from overleaf/jpa-zod-web-core-1

[web] core-1: adopt strict zod schema and extend test coverage

GitOrigin-RevId: 6dc74a100989e6eca1565480a9ecb5f24404ef96
This commit is contained in:
Jakob Ackermann
2026-09-17 11:55:33 +00:00
committed by Copybot
parent f26a48fc4c
commit e2564f356c
44 changed files with 3350 additions and 335 deletions
@@ -6,6 +6,9 @@ const MODULE_PATH = '../../../../app/src/Features/Chat/ChatController.mjs'
describe('ChatController', function () {
beforeEach(async function (ctx) {
ctx.user_id = 'mock-user-id'
// project_id/message_id are validated as Mongo ObjectIds
ctx.project_id = '507f191e810c19729de860ea'
ctx.message_id = '507f191e810c19729de860eb'
ctx.settings = {}
ctx.ChatApiHandler = { promises: {} }
ctx.ChatManager = { promises: {} }
@@ -126,10 +129,11 @@ describe('ChatController', function () {
describe('getMessages', function () {
beforeEach(async function (ctx) {
ctx.req.query = {
limit: (ctx.limit = '30'),
before: (ctx.before = '12345'),
}
// sent over the wire as query-string values, but coerced to numbers
// by the schema before being passed on
ctx.req.query = { limit: '30', before: '12345' }
ctx.limit = 30
ctx.before = 12345
ctx.ChatManager.promises.injectUserInfoIntoThreads = sinon
.stub()
.resolves()
@@ -149,4 +153,60 @@ describe('ChatController', function () {
ctx.res.json.calledWith(ctx.messages).should.equal(true)
})
})
describe('deleteMessage', function () {
beforeEach(async function (ctx) {
ctx.req.params.message_id = ctx.message_id
ctx.ChatApiHandler.promises.deleteGlobalMessage = sinon.stub().resolves()
await ctx.ChatController.deleteMessage(ctx.req, ctx.res)
})
it('should tell the chat handler to delete the message', function (ctx) {
ctx.ChatApiHandler.promises.deleteGlobalMessage
.calledWith(ctx.project_id, ctx.message_id)
.should.equal(true)
})
it('should tell the editor real time controller about the deletion', function (ctx) {
ctx.EditorRealTimeController.emitToRoom
.calledWith(ctx.project_id, 'delete-global-message', {
messageId: ctx.message_id,
userId: ctx.user_id,
})
.should.equal(true)
})
it('should return a 204 status code', function (ctx) {
ctx.res.sendStatus.calledWith(204).should.equal(true)
})
})
describe('editMessage', function () {
beforeEach(async function (ctx) {
ctx.req.params.message_id = ctx.message_id
ctx.req.body = { content: (ctx.content = 'edited-content') }
ctx.ChatApiHandler.promises.editGlobalMessage = sinon.stub().resolves()
await ctx.ChatController.editMessage(ctx.req, ctx.res, sinon.stub())
})
it('should tell the chat handler to edit the message', function (ctx) {
ctx.ChatApiHandler.promises.editGlobalMessage
.calledWith(ctx.project_id, ctx.message_id, ctx.user_id, ctx.content)
.should.equal(true)
})
it('should tell the editor real time controller about the edit', function (ctx) {
ctx.EditorRealTimeController.emitToRoom
.calledWith(ctx.project_id, 'edit-global-message', {
messageId: ctx.message_id,
userId: ctx.user_id,
content: ctx.content,
})
.should.equal(true)
})
it('should return a 204 status code', function (ctx) {
ctx.res.sendStatus.calledWith(204).should.equal(true)
})
})
})
@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import {
InvalidParamsError,
InvalidRequestError,
} from '@overleaf/validation-tools'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
@@ -51,8 +55,12 @@ describe('CollaboratorsController', function () {
promises: {
getAllInvitedMembers: sinon.stub(),
getProjectAccess: sinon.stub().resolves(ctx.projectAccess),
getPublicShareTokens: sinon.stub(),
},
}
ctx.Features = {
hasFeature: sinon.stub().returns(true),
}
ctx.AuthorizationManager = {
promises: {
getPrivilegeLevelForProject: sinon.stub().resolves('readOnly'),
@@ -100,6 +108,7 @@ describe('CollaboratorsController', function () {
}
ctx.TokenAccessHandler = {
getRequestToken: sinon.stub().returns('access-token'),
createTokenHashPrefix: sinon.stub().returns('hash-prefix'),
}
ctx.ProjectAuditLogHandler = {
@@ -236,6 +245,10 @@ describe('CollaboratorsController', function () {
default: ctx.EmailHandler,
}))
vi.doMock('../../../../app/src/infrastructure/Features.mjs', () => ({
default: ctx.Features,
}))
ctx.CollaboratorsController = (await import(MODULE_PATH)).default
})
@@ -243,8 +256,8 @@ describe('CollaboratorsController', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.req.params = {
Project_id: ctx.projectId,
user_id: ctx.user._id,
Project_id: ctx.projectId.toString(),
user_id: ctx.user._id.toString(),
}
ctx.res.sendStatus = sinon.spy(() => {
resolve()
@@ -256,14 +269,17 @@ describe('CollaboratorsController', function () {
it('should from the user from the project', function (ctx) {
expect(
ctx.CollaboratorsHandler.promises.removeUserFromProject
).to.have.been.calledWith(ctx.projectId, ctx.user._id)
).to.have.been.calledWith(
ctx.projectId.toString(),
ctx.user._id.toString()
)
})
it('should emit a userRemovedFromProject event to the proejct', function (ctx) {
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
ctx.projectId,
ctx.projectId.toString(),
'userRemovedFromProject',
ctx.user._id
ctx.user._id.toString()
)
})
@@ -273,36 +289,51 @@ describe('CollaboratorsController', function () {
it('should have called emitToRoom', function (ctx) {
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
ctx.projectId,
ctx.projectId.toString(),
'project:membership:changed'
)
})
it('should look up the collaborator email', function (ctx) {
expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
{ _id: ctx.user._id },
{ _id: ctx.user._id.toString() },
{ email: 1 }
)
})
it('should write a project audit log', function (ctx) {
ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith(
ctx.projectId,
ctx.projectId.toString(),
'remove-collaborator',
ctx.user._id,
ctx.req.ip,
{
userId: ctx.user._id,
userId: ctx.user._id.toString(),
collaboratorEmail: 'user@example.com',
}
)
})
})
describe('removeUserFromProject with a malformed project id', function () {
it('rejects the request instead of removing anyone', async function (ctx) {
ctx.req.params = {
Project_id: 'not-an-object-id',
user_id: ctx.user._id.toString(),
}
await ctx.CollaboratorsController.removeUserFromProject(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.CollaboratorsHandler.promises.removeUserFromProject).to.not
.have.been.called
})
})
describe('removeSelfFromProject', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.req.params = { Project_id: ctx.projectId }
ctx.req.params = { Project_id: ctx.projectId.toString() }
ctx.res.sendStatus = sinon.spy(() => {
resolve()
})
@@ -313,12 +344,12 @@ describe('CollaboratorsController', function () {
it('should remove the logged in user from the project', function (ctx) {
expect(
ctx.CollaboratorsHandler.promises.removeUserFromProject
).to.have.been.calledWith(ctx.projectId, ctx.user._id)
).to.have.been.calledWith(ctx.projectId.toString(), ctx.user._id)
})
it('should emit a userRemovedFromProject event to the proejct', function (ctx) {
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
ctx.projectId,
ctx.projectId.toString(),
'userRemovedFromProject',
ctx.user._id
)
@@ -327,7 +358,7 @@ describe('CollaboratorsController', function () {
it('should remove the project from all tags', function (ctx) {
expect(
ctx.TagsHandler.promises.removeProjectFromAllTags
).to.have.been.calledWith(ctx.user._id, ctx.projectId)
).to.have.been.calledWith(ctx.user._id, ctx.projectId.toString())
})
it('should return a success code', function (ctx) {
@@ -336,7 +367,7 @@ describe('CollaboratorsController', function () {
it('should emit a project:membership:changed event to the project', function (ctx) {
expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
ctx.projectId,
ctx.projectId.toString(),
'project:membership:changed',
{ members: true }
)
@@ -344,7 +375,7 @@ describe('CollaboratorsController', function () {
it('should write a project audit log', function (ctx) {
ctx.ProjectAuditLogHandler.addEntryInBackground.should.have.been.calledWith(
ctx.projectId,
ctx.projectId.toString(),
'leave-project',
ctx.user._id,
ctx.req.ip
@@ -352,10 +383,22 @@ describe('CollaboratorsController', function () {
})
})
describe('removeSelfFromProject with a malformed project id', function () {
it('rejects the request instead of removing anyone', async function (ctx) {
ctx.req.params = { Project_id: 'not-an-object-id' }
await ctx.CollaboratorsController.removeSelfFromProject(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.CollaboratorsHandler.promises.removeUserFromProject).to.not
.have.been.called
})
})
describe('getAllMembers', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.req.params = { Project_id: ctx.projectId }
ctx.req.params = { Project_id: ctx.projectId.toString() }
ctx.res.json = sinon.spy(() => {
resolve()
})
@@ -409,6 +452,18 @@ describe('CollaboratorsController', function () {
})
})
describe('getAllMembers with a malformed project id', function () {
it('rejects the request instead of listing members', async function (ctx) {
ctx.req.params = { Project_id: 'not-an-object-id' }
await ctx.CollaboratorsController.getAllMembers(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.CollaboratorsGetter.promises.getAllInvitedMembers).to.not.have
.been.called
})
})
describe('getAccessRequests', function () {
beforeEach(async function (ctx) {
ctx.requesterId = new ObjectId()
@@ -443,6 +498,18 @@ describe('CollaboratorsController', function () {
})
})
describe('getAccessRequests with a malformed project id', function () {
it('rejects the request instead of listing access requests', async function (ctx) {
ctx.req.params = { Project_id: 'not-an-object-id' }
await ctx.CollaboratorsController.getAccessRequests(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.CollaboratorsGetter.promises.getProjectAccess).to.not.have.been
.called
})
})
describe('setCollaboratorInfo', function () {
beforeEach(function (ctx) {
ctx.req.params = {
@@ -502,6 +569,23 @@ describe('CollaboratorsController', function () {
})
})
it('rejects an unrecognized field in the body instead of falling back', async function (ctx) {
ctx.req.body = { privilegeLevel: 'readOnly', notARealField: 'nope' }
await new Promise(resolve => {
ctx.next = sinon.spy(err => {
expect(err).to.be.instanceOf(InvalidRequestError)
resolve()
})
ctx.CollaboratorsController.setCollaboratorInfo(
ctx.req,
ctx.res,
ctx.next
)
})
expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to
.not.have.been.called
})
describe('when setting privilege level to readAndWrite', function () {
beforeEach(function (ctx) {
ctx.req.body = { privilegeLevel: 'readAndWrite' }
@@ -644,6 +728,19 @@ describe('CollaboratorsController', function () {
ctx.CollaboratorsController.transferOwnership(ctx.req, ctx.res)
})
})
it('rejects an unrecognized field in the body instead of falling back', async function (ctx) {
ctx.req.body = {
user_id: ctx.user._id.toString(),
notARealField: 'nope',
}
await ctx.CollaboratorsController.transferOwnership(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidRequestError)
expect(ctx.OwnershipTransferHandler.promises.transferOwnership).to.not
.have.been.called
})
})
describe('requestAccess', function () {
@@ -758,6 +855,19 @@ describe('CollaboratorsController', function () {
})
})
describe('requestAccess with a malformed project id', function () {
it('rejects the request instead of recording it', async function (ctx) {
ctx.req.params = { Project_id: 'not-an-object-id' }
ctx.req.body = { privilegeLevel: 'readAndWrite' }
await ctx.CollaboratorsController.requestAccess(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.CollaboratorsHandler.promises.requestAccess).to.not.have.been
.called
})
})
describe('declineAccessRequest', function () {
beforeEach(function (ctx) {
ctx.requesterId = new ObjectId()
@@ -850,6 +960,21 @@ describe('CollaboratorsController', function () {
})
})
describe('declineAccessRequest with a malformed project id', function () {
it('rejects the request instead of declining it', async function (ctx) {
ctx.req.params = {
Project_id: 'not-an-object-id',
user_id: new ObjectId().toString(),
}
await ctx.CollaboratorsController.declineAccessRequest(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.CollaboratorsHandler.promises.declineAccessRequest).to.not.have
.been.called
})
})
describe('grantAccessRequest', function () {
beforeEach(function (ctx) {
ctx.requesterId = new ObjectId()
@@ -1006,4 +1131,100 @@ describe('CollaboratorsController', function () {
)
})
})
describe('grantAccessRequest with a malformed project id', function () {
it('rejects the request instead of granting it', async function (ctx) {
ctx.req.params = {
Project_id: 'not-an-object-id',
user_id: new ObjectId().toString(),
}
ctx.req.body = { privilegeLevel: 'readAndWrite' }
await ctx.CollaboratorsController.grantAccessRequest(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to
.not.have.been.called
})
})
describe('getShareTokens', function () {
beforeEach(function (ctx) {
ctx.req.params = { Project_id: ctx.projectId.toString() }
})
it('rejects a malformed project id before checking anything else', async function (ctx) {
ctx.req.params = { Project_id: 'not-an-object-id' }
await ctx.CollaboratorsController.getShareTokens(
ctx.req,
ctx.res
).should.be.rejectedWith(InvalidParamsError)
expect(ctx.CollaboratorsGetter.promises.getPublicShareTokens).to.not.have
.been.called
})
it('returns 403 when link sharing is disabled', async function (ctx) {
ctx.Features.hasFeature.withArgs('link-sharing').returns(false)
await new Promise(resolve => {
ctx.res.sendStatus = sinon.spy(() => resolve())
ctx.CollaboratorsController.getShareTokens(ctx.req, ctx.res)
})
expect(ctx.res.sendStatus).to.have.been.calledWith(403)
expect(ctx.CollaboratorsGetter.promises.getPublicShareTokens).to.not.have
.been.called
})
it('returns 403 when the logged in user has no share tokens', async function (ctx) {
ctx.CollaboratorsGetter.promises.getPublicShareTokens.resolves(null)
await new Promise(resolve => {
ctx.res.sendStatus = sinon.spy(() => resolve())
ctx.CollaboratorsController.getShareTokens(ctx.req, ctx.res)
})
expect(ctx.res.sendStatus).to.have.been.calledWith(403)
})
it('returns the hashed tokens for a logged in user', async function (ctx) {
ctx.CollaboratorsGetter.promises.getPublicShareTokens.resolves({
readOnly: 'ro-token',
readAndWrite: 'rw-token',
})
await new Promise(resolve => {
ctx.res.json = sinon.spy(() => resolve())
ctx.CollaboratorsController.getShareTokens(ctx.req, ctx.res)
})
expect(
ctx.CollaboratorsGetter.promises.getPublicShareTokens
).to.have.been.calledWith(
new ObjectId(ctx.user._id.toString()),
new ObjectId(ctx.projectId.toString())
)
expect(ctx.res.json).to.have.been.calledWith({
readOnly: 'ro-token',
readAndWrite: 'rw-token',
readOnlyHashPrefix: 'hash-prefix',
readAndWriteHashPrefix: 'hash-prefix',
})
})
it('falls back to the session token for an anonymous request', async function (ctx) {
ctx.SessionManager.getLoggedInUserId = sinon.stub().returns(null)
ctx.TokenAccessHandler.getRequestToken = sinon
.stub()
.returns('anon-ro-token')
await new Promise(resolve => {
ctx.res.json = sinon.spy(() => resolve())
ctx.CollaboratorsController.getShareTokens(ctx.req, ctx.res)
})
expect(ctx.CollaboratorsGetter.promises.getPublicShareTokens).to.not.have
.been.called
expect(ctx.TokenAccessHandler.getRequestToken).to.have.been.calledWith(
ctx.req,
ctx.projectId.toString()
)
expect(ctx.res.json).to.have.been.calledWith({
readOnly: 'anon-ro-token',
readOnlyHashPrefix: 'hash-prefix',
})
})
})
})
@@ -37,7 +37,9 @@ describe('DocumentUpdaterController', function () {
)
ctx.controller = (await import(MODULE_PATH)).default
ctx.projectId = '2k3j1lk3j21lk3j'
// Project_id/Doc_id are validated as Mongo ObjectIds
ctx.projectId = '507f191e810c19729de860ea'
ctx.docId = '507f191e810c19729de860eb'
ctx.fileId = '12321kklj1lk3jk12'
ctx.req = {
params: {
@@ -12,7 +12,7 @@ describe('DocumentController', function () {
ctx.res = new MockResponse(vi)
ctx.req = new MockRequest(vi)
ctx.next = sinon.stub()
ctx.doc = { _id: 'doc-id-123' }
ctx.doc = { _id: '5c9a5d5b0000000000000002' }
ctx.doc_lines = ['one', 'two', 'three']
ctx.version = 42
ctx.ranges = {
@@ -36,11 +36,15 @@ describe('DocumentController', function () {
],
}
ctx.pathname = '/a/b/c/file.tex'
ctx.lastUpdatedAt = new Date().getTime()
ctx.lastUpdatedBy = 'fake-last-updater-id'
// stored/read back as a string -- Redis (ioredis mget) always returns
// strings, and document-updater's RedisManager never parses this key
// back to a number, so this is a numeric-looking string on the wire,
// not a JS number.
ctx.lastUpdatedAt = String(new Date().getTime())
ctx.lastUpdatedBy = '5c9a5d5b0000000000000003'
ctx.rev = 5
ctx.project = {
_id: 'project-id-123',
_id: '5c9a5d5b0000000000000001',
overleaf: {
history: {
id: 1234,
@@ -212,7 +216,7 @@ describe('DocumentController', function () {
ctx.doc_lines,
ctx.version,
ctx.ranges,
ctx.lastUpdatedAt,
Number(ctx.lastUpdatedAt),
ctx.lastUpdatedBy
)
})
@@ -237,7 +241,11 @@ describe('DocumentController', function () {
ctx.ProjectEntityUpdateHandler.promises.updateDocLines.rejects(
new Errors.NotFoundError('document does not exist')
)
ctx.req.body = { lines: ctx.doc_lines }
ctx.req.body = {
lines: ctx.doc_lines,
version: ctx.version,
ranges: ctx.ranges,
}
ctx.next.callsFake(() => {
resolve()
})
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
@@ -10,6 +10,7 @@ import sinon from 'sinon'
import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
import { DocumentConversionError } from '../../../../app/src/Features/Errors/Errors.js'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
@@ -19,7 +20,7 @@ const modulePath =
describe('ProjectDownloadsController', function () {
beforeEach(async function (ctx) {
ctx.project_id = 'project-id-123'
ctx.project_id = '507f191e810c19729de860ea'
ctx.req = new MockRequest(vi)
ctx.res = new MockResponse(vi)
ctx.next = sinon.stub()
@@ -113,6 +114,10 @@ describe('ProjectDownloadsController', function () {
ctx.ProjectDownloadsController = (await import(modulePath)).default
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('downloadProject', function () {
beforeEach(function (ctx) {
ctx.stream = { pipe: sinon.stub() }
@@ -194,13 +199,72 @@ describe('ProjectDownloadsController', function () {
})
})
describe('downloadProject with an invalid Project_id', function () {
beforeEach(function (ctx) {
ctx.stream = { pipe: sinon.stub() }
ctx.ProjectZipStreamManager.createZipStreamForProject = sinon
.stub()
.yields(null, ctx.stream)
ctx.req.params = { Project_id: 'not-a-valid-object-id' }
ctx.req.ip = '192.168.1.1'
ctx.req.session = {
user: {
_id: 'user-id-123',
email: 'user@example.com',
},
}
ctx.ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, {
name: 'a project',
overleaf: { history: { id: 123 } },
})
ctx.DocumentUpdaterHandler.flushProjectToMongo = sinon
.stub()
.callsArgWith(1)
})
it('should still process the request in log mode', function (ctx) {
setReqValidationModeForTests('log')
ctx.ProjectDownloadsController.downloadProject(ctx.req, ctx.res, ctx.next)
sinon.assert.notCalled(ctx.next)
sinon.assert.calledWith(
ctx.ProjectZipStreamManager.createZipStreamForProject,
'not-a-valid-object-id'
)
})
describe('when enforced', function () {
afterEach(function () {
setReqValidationModeForTests(null)
})
it('should reject the request', function (ctx) {
setReqValidationModeForTests('enforce')
let error
try {
ctx.ProjectDownloadsController.downloadProject(
ctx.req,
ctx.res,
ctx.next
)
} catch (err) {
error = err
}
expect(error).to.exist
expect(error.name).to.equal('InvalidParamsError')
sinon.assert.notCalled(
ctx.ProjectZipStreamManager.createZipStreamForProject
)
})
})
})
describe('downloadMultipleProjects', function () {
beforeEach(function (ctx) {
ctx.stream = { pipe: sinon.stub() }
ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects = sinon
.stub()
.yields(null, ctx.stream)
ctx.project_ids = ['project-1', 'project-2']
ctx.project_ids = ['507f191e810c19729de860ea', '507f191e810c19729de860eb']
ctx.req.query = { project_ids: ctx.project_ids.join(',') }
ctx.req.ip = '192.168.1.1'
ctx.req.session = {
@@ -273,6 +337,67 @@ describe('ProjectDownloadsController', function () {
})
})
describe('downloadMultipleProjects with an invalid project id', function () {
beforeEach(function (ctx) {
ctx.stream = { pipe: sinon.stub() }
ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects = sinon
.stub()
.yields(null, ctx.stream)
ctx.req.query = {
project_ids: 'not-a-valid-object-id,507f191e810c19729de860eb',
}
ctx.req.ip = '192.168.1.1'
ctx.req.session = {
user: {
_id: 'user-id-123',
email: 'user@example.com',
},
}
ctx.DocumentUpdaterHandler.flushMultipleProjectsToMongo = sinon
.stub()
.callsArgWith(1)
})
it('should still process the request via the fallback schema in log mode', function (ctx) {
setReqValidationModeForTests('log')
ctx.ProjectDownloadsController.downloadMultipleProjects(
ctx.req,
ctx.res,
ctx.next
)
sinon.assert.notCalled(ctx.next)
sinon.assert.calledWith(
ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects,
['not-a-valid-object-id', '507f191e810c19729de860eb']
)
})
describe('when enforced', function () {
afterEach(function () {
setReqValidationModeForTests(null)
})
it('should reject the request', function (ctx) {
setReqValidationModeForTests('enforce')
let error
try {
ctx.ProjectDownloadsController.downloadMultipleProjects(
ctx.req,
ctx.res,
ctx.next
)
} catch (err) {
error = err
}
expect(error).to.exist
expect(error.name).to.equal('InvalidRequestError')
sinon.assert.notCalled(
ctx.ProjectZipStreamManager.createZipStreamForMultipleProjects
)
})
})
})
describe('exportProjectConversion', function () {
describe('with a supported type (default streaming)', function () {
beforeEach(async function (ctx) {
@@ -51,7 +51,8 @@ describe('EditorHttpController', function () {
ctx.folder = { _id: new ObjectId() }
ctx.source = 'editor'
ctx.parentFolderId = 'mock-folder-id'
// parent_folder_id/entity_id/folder_id are all validated as Mongo ObjectIds
ctx.parentFolderId = new ObjectId().toString()
ctx.req = new MockRequest(vi)
ctx.res = new MockResponse(vi)
ctx.next = sinon.stub()
@@ -526,7 +527,7 @@ describe('EditorHttpController', function () {
describe('addDoc', function () {
beforeEach(function (ctx) {
ctx.req.params = { Project_id: ctx.project._id }
ctx.req.params = { Project_id: ctx.project._id.toString() }
ctx.req.body = {
name: (ctx.docName = 'doc-name'),
parent_folder_id: ctx.parentFolderId,
@@ -543,7 +544,7 @@ describe('EditorHttpController', function () {
it('should call EditorController.addDoc', function (ctx) {
expect(ctx.EditorController.promises.addDoc).to.have.been.calledWith(
ctx.project._id,
ctx.project._id.toString(),
ctx.parentFolderId,
ctx.docName,
[],
@@ -588,7 +589,7 @@ describe('EditorHttpController', function () {
describe('addFolder', function () {
beforeEach(function (ctx) {
ctx.folderName = 'folder-name'
ctx.req.params = { Project_id: ctx.project._id }
ctx.req.params = { Project_id: ctx.project._id.toString() }
ctx.req.body = {
name: ctx.folderName,
parent_folder_id: ctx.parentFolderId,
@@ -605,7 +606,7 @@ describe('EditorHttpController', function () {
it('should call EditorController.addFolder', function (ctx) {
expect(ctx.EditorController.promises.addFolder).to.have.been.calledWith(
ctx.project._id,
ctx.project._id.toString(),
ctx.parentFolderId,
ctx.folderName,
'editor'
@@ -661,10 +662,11 @@ describe('EditorHttpController', function () {
describe('renameEntity', function () {
beforeEach(function (ctx) {
ctx.entityId = 'entity-id-123'
ctx.entityType = 'entity-type'
// entity_id is validated as a Mongo ObjectId; entity_type as an enum
ctx.entityId = new ObjectId().toString()
ctx.entityType = 'doc'
ctx.req.params = {
Project_id: ctx.project._id,
Project_id: ctx.project._id.toString(),
entity_id: ctx.entityId,
entity_type: ctx.entityType,
}
@@ -684,7 +686,7 @@ describe('EditorHttpController', function () {
expect(
ctx.EditorController.promises.renameEntity
).to.have.been.calledWith(
ctx.project._id,
ctx.project._id.toString(),
ctx.entityId,
ctx.entityType,
ctx.newName,
@@ -725,11 +727,13 @@ describe('EditorHttpController', function () {
describe('moveEntity', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.entityId = 'entity-id-123'
ctx.entityType = 'entity-type'
ctx.folderId = 'folder-id-123'
// entity_id/folder_id are validated as Mongo ObjectIds; entity_type
// as an enum
ctx.entityId = new ObjectId().toString()
ctx.entityType = 'doc'
ctx.folderId = new ObjectId().toString()
ctx.req.params = {
Project_id: ctx.project._id,
Project_id: ctx.project._id.toString(),
entity_id: ctx.entityId,
entity_type: ctx.entityType,
}
@@ -741,7 +745,7 @@ describe('EditorHttpController', function () {
it('should call EditorController.moveEntity', function (ctx) {
expect(ctx.EditorController.promises.moveEntity).to.have.been.calledWith(
ctx.project._id,
ctx.project._id.toString(),
ctx.entityId,
ctx.folderId,
ctx.entityType,
@@ -755,35 +759,40 @@ describe('EditorHttpController', function () {
})
})
describe('deleteEntity', function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
ctx.entityId = 'entity-id-123'
ctx.entityType = 'entity-type'
ctx.req.params = {
Project_id: ctx.project._id,
entity_id: ctx.entityId,
entity_type: ctx.entityType,
}
ctx.res.callback = resolve
ctx.EditorHttpController.deleteEntity(ctx.req, ctx.res)
for (const [name, entityType] of [
['deleteDoc', 'doc'],
['deleteFile', 'file'],
['deleteFolder', 'folder'],
]) {
describe(name, function () {
beforeEach(async function (ctx) {
await new Promise(resolve => {
// entity_id is validated as a Mongo ObjectId
ctx.entityId = new ObjectId().toString()
ctx.req.params = {
Project_id: ctx.project._id.toString(),
entity_id: ctx.entityId,
}
ctx.res.callback = resolve
ctx.EditorHttpController[name](ctx.req, ctx.res)
})
})
it('should call EditorController.deleteEntity', function (ctx) {
expect(
ctx.EditorController.promises.deleteEntity
).to.have.been.calledWith(
ctx.project._id.toString(),
ctx.entityId,
entityType,
'editor',
ctx.user._id
)
})
it('should send back a success response', function (ctx) {
expect(ctx.res.statusCode).to.equal(204)
})
})
it('should call EditorController.deleteEntity', function (ctx) {
expect(
ctx.EditorController.promises.deleteEntity
).to.have.been.calledWith(
ctx.project._id,
ctx.entityId,
ctx.entityType,
'editor',
ctx.user._id
)
})
it('should send back a success response', function (ctx) {
expect(ctx.res.statusCode).to.equal(204)
})
})
}
})
@@ -201,6 +201,25 @@ describe('HistoryController', function () {
})
})
describe('proxyToHistoryApi (with an invalid request)', function () {
beforeEach(async function (ctx) {
ctx.req = {
url: '/mock/url',
method: 'GET',
session: sinon.stub(),
params: { Project_id: 'not-an-object-id' },
}
ctx.res = { set: sinon.stub() }
await ctx.HistoryController.proxyToHistoryApi(ctx.req, ctx.res, ctx.next)
})
it('rejects the request without calling the history api', function (ctx) {
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.fetchStreamWithResponse.should.not.have.been.called
})
})
describe('proxyToHistoryApiAndInjectUserDetails', function () {
beforeEach(async function (ctx) {
ctx.req = {
@@ -280,6 +299,28 @@ describe('HistoryController', function () {
})
})
describe('proxyToHistoryApiAndInjectUserDetails (with an invalid request)', function () {
beforeEach(async function (ctx) {
ctx.req = {
url: '/mock/url',
method: 'GET',
params: { Project_id: 'not-an-object-id' },
}
ctx.res = { json: sinon.stub() }
await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
ctx.req,
ctx.res,
ctx.next
)
})
it('rejects the request without calling the history api', function (ctx) {
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.fetchJson.should.not.have.been.called
})
})
describe('resyncProjectHistory', function () {
describe('for a project without project-history enabled', function () {
beforeEach(async function (ctx) {
@@ -329,6 +370,29 @@ describe('HistoryController', function () {
ctx.res.sendStatus.should.have.been.calledWith(204)
})
})
describe('with an invalid historyRangesMigration value', function () {
beforeEach(async function (ctx) {
ctx.req = {
params: { Project_id: ctx.project_id },
body: { historyRangesMigration: 'sideways' },
}
ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
await ctx.HistoryController.resyncProjectHistory(
ctx.req,
ctx.res,
ctx.next
)
})
it('rejects the request without resyncing', function (ctx) {
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.not
.have.been.called
})
})
})
describe('restoreFileFromV2', function () {
@@ -490,6 +554,106 @@ describe('HistoryController', function () {
})
})
describe('getLabels', function () {
beforeEach(function (ctx) {
ctx.res = { json: sinon.stub() }
ctx.labels = [{ id: 'label-1', comment: 'a label' }]
ctx.fetchJson.resolves(ctx.labels)
})
it('fetches and returns the labels for a valid request', async function (ctx) {
ctx.req = { params: { Project_id: ctx.project_id } }
await ctx.HistoryController.getLabels(ctx.req, ctx.res, ctx.next)
ctx.fetchJson.should.have.been.calledWith(
`${ctx.settings.apis.project_history.url}/project/${ctx.project_id}/labels`
)
ctx.res.json.should.have.been.calledWith(ctx.labels)
})
it('rejects a malformed Project_id without calling the history api', async function (ctx) {
ctx.req = { params: { Project_id: 'not-an-object-id' } }
await ctx.HistoryController.getLabels(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.fetchJson.should.not.have.been.called
})
})
describe('createLabel', function () {
beforeEach(function (ctx) {
ctx.res = { json: sinon.stub() }
})
it('creates and returns the label for a valid request', async function (ctx) {
ctx.req = {
params: { Project_id: ctx.project_id },
body: { comment: 'a label', version: 3 },
session: {},
}
ctx.label = { id: 'label-1', comment: 'a label', version: 3 }
ctx.fetchJson.resolves(ctx.label)
await ctx.HistoryController.createLabel(ctx.req, ctx.res, ctx.next)
ctx.fetchJson.should.have.been.calledWith(
`${ctx.settings.apis.project_history.url}/project/${ctx.project_id}/labels`,
{
method: 'POST',
json: { comment: 'a label', version: 3, user_id: ctx.user_id },
}
)
ctx.res.json.should.have.been.calledWith(
sinon.match({ comment: 'a label', version: 3 })
)
})
it('rejects a negative version without calling the history api', async function (ctx) {
ctx.req = {
params: { Project_id: ctx.project_id },
body: { comment: 'a label', version: -1 },
session: {},
}
await ctx.HistoryController.createLabel(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.fetchJson.should.not.have.been.called
})
})
describe('deleteLabel', function () {
beforeEach(function (ctx) {
ctx.res = { sendStatus: sinon.stub() }
ctx.labelId = '000000000000000087654321'
ctx.ProjectGetter.promises = {
getProject: sinon.stub().resolves({
owner_ref: { equals: sinon.stub().returns(true) },
}),
}
})
it('deletes the label for a valid request', async function (ctx) {
ctx.req = {
params: { Project_id: ctx.project_id, label_id: ctx.labelId },
session: {},
}
await ctx.HistoryController.deleteLabel(ctx.req, ctx.res, ctx.next)
ctx.fetchNothing.should.have.been.calledWith(
`${ctx.settings.apis.project_history.url}/project/${ctx.project_id}/labels/${ctx.labelId}`,
{ method: 'DELETE' }
)
ctx.res.sendStatus.should.have.been.calledWith(204)
})
it('rejects a malformed label_id without calling the history api', async function (ctx) {
ctx.req = {
params: { Project_id: ctx.project_id, label_id: 'not-an-object-id' },
session: {},
}
await ctx.HistoryController.deleteLabel(ctx.req, ctx.res, ctx.next)
ctx.next.should.have.been.calledOnce
ctx.next.firstCall.args[0].should.be.an.instanceof(Error)
ctx.fetchNothing.should.not.have.been.called
})
})
describe('requestBlob', function () {
describe('With Range header', function () {
beforeEach(async function (ctx) {
@@ -1,8 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import MockResponse from '../helpers/MockResponse.mjs'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
const modulePath = '../../../../app/src/Features/Metadata/MetaController.mjs'
const PROJECT_ID = '507f1f77bcf86cd799439011'
const DOC_ID = '507f191e810c19729de860ea'
describe('MetaController', function () {
beforeEach(async function (ctx) {
ctx.EditorRealTimeController = {
@@ -46,6 +50,10 @@ describe('MetaController', function () {
ctx.MetadataController = (await import(modulePath)).default
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('getMetadata', function () {
it('should respond with json', async function (ctx) {
const projectMeta = {
@@ -60,18 +68,18 @@ describe('MetaController', function () {
.stub()
.resolves(projectMeta)
const req = { params: { project_id: 'project-id' } }
const req = { params: { project_id: PROJECT_ID } }
const res = new MockResponse(vi)
const next = sinon.stub()
await ctx.MetadataController.getMetadata(req, res, next)
ctx.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith(
'project-id'
PROJECT_ID
)
expect(res.json).toHaveBeenCalledTimes(1)
expect(res.json).toHaveBeenCalledWith({
projectId: 'project-id',
projectId: PROJECT_ID,
projectMeta,
})
next.should.not.have.been.called
@@ -82,18 +90,33 @@ describe('MetaController', function () {
.stub()
.throws(new Error('woops'))
const req = { params: { project_id: 'project-id' } }
const req = { params: { project_id: PROJECT_ID } }
const res = new MockResponse(vi)
const next = sinon.stub()
await ctx.MetadataController.getMetadata(req, res, next)
ctx.MetaHandler.promises.getAllMetaForProject.should.have.been.calledWith(
'project-id'
PROJECT_ID
)
expect(res.json).not.toHaveBeenCalled()
next.should.have.been.calledWithMatch(error => error instanceof Error)
})
it('should reject a malformed project id', async function (ctx) {
const req = { params: { project_id: 'not-an-object-id' } }
const res = new MockResponse(vi)
const next = sinon.stub()
await ctx.MetadataController.getMetadata(req, res, next)
expect(ctx.MetaHandler.promises.getAllMetaForProject.called).to.equal(
false
)
next.should.have.been.calledWithMatch(
error => error.name === 'InvalidParamsError'
)
})
})
describe('broadcastMetadataForDoc', function () {
@@ -107,7 +130,7 @@ describe('MetaController', function () {
ctx.EditorRealTimeController.emitToRoom = sinon.stub()
const req = {
params: { project_id: 'project-id', doc_id: 'doc-id' },
params: { project_id: PROJECT_ID, doc_id: DOC_ID },
body: { broadcast: true },
}
const res = new MockResponse(vi)
@@ -116,7 +139,7 @@ describe('MetaController', function () {
await ctx.MetadataController.broadcastMetadataForDoc(req, res, next)
ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
'project-id'
PROJECT_ID
)
expect(res.json).not.toHaveBeenCalled()
expect(res.sendStatus).toHaveBeenCalledTimes(1)
@@ -125,7 +148,7 @@ describe('MetaController', function () {
ctx.EditorRealTimeController.emitToRoom.should.have.been.calledOnce
const { lastCall } = ctx.EditorRealTimeController.emitToRoom
expect(lastCall.args[0]).to.equal('project-id')
expect(lastCall.args[0]).to.equal(PROJECT_ID)
expect(lastCall.args[1]).to.equal('broadcastDocMeta')
expect(lastCall.args[2]).to.have.all.keys(['docId', 'meta'])
})
@@ -142,7 +165,7 @@ describe('MetaController', function () {
ctx.EditorRealTimeController.emitToRoom = sinon.stub()
const req = {
params: { project_id: 'project-id', doc_id: 'doc-id' },
params: { project_id: PROJECT_ID, doc_id: DOC_ID },
body: { broadcast: false },
}
const res = new MockResponse(vi)
@@ -151,12 +174,12 @@ describe('MetaController', function () {
await ctx.MetadataController.broadcastMetadataForDoc(req, res, next)
ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
'project-id'
PROJECT_ID
)
ctx.EditorRealTimeController.emitToRoom.should.not.have.been.called
expect(res.json).toHaveBeenCalledTimes(1)
expect(res.json).toHaveBeenCalledWith({
docId: 'doc-id',
docId: DOC_ID,
meta: docMeta,
})
next.should.not.have.been.called
@@ -170,7 +193,7 @@ describe('MetaController', function () {
ctx.EditorRealTimeController.emitToRoom = sinon.stub()
const req = {
params: { project_id: 'project-id', doc_id: 'doc-id' },
params: { project_id: PROJECT_ID, doc_id: DOC_ID },
body: { broadcast: true },
}
const res = new MockResponse(vi)
@@ -179,10 +202,48 @@ describe('MetaController', function () {
await ctx.MetadataController.broadcastMetadataForDoc(req, res, next)
ctx.MetaHandler.promises.getMetaForDoc.should.have.been.calledWith(
'project-id'
PROJECT_ID
)
expect(res.json).not.toHaveBeenCalled()
next.should.have.been.calledWithMatch(error => error instanceof Error)
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('should reject a malformed doc id', async function (ctx) {
const req = {
params: { project_id: PROJECT_ID, doc_id: 'not-an-object-id' },
body: { broadcast: true },
}
const res = new MockResponse(vi)
const next = sinon.stub()
await ctx.MetadataController.broadcastMetadataForDoc(req, res, next)
expect(ctx.MetaHandler.promises.getMetaForDoc.called).to.equal(false)
next.should.have.been.calledWithMatch(
error => error.name === 'InvalidParamsError'
)
})
it('should reject an unrecognized body field', async function (ctx) {
const req = {
params: { project_id: PROJECT_ID, doc_id: DOC_ID },
body: { broadcast: true, evil: 'x' },
}
const res = new MockResponse(vi)
const next = sinon.stub()
await ctx.MetadataController.broadcastMetadataForDoc(req, res, next)
expect(ctx.MetaHandler.promises.getMetaForDoc.called).to.equal(false)
next.should.have.been.calledWithMatch(
error => error.name === 'InvalidRequestError'
)
})
})
})
})
@@ -1,5 +1,6 @@
import { beforeEach, describe, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, it, vi } from 'vitest'
import sinon from 'sinon'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
const modulePath = new URL(
'../../../../app/src/Features/Notifications/NotificationsController.mjs',
@@ -8,7 +9,7 @@ const modulePath = new URL(
describe('NotificationsController', function () {
const userId = '123nd3ijdks'
const notificationId = '123njdskj9jlk'
const notificationId = '507f1f77bcf86cd799439011'
beforeEach(async function (ctx) {
ctx.handler = {
@@ -52,6 +53,10 @@ describe('NotificationsController', function () {
ctx.controller = (await import(modulePath)).default
})
afterEach(function () {
setReqValidationModeForTests(null)
})
it('should ask the handler for all unread notifications', async function (ctx) {
await new Promise(resolve => {
const allNotifications = [{ _id: notificationId, user_id: userId }]
@@ -98,4 +103,26 @@ describe('NotificationsController', function () {
})
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('should reject a malformed notification id on markNotificationAsRead', function (ctx) {
ctx.req.params.notificationId = 'not-an-object-id'
;(() =>
ctx.controller.markNotificationAsRead(ctx.req, {
sendStatus: sinon.stub(),
})).should.throw()
})
it('should reject a malformed notification id on getNotification', function (ctx) {
ctx.req.params.notificationId = 'not-an-object-id'
;(() =>
ctx.controller.getNotification(ctx.req, {
json: sinon.stub(),
})).should.throw()
})
})
})
@@ -1,5 +1,6 @@
import { beforeEach, describe, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import { setReqValidationModeForTests } from '@overleaf/validation-tools'
const modulePath = '../../../../app/src/Features/Project/ProjectApiController'
@@ -15,7 +16,7 @@ describe('Project api controller', function () {
)
ctx.controller = (await import(modulePath)).default
ctx.project_id = '321l3j1kjkjl'
ctx.project_id = '507f191e810c19729de860ea'
ctx.req = {
params: {
project_id: ctx.project_id,
@@ -29,6 +30,10 @@ describe('Project api controller', function () {
return (ctx.projDetails = { name: 'something' })
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('getProjectDetails', function () {
it('should ask the project details handler for proj details', async function (ctx) {
await new Promise(resolve => {
@@ -53,5 +58,46 @@ describe('Project api controller', function () {
ctx.controller.getProjectDetails(ctx.req, ctx.res, ctx.next)
return ctx.next.calledWith('error').should.equal(true)
})
describe('with a malformed project_id', function () {
beforeEach(function (ctx) {
ctx.req.params.project_id = 'not-a-valid-object-id'
})
it('should still process the request in log mode', function (ctx) {
setReqValidationModeForTests('log')
ctx.ProjectDetailsHandler.getDetails.callsArgWith(
1,
null,
ctx.projDetails
)
ctx.res.json = sinon.stub()
ctx.controller.getProjectDetails(ctx.req, ctx.res, ctx.next)
sinon.assert.notCalled(ctx.next)
sinon.assert.calledWith(
ctx.ProjectDetailsHandler.getDetails,
'not-a-valid-object-id'
)
})
describe('when enforced', function () {
afterEach(function () {
setReqValidationModeForTests(null)
})
it('should reject the request', function (ctx) {
setReqValidationModeForTests('enforce')
let error
try {
ctx.controller.getProjectDetails(ctx.req, ctx.res, ctx.next)
} catch (err) {
error = err
}
expect(error).to.exist
expect(error.name).to.equal('InvalidParamsError')
sinon.assert.notCalled(ctx.ProjectDetailsHandler.getDetails)
})
})
})
})
})
@@ -59,6 +59,8 @@ describe('ProjectController', function () {
promises: {
deleteProject: sinon.stub().resolves(),
restoreProject: sinon.stub().resolves(),
archiveProject: sinon.stub().resolves(),
unarchiveProject: sinon.stub().resolves(),
},
findArchivedProjects: sinon.stub(),
}
@@ -543,7 +545,8 @@ describe('ProjectController', function () {
ctx.req = {
query: {},
params: {
Project_id: ctx.project_id,
// real Express req.params are always strings
Project_id: ctx.project_id.toString(),
},
headers: {},
connection: {
@@ -652,6 +655,16 @@ describe('ProjectController', function () {
ctx.ProjectController.updateProjectSettings(ctx.req, ctx.res)
})
})
it('should reject a malformed mainBibliographyDocId', async function (ctx) {
await new Promise(resolve => {
ctx.req.body = { mainBibliographyDocId: 'not-an-object-id' }
ctx.ProjectController.updateProjectSettings(ctx.req, ctx.res, err => {
expect(err).to.exist
resolve()
})
})
})
})
describe('updateProjectAdminSettings', function () {
@@ -733,7 +746,7 @@ describe('ProjectController', function () {
await new Promise(resolve => {
ctx.res.sendStatus = code => {
ctx.ProjectDeleter.promises.deleteProject
.calledWith(ctx.project_id, {
.calledWith(ctx.project_id.toString(), {
deleterUser: ctx.user,
ipAddress: ctx.req.ip,
deletedReason: 'user',
@@ -747,12 +760,42 @@ describe('ProjectController', function () {
})
})
describe('archiveProject', function () {
it('should call the project deleter', async function (ctx) {
await new Promise(resolve => {
ctx.res.sendStatus = code => {
ctx.ProjectDeleter.promises.archiveProject
.calledWith(ctx.project_id.toString(), ctx.user._id)
.should.equal(true)
code.should.equal(200)
resolve()
}
ctx.ProjectController.archiveProject(ctx.req, ctx.res)
})
})
})
describe('unarchiveProject', function () {
it('should call the project deleter', async function (ctx) {
await new Promise(resolve => {
ctx.res.sendStatus = code => {
ctx.ProjectDeleter.promises.unarchiveProject
.calledWith(ctx.project_id.toString(), ctx.user._id)
.should.equal(true)
code.should.equal(200)
resolve()
}
ctx.ProjectController.unarchiveProject(ctx.req, ctx.res)
})
})
})
describe('restoreProject', function () {
it('should tell the project deleter', async function (ctx) {
await new Promise(resolve => {
ctx.res.sendStatus = code => {
ctx.ProjectDeleter.promises.restoreProject
.calledWith(ctx.project_id)
.calledWith(ctx.project_id.toString())
.should.equal(true)
code.should.equal(200)
resolve()
@@ -767,7 +810,7 @@ describe('ProjectController', function () {
await new Promise(resolve => {
ctx.res.json = json => {
ctx.ProjectDuplicator.promises.duplicate
.calledWith(ctx.user, ctx.project_id, ctx.projectName)
.calledWith(ctx.user, ctx.project_id.toString(), ctx.projectName)
.should.equal(true)
json.project_id.should.equal(ctx.project_id)
resolve()
@@ -873,7 +916,7 @@ describe('ProjectController', function () {
describe('renameProject', function () {
beforeEach(function (ctx) {
ctx.newProjectName = 'my supper great new project'
ctx.req.body.newProjectName = ctx.newProjectName
ctx.req.body = { newProjectName: ctx.newProjectName }
ctx.req.params.Project_id = ctx.project_id.toString()
})
@@ -1101,7 +1144,7 @@ describe('ProjectController', function () {
resCode.should.equal(401)
ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.should.have.been.calledWith(
ctx.user._id,
ctx.project_id,
ctx.project_id.toString(),
'some-token'
)
resolve()
@@ -1140,7 +1183,7 @@ describe('ProjectController', function () {
await new Promise(resolve => {
ctx.res.render = (pageName, opts) => {
ctx.ProjectUpdateHandler.promises.markAsOpened
.calledWith(ctx.project_id)
.calledWith(ctx.project_id.toString())
.should.equal(true)
resolve()
}
@@ -1357,13 +1400,13 @@ describe('ProjectController', function () {
})
describe('when the projectId does not match (0)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(0)
ctx.req.params.Project_id = ObjectId.createFromTime(0).toString()
})
checkNonMatch()
})
describe('when the projectId does not match (42)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(42)
ctx.req.params.Project_id = ObjectId.createFromTime(42).toString()
})
checkNonMatch()
})
@@ -1375,21 +1418,21 @@ describe('ProjectController', function () {
})
describe('when the projectId matches (0)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(0)
ctx.req.params.Project_id = ObjectId.createFromTime(0).toString()
})
checkMatch()
checkForBetaUser()
})
describe('when the projectId does not match (1)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(1)
ctx.req.params.Project_id = ObjectId.createFromTime(1).toString()
})
checkNonMatch()
checkForBetaUser()
})
describe('when the projectId does not match (42)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(42)
ctx.req.params.Project_id = ObjectId.createFromTime(42).toString()
})
checkNonMatch()
})
@@ -1400,26 +1443,26 @@ describe('ProjectController', function () {
})
describe('when the projectId matches (0)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(0)
ctx.req.params.Project_id = ObjectId.createFromTime(0).toString()
})
checkMatch()
})
describe('when the projectId matches (9)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(9)
ctx.req.params.Project_id = ObjectId.createFromTime(9).toString()
})
checkMatch()
checkForBetaUser()
})
describe('when the projectId does not match (10)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(10)
ctx.req.params.Project_id = ObjectId.createFromTime(10).toString()
})
checkNonMatch()
})
describe('when the projectId does not match (42)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(42)
ctx.req.params.Project_id = ObjectId.createFromTime(42).toString()
})
checkNonMatch()
checkForBetaUser()
@@ -1431,26 +1474,26 @@ describe('ProjectController', function () {
})
describe('when the projectId matches (0)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(0)
ctx.req.params.Project_id = ObjectId.createFromTime(0).toString()
})
checkMatch()
checkForBetaUser()
})
describe('when the projectId matches (10)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(10)
ctx.req.params.Project_id = ObjectId.createFromTime(10).toString()
})
checkMatch()
})
describe('when the projectId matches (42)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(42)
ctx.req.params.Project_id = ObjectId.createFromTime(42).toString()
})
checkMatch()
})
describe('when the projectId matches (99)', function () {
beforeEach(function (ctx) {
ctx.req.params.Project_id = ObjectId.createFromTime(99)
ctx.req.params.Project_id = ObjectId.createFromTime(99).toString()
})
checkMatch()
})
@@ -1570,7 +1613,7 @@ describe('ProjectController', function () {
ctx.res.render = (pageName, opts) => {
ctx.Modules.promises.hooks.fire.should.have.been.calledWith(
'enforceCollaboratorLimit',
ctx.project_id
ctx.project_id.toString()
)
resolve()
}
@@ -1928,8 +1971,8 @@ describe('ProjectController', function () {
describe('projectEntitiesJson', function () {
beforeEach(function (ctx) {
ctx.SessionManager.getLoggedInUserId = sinon.stub().returns('abc')
ctx.req.params = { Project_id: 'abcd' }
ctx.project = { _id: 'abcd' }
ctx.req.params = { Project_id: '507f191e810c19729de860ea' }
ctx.project = { _id: '507f191e810c19729de860ea' }
ctx.docs = [
{ path: '/things/b.txt', doc: true },
{ path: '/main.tex', doc: true },
@@ -1945,7 +1988,7 @@ describe('ProjectController', function () {
await new Promise(resolve => {
ctx.res.json = data => {
expect(data).to.deep.equal({
project_id: 'abcd',
project_id: '507f191e810c19729de860ea',
entities: [
{ path: '/main.tex', type: 'doc' },
{ path: '/things/a.txt', type: 'file' },
@@ -1,8 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import Settings from '@overleaf/settings'
import {
InvalidRequestError,
setReqValidationModeForTests,
} from '@overleaf/validation-tools'
const ObjectId = mongodb.ObjectId
@@ -335,6 +339,10 @@ describe('ProjectListController', function () {
ctx.res = {}
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('projectListPage', function () {
beforeEach(function (ctx) {
ctx.projects = [
@@ -1319,4 +1327,110 @@ describe('ProjectListController', function () {
await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
})
})
describe('getProjectsJson', function () {
beforeEach(function (ctx) {
ctx.projects = [
{ _id: 1, lastUpdated: new Date(1), owner_ref: 'user-1' },
{ _id: 2, lastUpdated: new Date(2), owner_ref: 'user-2' },
]
ctx.allProjects = {
owned: ctx.projects,
readAndWrite: [],
readOnly: [],
tokenReadAndWrite: [],
tokenReadOnly: [],
review: [],
}
ctx.ProjectGetter.promises.findAllUsersProjects.resolves(ctx.allProjects)
ctx.next = sinon.stub()
})
it('should respond with the paginated/filtered/sorted projects', async function (ctx) {
ctx.req.body = {
filters: { ownedByUser: true },
sort: { by: 'lastUpdated', order: 'desc' },
page: { size: 20 },
}
await new Promise(resolve => {
ctx.res.json = data => {
expect(data.totalSize).to.equal(ctx.projects.length)
expect(data.projects).to.have.length(ctx.projects.length)
resolve()
}
ctx.ProjectListController.getProjectsJson(ctx.req, ctx.res, ctx.next)
})
sinon.assert.notCalled(ctx.next)
})
it('should work with only a sort and no filters/page', async function (ctx) {
ctx.req.body = { sort: { by: 'title', order: 'asc' } }
await new Promise(resolve => {
ctx.res.json = data => {
expect(data.totalSize).to.equal(ctx.projects.length)
resolve()
}
ctx.ProjectListController.getProjectsJson(ctx.req, ctx.res, ctx.next)
})
})
it('should not reject an unsupported sort.by value in log mode', async function (ctx) {
setReqValidationModeForTests('log')
ctx.req.body = { sort: { by: 'not-a-real-field', order: 'asc' } }
await ctx.ProjectListController.getProjectsJson(
ctx.req,
ctx.res,
ctx.next
)
sinon.assert.calledOnce(ctx.next)
const err = ctx.next.firstCall.args[0]
expect(err).to.not.be.instanceOf(InvalidRequestError)
expect(err.message).to.equal('Invalid sorting criteria')
})
it('should not reject an unknown filter key in log mode', async function (ctx) {
setReqValidationModeForTests('log')
ctx.req.body = { filters: { notARealFilter: true } }
await new Promise(resolve => {
ctx.res.json = data => {
expect(data.totalSize).to.equal(ctx.projects.length)
resolve()
}
ctx.ProjectListController.getProjectsJson(ctx.req, ctx.res, ctx.next)
})
sinon.assert.notCalled(ctx.next)
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects an unsupported sort.by value', async function (ctx) {
ctx.req.body = { sort: { by: 'not-a-real-field', order: 'asc' } }
await ctx.ProjectListController.getProjectsJson(
ctx.req,
ctx.res,
ctx.next
)
sinon.assert.calledWith(
ctx.next,
sinon.match.instanceOf(InvalidRequestError)
)
})
it('rejects an unknown filter key', async function (ctx) {
ctx.req.body = { filters: { notARealFilter: true } }
await ctx.ProjectListController.getProjectsJson(
ctx.req,
ctx.res,
ctx.next
)
sinon.assert.calledWith(
ctx.next,
sinon.match.instanceOf(InvalidRequestError)
)
})
})
})
})
@@ -1,12 +1,17 @@
import { assert, beforeEach, describe, it, vi } from 'vitest'
import { afterEach, assert, beforeEach, describe, it, vi } from 'vitest'
import sinon from 'sinon'
import { InvalidRequestError } from '@overleaf/validation-tools'
import {
InvalidRequestError,
setReqValidationModeForTests,
} from '@overleaf/validation-tools'
const modulePath = '../../../../app/src/Features/Tags/TagsController.mjs'
describe('TagsController', function () {
const userId = '123nd3ijdks'
const projectId = '123njdskj9jlk'
const userId = '507f191e810c19729de860ea'
const projectId = '507f191e810c19729de860eb'
const tagId = '507f191e810c19729de860ec'
const projectId2 = '507f191e810c19729de860ed'
beforeEach(async function (ctx) {
ctx.TagsHandler = {
@@ -40,9 +45,7 @@ describe('TagsController', function () {
ctx.TagsController = (await import(modulePath)).default
ctx.req = {
params: {
projectId,
},
params: {},
session: {
user: {
_id: userId,
@@ -57,6 +60,10 @@ describe('TagsController', function () {
ctx.res.json = sinon.stub()
})
afterEach(function () {
setReqValidationModeForTests(null)
})
it('get all tags', async function (ctx) {
await new Promise(resolve => {
const allTags = [{ name: 'tag', projects: ['123423', '423423'] }]
@@ -126,8 +133,8 @@ describe('TagsController', function () {
it('delete a tag', async function (ctx) {
await new Promise(resolve => {
ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
ctx.req.session.user._id = ctx.userId = 'user-id-123'
ctx.req.params.tagId = ctx.tagId = tagId
ctx.req.session.user._id = ctx.userId = userId
ctx.TagsController.deleteTag(ctx.req, {
status: code => {
assert.equal(code, 204)
@@ -145,10 +152,48 @@ describe('TagsController', function () {
})
})
describe('rename a tag', function () {
beforeEach(function (ctx) {
ctx.req.params.tagId = ctx.tagId = tagId
ctx.req.session.user._id = ctx.userId = userId
})
it('with a name', async function (ctx) {
await new Promise(resolve => {
ctx.req.body = {
name: (ctx.tagName = 'new-name'),
}
ctx.TagsController.renameTag(ctx.req, {
status: code => {
assert.equal(code, 204)
sinon.assert.calledWith(
ctx.TagsHandler.promises.renameTag,
ctx.userId,
ctx.tagId,
ctx.tagName
)
resolve()
return {
end: () => {},
}
},
})
})
})
it('with an empty name, even when the schema is in log-only mode', async function (ctx) {
setReqValidationModeForTests('log')
ctx.req.body = { name: '' }
await ctx.TagsController.renameTag(ctx.req, ctx.res)
sinon.assert.calledWith(ctx.res.status, 400)
sinon.assert.notCalled(ctx.TagsHandler.promises.renameTag)
})
})
describe('edit a tag', function () {
beforeEach(function (ctx) {
ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
ctx.req.session.user._id = ctx.userId = 'user-id-123'
ctx.req.params.tagId = ctx.tagId = tagId
ctx.req.session.user._id = ctx.userId = userId
})
it('with a name and no color', async function (ctx) {
@@ -201,17 +246,25 @@ describe('TagsController', function () {
it('without a name', function (ctx) {
ctx.req.body = { name: undefined }
ctx.TagsController.renameTag(ctx.req, ctx.res).should.be.rejectedWith(
ctx.TagsController.editTag(ctx.req, ctx.res).should.be.rejectedWith(
InvalidRequestError
)
})
it('with an empty name, even when the schema is in log-only mode', async function (ctx) {
setReqValidationModeForTests('log')
ctx.req.body = { name: '' }
await ctx.TagsController.editTag(ctx.req, ctx.res)
sinon.assert.calledWith(ctx.res.status, 400)
sinon.assert.notCalled(ctx.TagsHandler.promises.editTag)
})
})
it('add a project to a tag', async function (ctx) {
await new Promise(resolve => {
ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
ctx.req.params.projectId = ctx.projectId = 'project-id-123'
ctx.req.session.user._id = ctx.userId = 'user-id-123'
ctx.req.params.tagId = ctx.tagId = tagId
ctx.req.params.projectId = ctx.projectId = projectId
ctx.req.session.user._id = ctx.userId = userId
ctx.TagsController.addProjectToTag(ctx.req, {
status: code => {
assert.equal(code, 204)
@@ -232,12 +285,9 @@ describe('TagsController', function () {
it('add projects to a tag', async function (ctx) {
await new Promise(resolve => {
ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
ctx.req.body.projectIds = ctx.projectIds = [
'project-id-123',
'project-id-234',
]
ctx.req.session.user._id = ctx.userId = 'user-id-123'
ctx.req.params.tagId = ctx.tagId = tagId
ctx.req.body.projectIds = ctx.projectIds = [projectId, projectId2]
ctx.req.session.user._id = ctx.userId = userId
ctx.TagsController.addProjectsToTag(ctx.req, {
status: code => {
assert.equal(code, 204)
@@ -258,9 +308,9 @@ describe('TagsController', function () {
it('remove a project from a tag', async function (ctx) {
await new Promise(resolve => {
ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
ctx.req.params.projectId = ctx.projectId = 'project-id-123'
ctx.req.session.user._id = ctx.userId = 'user-id-123'
ctx.req.params.tagId = ctx.tagId = tagId
ctx.req.params.projectId = ctx.projectId = projectId
ctx.req.session.user._id = ctx.userId = userId
ctx.TagsController.removeProjectFromTag(ctx.req, {
status: code => {
assert.equal(code, 204)
@@ -281,12 +331,9 @@ describe('TagsController', function () {
it('remove projects from a tag', async function (ctx) {
await new Promise(resolve => {
ctx.req.params.tagId = ctx.tagId = 'tag-id-123'
ctx.req.body.projectIds = ctx.projectIds = [
'project-id-123',
'project-id-234',
]
ctx.req.session.user._id = ctx.userId = 'user-id-123'
ctx.req.params.tagId = ctx.tagId = tagId
ctx.req.body.projectIds = ctx.projectIds = [projectId, projectId2]
ctx.req.session.user._id = ctx.userId = userId
ctx.TagsController.removeProjectsFromTag(ctx.req, {
status: code => {
assert.equal(code, 204)
@@ -5,11 +5,18 @@
* DS206: Consider reworking classes to avoid initClass
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import Path from 'node:path'
import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
import ArchiveErrors from '../../../../app/src/Features/Uploads/ArchiveErrors.mjs'
import {
InvalidParamsError,
InvalidRequestError,
setReqValidationModeForTests,
} from '@overleaf/validation-tools'
import { asZodError } from '@overleaf/validation-tools/testUtils.js'
import {
FileTooLargeError,
DocumentConversionError,
@@ -23,6 +30,21 @@ vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
const modulePath =
'../../../../app/src/Features/Uploads/ProjectUploadController.mjs'
// A minimal but schema-valid multer file object (zz.uploadedFile()), keyed
// off the temp path used throughout these tests.
function uploadedFile(path) {
return {
fieldname: 'qqfile',
originalname: Path.basename(path),
encoding: '7bit',
mimetype: 'application/octet-stream',
size: 1234,
destination: Path.dirname(path),
filename: Path.basename(path),
path,
}
}
describe('ProjectUploadController', function () {
beforeEach(async function (ctx) {
let Timer
@@ -142,13 +164,15 @@ describe('ProjectUploadController', function () {
ctx.ProjectUploadController = (await import(modulePath)).default
})
afterEach(function () {
setReqValidationModeForTests(null)
})
describe('uploadProject', function () {
beforeEach(function (ctx) {
ctx.path = '/path/to/file/on/disk.zip'
ctx.fileName = 'filename.zip'
ctx.req.file = {
path: ctx.path,
}
ctx.req.file = uploadedFile(ctx.path)
ctx.req.body = {
name: ctx.fileName,
}
@@ -157,7 +181,7 @@ describe('ProjectUploadController', function () {
_id: ctx.user_id,
},
}
ctx.project = { _id: (ctx.project_id = 'project-id-123') }
ctx.project = { _id: (ctx.project_id = '507f191e810c19729de860ea') }
ctx.fs.unlink = sinon.stub()
ctx.fsPromises.unlink = sinon.stub().resolves()
@@ -253,17 +277,80 @@ describe('ProjectUploadController', function () {
ctx.fs.unlink.calledWith(ctx.path).should.equal(true)
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects an unrecognized body field', function (ctx) {
ctx.req.body.extraField = 'nope'
expect(() =>
ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
).to.throw(InvalidRequestError)
expect(ctx.fs.unlink).not.to.have.been.called
})
it('accepts relativePath set to the literal "null" string', function (ctx) {
ctx.req.body.relativePath = 'null'
ctx.ProjectUploadManager.createProjectFromZipArchive = sinon
.stub()
.callsArgWith(3, null, ctx.project)
ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
expect(ctx.res.body).to.deep.equal(
JSON.stringify({
success: true,
project_id: ctx.project_id,
})
)
})
it('accepts a nested relativePath', function (ctx) {
ctx.req.body.relativePath = 'folder/main.tex'
ctx.ProjectUploadManager.createProjectFromZipArchive = sinon
.stub()
.callsArgWith(3, null, ctx.project)
ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
expect(ctx.res.body).to.deep.equal(
JSON.stringify({
success: true,
project_id: ctx.project_id,
})
)
})
it('rejects a relativePath that attempts path traversal', function (ctx) {
ctx.req.body.relativePath = '../../etc/passwd'
let error
try {
ctx.ProjectUploadController.uploadProject(ctx.req, ctx.res)
} catch (err) {
error = err
}
expect(
sinon
.match({
name: 'InvalidRequestError',
zodError: asZodError({
code: 'custom',
path: ['body', 'relativePath'],
message: 'path traversal detected',
}),
})
.test(error)
).to.be.true
expect(ctx.fs.unlink).not.to.have.been.called
})
})
})
describe('uploadFile', function () {
beforeEach(function (ctx) {
ctx.project_id = 'project-id-123'
ctx.folder_id = 'folder-id-123'
ctx.project_id = '507f191e810c19729de860ea'
ctx.folder_id = '507f191e810c19729de860eb'
ctx.path = '/path/to/file/on/disk.png'
ctx.fileName = 'filename.png'
ctx.req.file = {
path: ctx.path,
}
ctx.req.file = uploadedFile(ctx.path)
ctx.req.body = {
name: ctx.fileName,
}
@@ -480,13 +567,25 @@ describe('ProjectUploadController', function () {
ctx.fsPromises.unlink.calledWith(ctx.path).should.equal(true)
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects a malformed Project_id param', async function (ctx) {
ctx.req.params.Project_id = 'not-an-object-id'
await expect(
ctx.ProjectUploadController.uploadFile(ctx.req, ctx.res)
).to.be.rejectedWith(InvalidParamsError)
expect(ctx.fsPromises.unlink).not.to.have.been.called
})
})
})
describe('importDocument', function () {
beforeEach(async function (ctx) {
ctx.req.file = {
path: '/path/to/uploaded/file.docx',
}
ctx.req.file = uploadedFile('/path/to/uploaded/file.docx')
ctx.req.body = {
name: 'file.docx',
}
@@ -563,9 +662,7 @@ describe('ProjectUploadController', function () {
describe('with conversionType=markdown', async function () {
beforeEach(async function (ctx) {
ctx.req.file = {
path: '/path/to/uploaded/file.md',
}
ctx.req.file = uploadedFile('/path/to/uploaded/file.md')
ctx.req.body = {
name: 'file.md',
}
@@ -775,6 +872,23 @@ describe('ProjectUploadController', function () {
expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.req.file.path)
})
})
describe('request validation', function () {
beforeEach(function () {
setReqValidationModeForTests('enforce')
})
it('rejects an unrecognized body field', async function (ctx) {
ctx.req.body.extraField = 'nope'
await expect(
ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
).to.be.rejectedWith(InvalidRequestError)
expect(
ctx.DocumentConversionManager.promises
.convertDocumentToLaTeXZipArchive
).not.to.have.been.called
})
})
})
describe('multerMiddleware', function () {