[web] enforce size limit on individual files when cloning project (#35211)

* [web] enforce size limit on individual files when cloning project

Co-authored-by: Brian Gough <brian.gough@overleaf.com>

* [web] improve test coverage

* [agents] speed up running frontend tests

* [web] gracefully handle project too large error

---------

Co-authored-by: Brian Gough <brian.gough@overleaf.com>
GitOrigin-RevId: c7b35a3a9688e15a9bb51c61816da82d39e0d29d
This commit is contained in:
Jakob Ackermann
2026-09-17 11:20:03 +00:00
committed by Copybot
co-authored by Brian Gough
parent f62bd990d4
commit 84c6a68eae
13 changed files with 365 additions and 22 deletions
@@ -1,6 +1,8 @@
import { beforeAll, beforeEach, describe, it, vi, expect } from 'vitest'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import { RequestFailedError } from '@overleaf/fetch-utils'
import { FileTooLargeError } from '../../../../app/src/Features/Errors/Errors.js'
import {
cleanupTestDatabase,
db,
@@ -9,6 +11,12 @@ import {
const { ObjectId } = mongodb
// Ensure the module under test and this test file share the same
// FileTooLargeError class, so `instanceof` checks line up.
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
const MODULE_PATH = '../../../../app/src/Features/History/HistoryManager'
const GLOBAL_BLOBS = [
@@ -40,6 +48,7 @@ describe('HistoryManager', function () {
ctx.FetchUtils = {
fetchJson: sinon.stub(),
fetchNothing: sinon.stub().resolves(),
RequestFailedError,
}
ctx.projectHistoryUrl = 'http://project_history.example.com'
ctx.v1HistoryUrl = 'http://v1_history.example.com'
@@ -364,6 +373,106 @@ describe('HistoryManager', function () {
})
})
describe('copyBlob', function () {
const sourceHistoryId = 'source-history-id'
const targetHistoryId = 'target-history-id'
const hash = 'abcdef0123456789abcdef0123456789abcdef01'
it('should call the v1-history service without a size limit', async function (ctx) {
await ctx.HistoryManager.promises.copyBlob(
sourceHistoryId,
targetHistoryId,
hash
)
expect(ctx.FetchUtils.fetchNothing).to.have.been.calledOnce
const [url, opts] = ctx.FetchUtils.fetchNothing.getCall(0).args
expect(url.toString()).to.equal(
`${ctx.v1HistoryUrl}/projects/${targetHistoryId}/blobs/${hash}?copyFrom=${sourceHistoryId}`
)
expect(opts).to.deep.equal({
method: 'POST',
basicAuth: {
user: ctx.v1HistoryUser,
password: ctx.v1HistoryPassword,
},
})
})
it('should pass the size limit as a query parameter', async function (ctx) {
await ctx.HistoryManager.promises.copyBlob(
sourceHistoryId,
targetHistoryId,
hash,
1234
)
const [url] = ctx.FetchUtils.fetchNothing.getCall(0).args
expect(url.searchParams.get('copyFrom')).to.equal(sourceHistoryId)
expect(url.searchParams.get('sizeLimit')).to.equal('1234')
})
it('should throw a FileTooLargeError with the size on a 413 response', async function (ctx) {
ctx.FetchUtils.fetchNothing.rejects(
new RequestFailedError(
`${ctx.v1HistoryUrl}/projects/${targetHistoryId}/blobs/${hash}`,
{ method: 'POST' },
{ status: 413 },
JSON.stringify({ size: 9000 })
)
)
await expect(
ctx.HistoryManager.promises.copyBlob(
sourceHistoryId,
targetHistoryId,
hash,
1234
)
).to.be.rejected.then(err => {
expect(err).to.be.instanceof(FileTooLargeError)
expect(err.info.size).to.equal(9000)
})
})
it('should throw a FileTooLargeError even if the 413 body is not valid JSON', async function (ctx) {
ctx.FetchUtils.fetchNothing.rejects(
new RequestFailedError(
`${ctx.v1HistoryUrl}/projects/${targetHistoryId}/blobs/${hash}`,
{ method: 'POST' },
{ status: 413 },
'not json'
)
)
await expect(
ctx.HistoryManager.promises.copyBlob(
sourceHistoryId,
targetHistoryId,
hash,
1234
)
).to.be.rejected.then(err => {
expect(err).to.be.instanceof(FileTooLargeError)
expect(err.info.size).to.be.undefined
})
})
it('should rethrow non-413 request failures', async function (ctx) {
const err = new RequestFailedError(
`${ctx.v1HistoryUrl}/projects/${targetHistoryId}/blobs/${hash}`,
{ method: 'POST' },
{ status: 500 },
''
)
ctx.FetchUtils.fetchNothing.rejects(err)
await expect(
ctx.HistoryManager.promises.copyBlob(
sourceHistoryId,
targetHistoryId,
hash,
1234
)
).to.be.rejectedWith(err)
})
})
describe('deleteProject', function () {
const projectId = new ObjectId()
const historyId = new ObjectId()
@@ -3,8 +3,15 @@ import { beforeEach, describe, it, vi, expect } from 'vitest'
import path from 'node:path'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import { FileTooLargeError } from '../../../../app/src/Features/Errors/Errors.js'
const { ObjectId } = mongodb
// Ensure the module under test and this test file share the same
// FileTooLargeError class, so `instanceof` checks in the controller line up.
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
)
const MODULE_PATH = path.join(
import.meta.dirname,
'../../../../app/src/Features/Project/ProjectController'
@@ -769,6 +776,53 @@ describe('ProjectController', function () {
ctx.ProjectController.cloneProject(ctx.req, ctx.res)
})
})
it('should respond with a 413 when a file is too large to copy', async function (ctx) {
const err = new FileTooLargeError('file too large', {
path: 'huge.pdf',
size: 123456789,
})
ctx.ProjectDuplicator.promises.duplicate = sinon.stub().rejects(err)
await new Promise(resolve => {
ctx.res.status = sinon.stub().returns(ctx.res)
ctx.res.json = json => {
ctx.res.status.should.have.been.calledWith(413)
expect(json).to.deep.equal({
message: {
text: 'file too large to copy',
key: 'file_too_large_to_copy',
info: { path: 'huge.pdf', size: 123456789 },
},
})
resolve()
}
ctx.ProjectController.cloneProject(ctx.req, ctx.res)
})
})
it('should forward a FileTooLargeError without a path to next (e.g. project too large)', async function (ctx) {
const err = new FileTooLargeError('Project is too large')
ctx.ProjectDuplicator.promises.duplicate = sinon.stub().rejects(err)
await new Promise(resolve => {
const next = forwardedErr => {
expect(forwardedErr).to.equal(err)
resolve()
}
ctx.ProjectController.cloneProject(ctx.req, ctx.res, next)
})
})
it('should forward other errors to next', async function (ctx) {
const err = new Error('boom')
ctx.ProjectDuplicator.promises.duplicate = sinon.stub().rejects(err)
await new Promise(resolve => {
const next = forwardedErr => {
expect(forwardedErr).to.equal(err)
resolve()
}
ctx.ProjectController.cloneProject(ctx.req, ctx.res, next)
})
})
})
describe('newProject', function () {
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import sinon from 'sinon'
import mongodb from 'mongodb-legacy'
import OError from '@overleaf/o-error'
import Settings from '@overleaf/settings'
const { ObjectId } = mongodb
@@ -340,7 +342,8 @@ describe('ProjectDuplicator', function () {
ctx.HistoryManager.promises.copyBlob.should.have.been.calledWith(
ctx.project.overleaf.history.id,
ctx.newProject.overleaf.history.id,
file.hash
file.hash,
Settings.maxUploadSize
)
}
})
@@ -447,6 +450,20 @@ describe('ProjectDuplicator', function () {
)
).to.be.rejectedWith('copy blob error')
})
it('should tag the error with the file path', async function (ctx) {
ctx.file0.hash = '500'
await expect(
ctx.ProjectDuplicator.promises.duplicate(
ctx.owner,
ctx.project._id,
'name'
)
).to.be.rejected.then(err => {
// path is tagged without the leading slash
expect(OError.getFullInfo(err).path).to.equal('file0')
})
})
})
describe('when there is an error', function () {