mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
[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:
committed by
Copybot
co-authored by
Brian Gough
parent
f62bd990d4
commit
84c6a68eae
@@ -492,6 +492,11 @@ async function copyProjectBlob(req, res, next) {
|
||||
)
|
||||
return render.notFound(res)
|
||||
}
|
||||
if (query.sizeLimit > 0 && sourceBlob.getByteLength() > query.sizeLimit) {
|
||||
return res.status(HTTPStatus.REQUEST_ENTITY_TOO_LARGE).json({
|
||||
size: sourceBlob.getByteLength(),
|
||||
})
|
||||
}
|
||||
// Exit early if the blob exists in the target project.
|
||||
// This will also catch global blobs, which always exist.
|
||||
if (targetBlob) {
|
||||
|
||||
@@ -143,6 +143,7 @@ const schemas = {
|
||||
}),
|
||||
query: z.object({
|
||||
copyFrom: z.string(),
|
||||
sizeLimit: z.coerce.number().optional(),
|
||||
}),
|
||||
body: z.any().optional(),
|
||||
}),
|
||||
|
||||
@@ -298,6 +298,70 @@ describe('Project blobs API', function () {
|
||||
expect(newBlobResponseText).to.equal(fileContents.toString())
|
||||
})
|
||||
|
||||
it('rejects copying a blob that exceeds the size limit', async function () {
|
||||
const targetProjectId = '456'
|
||||
const targetClient =
|
||||
await testServer.createClientForProject(targetProjectId)
|
||||
const targetToken = testServer.createTokenForProject(targetProjectId)
|
||||
const url = new URL(
|
||||
testServer.url(
|
||||
`/api/projects/${targetProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
|
||||
)
|
||||
)
|
||||
url.searchParams.append('copyFrom', projectId)
|
||||
url.searchParams.append(
|
||||
'sizeLimit',
|
||||
(testFiles.HELLO_TXT_BYTE_LENGTH - 1).toString()
|
||||
)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${targetToken}` },
|
||||
})
|
||||
expect(response.status).to.equal(HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||
const body = await response.json()
|
||||
expect(body.size).to.equal(testFiles.HELLO_TXT_BYTE_LENGTH)
|
||||
|
||||
// check that it did not copy the blob to the target project
|
||||
await expectHttpError(
|
||||
targetClient.apis.Project.getProjectBlob({
|
||||
project_id: targetProjectId,
|
||||
hash: testFiles.HELLO_TXT_HASH,
|
||||
}),
|
||||
HTTPStatus.NOT_FOUND
|
||||
)
|
||||
})
|
||||
|
||||
it('copies a blob that is within the size limit', async function () {
|
||||
const targetProjectId = '456'
|
||||
const targetClient =
|
||||
await testServer.createClientForProject(targetProjectId)
|
||||
const targetToken = testServer.createTokenForProject(targetProjectId)
|
||||
const url = new URL(
|
||||
testServer.url(
|
||||
`/api/projects/${targetProjectId}/blobs/${testFiles.HELLO_TXT_HASH}`
|
||||
)
|
||||
)
|
||||
url.searchParams.append('copyFrom', projectId)
|
||||
url.searchParams.append(
|
||||
'sizeLimit',
|
||||
testFiles.HELLO_TXT_BYTE_LENGTH.toString()
|
||||
)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${targetToken}` },
|
||||
})
|
||||
expect(response.status).to.equal(HTTPStatus.CREATED)
|
||||
|
||||
const newBlobResponse = await targetClient.apis.Project.getProjectBlob({
|
||||
project_id: targetProjectId,
|
||||
hash: testFiles.HELLO_TXT_HASH,
|
||||
})
|
||||
const newBlobResponseText = await newBlobResponse.data.text()
|
||||
expect(newBlobResponseText).to.equal(fileContents.toString())
|
||||
})
|
||||
|
||||
it('skips copying a blob to another project if it already exists', async function () {
|
||||
const targetProjectId = '456'
|
||||
const targetClient =
|
||||
|
||||
@@ -14,7 +14,11 @@ import ProjectGetter from '../Project/ProjectGetter.mjs'
|
||||
import HistoryBackupDeletionHandler from './HistoryBackupDeletionHandler.mjs'
|
||||
import { db, waitForDb } from '../../infrastructure/mongodb.mjs'
|
||||
import Metrics from '@overleaf/metrics'
|
||||
import { NotFoundError, HistoryResyncPendingError } from '../Errors/Errors.js'
|
||||
import {
|
||||
NotFoundError,
|
||||
HistoryResyncPendingError,
|
||||
FileTooLargeError,
|
||||
} from '../Errors/Errors.js'
|
||||
|
||||
const HISTORY_V1_URL = settings.apis.v1_history.url
|
||||
const HISTORY_V1_BASIC_AUTH = {
|
||||
@@ -173,15 +177,27 @@ async function uploadBlobFromDisk(historyId, hash, byteLength, fsPath) {
|
||||
})
|
||||
}
|
||||
|
||||
async function copyBlob(sourceHistoryId, targetHistoryId, hash) {
|
||||
const url = `${HISTORY_V1_URL}/projects/${targetHistoryId}/blobs/${hash}`
|
||||
await fetchNothing(
|
||||
`${url}?${new URLSearchParams({ copyFrom: sourceHistoryId })}`,
|
||||
{
|
||||
async function copyBlob(sourceHistoryId, targetHistoryId, hash, sizeLimit) {
|
||||
const url = new URL(
|
||||
`${HISTORY_V1_URL}/projects/${targetHistoryId}/blobs/${hash}`
|
||||
)
|
||||
url.searchParams.set('copyFrom', sourceHistoryId)
|
||||
if (sizeLimit) url.searchParams.set('sizeLimit', sizeLimit)
|
||||
try {
|
||||
await fetchNothing(url, {
|
||||
method: 'POST',
|
||||
basicAuth: HISTORY_V1_BASIC_AUTH,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof RequestFailedError && err.response.status === 413) {
|
||||
let size
|
||||
try {
|
||||
;({ size } = JSON.parse(err.body))
|
||||
} catch {}
|
||||
throw new FileTooLargeError('file too large', { size })
|
||||
}
|
||||
)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function requestBlobWithProjectId(
|
||||
|
||||
@@ -57,6 +57,7 @@ import UserSettingsHelper from './UserSettingsHelper.mjs'
|
||||
import AiFeatureUsageRateLimiter from '../../infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs'
|
||||
import WorkbenchRateLimiter from '../../infrastructure/rate-limiters/WorkbenchRateLimiter.mjs'
|
||||
import PermissionsManager from '../Authorization/PermissionsManager.mjs'
|
||||
import { FileTooLargeError } from '../Errors/Errors.js'
|
||||
|
||||
const { checkUserPermissions } = PermissionsManager.promises
|
||||
const { isPaidSubscription } = SubscriptionHelper
|
||||
@@ -309,6 +310,17 @@ const _ProjectController = {
|
||||
projectId,
|
||||
userId: currentUser._id,
|
||||
})
|
||||
const { path, size } = OError.getFullInfo(err)
|
||||
if (err instanceof FileTooLargeError && path) {
|
||||
res.status(413).json({
|
||||
message: {
|
||||
text: 'file too large to copy',
|
||||
key: 'file_too_large_to_copy',
|
||||
info: { path, size },
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
return next(err)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { callbackify } from 'node:util'
|
||||
import Path from 'node:path'
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { promiseMapWithLimit } from '@overleaf/promise-utils'
|
||||
import { Doc } from '../../models/Doc.mjs'
|
||||
@@ -332,11 +333,13 @@ async function _copyFiles(
|
||||
await HistoryManager.promises.copyBlob(
|
||||
sourceHistoryId,
|
||||
targetHistoryId,
|
||||
file.hash
|
||||
file.hash,
|
||||
Settings.maxUploadSize
|
||||
)
|
||||
return { createdBlob: true, file, path }
|
||||
} catch (err) {
|
||||
throw OError.tag(err, 'unexpected error copying blob', {
|
||||
path: path.replace(/^\//, ''),
|
||||
sourceProjectId: sourceProject._id,
|
||||
targetProjectId: targetProject._id,
|
||||
sourceFile,
|
||||
|
||||
@@ -696,6 +696,7 @@
|
||||
"file_or_folder_name_already_exists": "",
|
||||
"file_outline": "",
|
||||
"file_size": "",
|
||||
"file_too_large_to_copy": "",
|
||||
"file_tree": "",
|
||||
"files_cannot_include_invalid_characters": "",
|
||||
"files_selected": "",
|
||||
|
||||
+27
-13
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable jsx-a11y/no-autofocus */
|
||||
import { FormEvent, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { postJSON } from '../../../infrastructure/fetch-json'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { postJSON, FetchError } from '@/infrastructure/fetch-json.ts'
|
||||
import { CloneProjectTag } from './clone-project-tag'
|
||||
import {
|
||||
OLModalBody,
|
||||
@@ -16,6 +16,7 @@ import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { Tag } from '../../../../../app/src/Features/Tags/types'
|
||||
import getMeta from '@/utils/meta.ts'
|
||||
|
||||
export default function CloneProjectModalContent({
|
||||
handleHide,
|
||||
@@ -35,8 +36,9 @@ export default function CloneProjectModalContent({
|
||||
projectTags: Tag[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { maxUploadSize } = getMeta('ol-ExposedSettings')
|
||||
|
||||
const [error, setError] = useState<string | boolean>()
|
||||
const [error, setError] = useState<FetchError | null>(null)
|
||||
const [clonedProjectName, setClonedProjectName] = useState(
|
||||
`${projectName} (Copy)`
|
||||
)
|
||||
@@ -57,7 +59,7 @@ export default function CloneProjectModalContent({
|
||||
return
|
||||
}
|
||||
|
||||
setError(false)
|
||||
setError(null)
|
||||
setInFlight(true)
|
||||
|
||||
// clone the project
|
||||
@@ -71,12 +73,8 @@ export default function CloneProjectModalContent({
|
||||
// open the cloned project
|
||||
handleAfterCloned(data, clonedProjectTags)
|
||||
})
|
||||
.catch(({ response, data }) => {
|
||||
if (response?.status === 400) {
|
||||
setError(data.message)
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
.catch(err => {
|
||||
setError(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setInFlight(false)
|
||||
@@ -128,9 +126,25 @@ export default function CloneProjectModalContent({
|
||||
{error && (
|
||||
<Notification
|
||||
content={
|
||||
typeof error === 'string' && error.length
|
||||
? error
|
||||
: t('generic_something_went_wrong')
|
||||
error.getErrorMessageKey() === 'file_too_large_to_copy' ? (
|
||||
<Trans
|
||||
components={[<code key="code-for-path" />]}
|
||||
i18nKey="file_too_large_to_copy"
|
||||
values={{
|
||||
size: (error.data.message.info.size / 1024 / 1024).toFixed(
|
||||
1
|
||||
),
|
||||
limit: Math.floor(maxUploadSize / 1024 / 1024),
|
||||
path: error.data.message.info.path,
|
||||
}}
|
||||
shouldUnescape
|
||||
tOptions={{ interpolation: { escapeValue: true } }}
|
||||
></Trans>
|
||||
) : error.response?.status === 400 ? (
|
||||
error.data.message
|
||||
) : (
|
||||
t('generic_something_went_wrong')
|
||||
)
|
||||
}
|
||||
type="error"
|
||||
/>
|
||||
|
||||
@@ -904,6 +904,7 @@
|
||||
"file_outline": "File outline",
|
||||
"file_size": "File size",
|
||||
"file_too_large": "File too large",
|
||||
"file_too_large_to_copy": "File <0>__path__</0> (__size__MiB) exceeds the maximum file size of __limit__MiB.",
|
||||
"file_tree": "File tree",
|
||||
"files_cannot_include_invalid_characters": "File name is empty or contains invalid characters",
|
||||
"files_selected": "files selected.",
|
||||
|
||||
+46
@@ -199,4 +199,50 @@ describe('<EditorCloneProjectModalWrapper />', function () {
|
||||
expect(button.disabled).to.be.false
|
||||
expect(cancelButton.disabled).to.be.false
|
||||
})
|
||||
|
||||
it('handles a file too large error response', async function () {
|
||||
const matcher = 'express:/project/:projectId/clone'
|
||||
|
||||
// default test maxUploadSize is 5 MiB (see reset-meta helper)
|
||||
fetchMock.postOnce(matcher, {
|
||||
status: 413,
|
||||
body: {
|
||||
message: {
|
||||
text: 'file too large to copy',
|
||||
key: 'file_too_large_to_copy',
|
||||
info: { path: 'huge.pdf', size: 8 * 1024 * 1024 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const handleHide = sinon.stub()
|
||||
const openProject = sinon.stub()
|
||||
|
||||
renderWithEditorContext(
|
||||
<EditorCloneProjectModalWrapper
|
||||
handleHide={handleHide}
|
||||
openProject={openProject}
|
||||
show
|
||||
/>,
|
||||
contextProps
|
||||
)
|
||||
|
||||
const button: HTMLButtonElement = await screen.findByRole('button', {
|
||||
name: 'Copy',
|
||||
})
|
||||
|
||||
fireEvent.click(button)
|
||||
await fetchMock.callHistory.flush(true)
|
||||
|
||||
expect(fetchMock.callHistory.done(matcher)).to.be.true
|
||||
expect(openProject).not.to.be.called
|
||||
|
||||
// the path is rendered inside a <code> element, interpolated with the
|
||||
// file size and the configured upload limit
|
||||
const code = await screen.findByText('huge.pdf')
|
||||
expect(code.tagName).to.equal('CODE')
|
||||
expect(code.parentElement?.textContent).to.equal(
|
||||
'File huge.pdf (8.0MiB) exceeds the maximum file size of 5MiB.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user