fix(core,express,fastify): remove uploads of requests a pipe rejected

File interceptors store uploads before the route's pipes run, and only
multer (or the multipart parser) removed them, for its own errors. When
a pipe rejected the request (e.g. ParseFilePipe with a
MaxFileSizeValidator, or ParseIntPipe on another parameter), the client
got a 400 and the files stayed in storage, unseen by the route handler.

The router now records a request whose pipes reject it, and the four
storing interceptors on both adapters remove its files through their
storage engine once the response has been sent, so an exception filter
still finds them. Behaviour change: such files no longer stay in
storage. Files of a request whose route handler fails are kept, as
before.

Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-1ETCUFFE.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-16 14:57:27 +02:00
1 parent 2f28d91afb
commit acb98de89e
19 files changed
+486 -24

No files matched your search

@@ -643,6 +643,29 @@ describe('File upload: Express (multer) and Fastify parity', () => {
expect(readdirSync(dirs.fastify)).toEqual([]);
});
it('should remove the stored files when a pipe rejects the request', async () => {
const res = await expectParity(http =>
http
.post('/disk-validated')
.field('count', 'not a number')
.attach('docs', text('fine'), txt('1.txt'))
.attach('docs', text('fine too'), txt('2.txt')),
);
expect(res).toEqual({
status: 400,
body: {
statusCode: 400,
error: 'Bad Request',
message: 'Validation failed (numeric string is expected)',
},
});
// Removed once the response has been sent.
await vi.waitFor(() => {
expect(readdirSync(dirs.express)).toEqual([]);
expect(readdirSync(dirs.fastify)).toEqual([]);
});
});
it('should report a storage engine error', async () => {
// Large enough that the file is still arriving when the engine fails.
const res = await expectParity(http =>
@@ -6,6 +6,7 @@ import {
MaxFileSizeValidator,
Module,
ParseFilePipe,
ParseIntPipe,
Post,
UploadedFile,
UploadedFiles,
@@ -211,6 +212,17 @@ export function createUploadModule(
return { files: describeFiles(files) };
}
// A pipe rejects the request after the files were stored, so the route
// handler never sees them.
@Post('disk-validated')
@UseInterceptors(api.FilesInterceptor('docs', 5, { dest: uploadDir }))
diskValidated(
@UploadedFiles() files: any,
@Body('count', ParseIntPipe) count: number,
) {
return { files: describeFiles(files), count };
}
// A third-party storage engine: multer's own disk storage.
@Post('engine-disk')
@UseInterceptors(
+1
View File
@@ -57,6 +57,7 @@ export { BaseExceptionFilterContext } from './exceptions/base-exception-filter-c
// Router
export { LegacyRouteConverter } from './router/legacy-route-converter.js';
export { isRejectedByPipes } from './router/rejected-by-pipes.js';
export { REQUEST_CONTEXT_ID } from './router/request/request-constants.js';
// Inspector
+20
View File
@@ -0,0 +1,20 @@
const rejectedRequests = new WeakSet<object>();
/**
* Records that the pipes of `request` rejected it, so its route handler did
* not run.
*/
export function markRejectedByPipes(request: unknown) {
if (typeof request === 'object' && request !== null) {
rejectedRequests.add(request);
}
}
/**
* Whether the pipes of `request` rejected it, so its route handler did not
* run. An interceptor can then release what it prepared for the handler,
* e.g. the files an upload interceptor stored.
*/
export function isRejectedByPipes(request: unknown): boolean {
return rejectedRequests.has(request as object);
}
@@ -57,6 +57,7 @@ import { InterceptorsContextCreator } from '../interceptors/interceptors-context
import { PipesConsumer } from '../pipes/pipes-consumer.js';
import { PipesContextCreator } from '../pipes/pipes-context-creator.js';
import { IRouteParamsFactory } from './interfaces/route-params-factory.interface.js';
import { markRejectedByPipes } from './rejected-by-pipes.js';
import {
CustomHeader,
RedirectResponse,
@@ -172,8 +173,15 @@ export class RouterExecutionContext {
handlerFiles: StreamableFile[] = [],
) =>
async () => {
const pendingPipes = fnApplyPipes && fnApplyPipes(args, req, res, next);
pendingPipes && (await pendingPipes);
try {
const pendingPipes =
fnApplyPipes && fnApplyPipes(args, req, res, next);
pendingPipes && (await pendingPipes);
} catch (err) {
// e.g. for upload interceptors to remove the files they stored
markRejectedByPipes(req);
throw err;
}
const result = callback.apply(instance, args);
if (!handlerObservable$) {
return result;
@@ -29,6 +29,7 @@ import { InterceptorsConsumer } from '../../interceptors/interceptors-consumer.j
import { InterceptorsContextCreator } from '../../interceptors/interceptors-context-creator.js';
import { PipesConsumer } from '../../pipes/pipes-consumer.js';
import { PipesContextCreator } from '../../pipes/pipes-context-creator.js';
import { isRejectedByPipes } from '../../router/rejected-by-pipes.js';
import { RouteParamsFactory } from '../../router/route-params-factory.js';
import { RouterExecutionContext } from '../../router/router-execution-context.js';
import { HeaderStream } from '../../router/sse-stream.js';
@@ -187,6 +188,53 @@ describe('RouterExecutionContext', () => {
});
});
describe('when the pipes reject the request', () => {
const createProxy = (pipesFn: Function, apply: () => unknown) => {
vi.spyOn(contextCreator, 'getMetadata').mockReturnValue({
argsLength: 1,
fnHandleResponse: vi.fn(),
isSseHandler: false,
paramtypes: [],
getParamsMetadata: vi.fn().mockReturnValue([]),
httpStatusCode: 200,
hasCustomHeaders: false,
responseHeaders: [],
} as any);
vi.spyOn(contextCreator, 'createGuardsFn').mockReturnValue(null as any);
vi.spyOn(contextCreator, 'createPipesFn').mockReturnValue(
pipesFn as any,
);
return contextCreator.create({} as any, { apply } as any, '', '', 0);
};
it('should record that the route handler did not run', async () => {
const error = new HttpException('Validation failed', 400);
const apply = vi.fn();
const req = {};
const proxy = createProxy(() => Promise.reject(error), apply);
await expect(proxy(req, {}, vi.fn())).rejects.toBe(error);
expect(apply).not.toHaveBeenCalled();
expect(isRejectedByPipes(req)).toBe(true);
});
it('should not record a request whose route handler fails', async () => {
const error = new Error('handler failed');
const req = {};
const proxy = createProxy(
() => Promise.resolve(),
() => {
throw error;
},
);
await expect(proxy(req, {}, vi.fn())).rejects.toBe(error);
expect(isRejectedByPipes(req)).toBe(false);
});
});
describe('when callback metadata is not undefined', () => {
let metadata: Record<number, RouteParamMetadata>;
let exchangeKeysForValuesSpy: ReturnType<typeof vi.fn>;
@@ -15,6 +15,7 @@ import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../multer/multer.utils.js';
@@ -56,7 +57,15 @@ export function AnyFilesInterceptor(
resolve();
}),
);
return next.handle();
return next
.handle()
.pipe(
removeFilesOnPipeRejection(
this.multer.storage,
ctx.getRequest(),
ctx.getResponse(),
),
);
}
}
const Interceptor = mixin(MixinInterceptor);
@@ -18,6 +18,7 @@ import {
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../multer/multer.utils.js';
@@ -64,7 +65,15 @@ export function FileFieldsInterceptor(
},
),
);
return next.handle();
return next
.handle()
.pipe(
removeFilesOnPipeRejection(
this.multer.storage,
ctx.getRequest(),
ctx.getResponse(),
),
);
}
}
const Interceptor = mixin(MixinInterceptor);
@@ -15,6 +15,7 @@ import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../multer/multer.utils.js';
@@ -62,7 +63,15 @@ export function FileInterceptor(
},
),
);
return next.handle();
return next
.handle()
.pipe(
removeFilesOnPipeRejection(
this.multer.storage,
ctx.getRequest(),
ctx.getResponse(),
),
);
}
}
const Interceptor = mixin(MixinInterceptor);
@@ -15,6 +15,7 @@ import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../multer/multer.utils.js';
@@ -65,7 +66,15 @@ export function FilesInterceptor(
},
),
);
return next.handle();
return next
.handle()
.pipe(
removeFilesOnPipeRejection(
this.multer.storage,
ctx.getRequest(),
ctx.getResponse(),
),
);
}
}
const Interceptor = mixin(MixinInterceptor);
@@ -4,6 +4,9 @@ import {
PayloadTooLargeException,
} from '@nestjs/common';
import { isPlainObject } from '@nestjs/common/internal';
import { isRejectedByPipes } from '@nestjs/core/internal';
import { catchError, type MonoTypeOperatorFunction, throwError } from 'rxjs';
import { finished } from 'stream';
import {
DEFAULT_FIELD_NAME_LIMITS,
multerExceptions,
@@ -71,6 +74,41 @@ function withDefaultLimits(
};
}
/**
* Removes the files multer stored for `req` when the route's pipes reject
* the request (e.g. `ParseFilePipe`): the route handler never sees them, and
* nothing else would remove them. They are removed once the response has
* been sent, as an exception filter may still use (or remove) them.
*/
export function removeFilesOnPipeRejection<T>(
storage: any,
req: any,
res: any,
): MonoTypeOperatorFunction<T> {
return catchError(err => {
if (isRejectedByPipes(req)) {
finished(res, () => {
for (const file of storedFiles(req)) {
try {
storage._removeFile(req, file, () => undefined);
} catch {
// The request has failed already.
}
}
});
}
return throwError(() => err);
});
}
function storedFiles(req: any): any[] {
const { file, files } = req;
if (file) {
return [file];
}
return Array.isArray(files) ? files : Object.values(files ?? {}).flat();
}
// Multer may add in a 'field' property to the error
// https://github.com/expressjs/multer/blob/aa42bea6ac7d0cb8fcb279b15a7278cda805dc63/lib/multer-error.js#L19
export function transformException(
@@ -1,6 +1,8 @@
import { CallHandler } from '@nestjs/common';
import { BadRequestException, CallHandler } from '@nestjs/common';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
import { of } from 'rxjs';
import { markRejectedByPipes } from '@nestjs/core/router/rejected-by-pipes.js';
import { lastValueFrom, of, throwError } from 'rxjs';
import { Writable } from 'stream';
import { AnyFilesInterceptor } from '../../../multer/interceptors/any-files.interceptor.js';
describe('FilesInterceptor', () => {
@@ -39,6 +41,37 @@ describe('FilesInterceptor', () => {
error => expect(error).not.toBeUndefined(),
);
});
it('should remove the stored files when the pipes reject the request', async () => {
const file = { fieldname: 'file' };
const storage = { _removeFile: vi.fn((_req, _file, cb) => cb(null)) };
const target = new (AnyFilesInterceptor())();
(target as any).multer = {
any: () => (req, _res, next) => {
req.files = [file];
next();
},
storage,
};
const req: any = {};
const res = new Writable({ write: (_chunk, _encoding, cb) => cb() });
const error = new BadRequestException('Validation failed');
markRejectedByPipes(req);
const result$ = await target.intercept(
new ExecutionContextHost([req, res]),
{ handle: () => throwError(() => error) },
);
await expect(lastValueFrom(result$)).rejects.toBe(error);
res.end();
await vi.waitFor(() =>
expect(storage._removeFile).toHaveBeenCalledWith(
req,
file,
expect.any(Function),
),
);
});
});
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (AnyFilesInterceptor({ limits: { fileSize: 10 } }))({
@@ -1,6 +1,8 @@
import { CallHandler } from '@nestjs/common';
import { BadRequestException, CallHandler } from '@nestjs/common';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
import { of } from 'rxjs';
import { markRejectedByPipes } from '@nestjs/core/router/rejected-by-pipes.js';
import { lastValueFrom, of, throwError } from 'rxjs';
import { Writable } from 'stream';
import { FileFieldsInterceptor } from '../../../multer/interceptors/file-fields.interceptor.js';
describe('FileFieldsInterceptor', () => {
@@ -58,6 +60,39 @@ describe('FileFieldsInterceptor', () => {
error => expect(error).not.toBeUndefined(),
);
});
it('should remove the stored files when the pipes reject the request', async () => {
const file = { fieldname: 'file' };
const storage = { _removeFile: vi.fn((_req, _file, cb) => cb(null)) };
const target = new (FileFieldsInterceptor([
{ name: 'file', maxCount: 1 },
]))();
(target as any).multer = {
fields: () => (req, _res, next) => {
req.files = { file: [file] };
next();
},
storage,
};
const req: any = {};
const res = new Writable({ write: (_chunk, _encoding, cb) => cb() });
const error = new BadRequestException('Validation failed');
markRejectedByPipes(req);
const result$ = await target.intercept(
new ExecutionContextHost([req, res]),
{ handle: () => throwError(() => error) },
);
await expect(lastValueFrom(result$)).rejects.toBe(error);
res.end();
await vi.waitFor(() =>
expect(storage._removeFile).toHaveBeenCalledWith(
req,
file,
expect.any(Function),
),
);
});
});
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (FileFieldsInterceptor([{ name: 'file', maxCount: 1 }], {
@@ -1,6 +1,8 @@
import { CallHandler } from '@nestjs/common';
import { BadRequestException, CallHandler } from '@nestjs/common';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
import { of } from 'rxjs';
import { markRejectedByPipes } from '@nestjs/core/router/rejected-by-pipes.js';
import { lastValueFrom, of, throwError } from 'rxjs';
import { Writable } from 'stream';
import { FileInterceptor } from '../../../multer/interceptors/file.interceptor.js';
describe('FileInterceptor', () => {
@@ -40,6 +42,37 @@ describe('FileInterceptor', () => {
error => expect(error).not.toBeUndefined(),
);
});
it('should remove the stored file when the pipes reject the request', async () => {
const file = { fieldname: 'file' };
const storage = { _removeFile: vi.fn((_req, _file, cb) => cb(null)) };
const target = new (FileInterceptor('file'))();
(target as any).multer = {
single: () => (req, _res, next) => {
req.file = file;
next();
},
storage,
};
const req: any = {};
const res = new Writable({ write: (_chunk, _encoding, cb) => cb() });
const error = new BadRequestException('Validation failed');
markRejectedByPipes(req);
const result$ = await target.intercept(
new ExecutionContextHost([req, res]),
{ handle: () => throwError(() => error) },
);
await expect(lastValueFrom(result$)).rejects.toBe(error);
res.end();
await vi.waitFor(() =>
expect(storage._removeFile).toHaveBeenCalledWith(
req,
file,
expect.any(Function),
),
);
});
});
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (FileInterceptor('file', {
@@ -1,6 +1,8 @@
import { CallHandler } from '@nestjs/common';
import { BadRequestException, CallHandler } from '@nestjs/common';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
import { of } from 'rxjs';
import { markRejectedByPipes } from '@nestjs/core/router/rejected-by-pipes.js';
import { lastValueFrom, of, throwError } from 'rxjs';
import { Writable } from 'stream';
import { FilesInterceptor } from '../../../multer/interceptors/files.interceptor.js';
describe('FilesInterceptor', () => {
@@ -42,6 +44,37 @@ describe('FilesInterceptor', () => {
error => expect(error).not.toBeUndefined(),
);
});
it('should remove the stored files when the pipes reject the request', async () => {
const file = { fieldname: 'file' };
const storage = { _removeFile: vi.fn((_req, _file, cb) => cb(null)) };
const target = new (FilesInterceptor('file'))();
(target as any).multer = {
array: () => (req, _res, next) => {
req.files = [file];
next();
},
storage,
};
const req: any = {};
const res = new Writable({ write: (_chunk, _encoding, cb) => cb() });
const error = new BadRequestException('Validation failed');
markRejectedByPipes(req);
const result$ = await target.intercept(
new ExecutionContextHost([req, res]),
{ handle: () => throwError(() => error) },
);
await expect(lastValueFrom(result$)).rejects.toBe(error);
res.end();
await vi.waitFor(() =>
expect(storage._removeFile).toHaveBeenCalledWith(
req,
file,
expect.any(Function),
),
);
});
});
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (FilesInterceptor('file', 10, {
@@ -11,10 +11,13 @@ import {
import {
applyDefaultLimits,
mergeMulterOptions,
removeFilesOnPipeRejection,
transformException,
} from '../../../multer/multer/multer.utils.js';
import { MulterOptions } from '../../../multer/interfaces/multer-options.interface.js';
import { Readable } from 'stream';
import { markRejectedByPipes } from '@nestjs/core/router/rejected-by-pipes.js';
import { lastValueFrom, throwError } from 'rxjs';
import { Readable, Writable } from 'stream';
describe('mergeMulterOptions', () => {
it('should let local options override top-level global options', () => {
@@ -196,6 +199,61 @@ describe('applyDefaultLimits', () => {
});
});
describe('removeFilesOnPipeRejection', () => {
const error = new BadRequestException('Validation failed');
const createStorage = () => ({
_removeFile: vi.fn((_req, _file, cb) => cb(null)),
});
const createResponse = () =>
new Writable({ write: (_chunk, _encoding, cb) => cb() });
const fail = (storage: any, req: any, res: Writable) =>
lastValueFrom(
throwError(() => error).pipe(
removeFilesOnPipeRejection(storage, req, res),
),
);
it.each([
['req.file', (file: object) => ({ file })],
['req.files', (file: object) => ({ files: [file] })],
['req.files by field', (file: object) => ({ files: { docs: [file] } })],
])(
'should remove %s once the response is sent when the pipes rejected the request',
async (_, createRequest) => {
const storage = createStorage();
const file = { fieldname: 'docs', path: '/uploads/a' };
const req = createRequest(file);
const res = createResponse();
markRejectedByPipes(req);
await expect(fail(storage, req, res)).rejects.toBe(error);
// An exception filter may still use the files until the response is out.
expect(storage._removeFile).not.toHaveBeenCalled();
res.end();
await vi.waitFor(() =>
expect(storage._removeFile).toHaveBeenCalledWith(
req,
file,
expect.any(Function),
),
);
},
);
it('should keep the files when the route handler failed', async () => {
const storage = createStorage();
const req = { file: { fieldname: 'doc', path: '/uploads/a' } };
const res = createResponse();
await expect(fail(storage, req, res)).rejects.toBe(error);
res.end();
await new Promise(resolve => setImmediate(resolve));
expect(storage._removeFile).not.toHaveBeenCalled();
});
});
describe('transformException', () => {
describe('if error does not exist', () => {
it('should behave as identity', () => {
@@ -10,8 +10,10 @@ import {
type Type,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { isRejectedByPipes } from '@nestjs/core/internal';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { catchError, concatMap, from, type Observable, throwError } from 'rxjs';
import { finished } from 'stream';
import { MULTIPART_MODULE_OPTIONS } from '../files.constants.js';
import type {
MultipartModuleOptions,
@@ -27,6 +29,7 @@ import {
drainRequest,
processMultipart,
processStream,
removeFiles,
resolveStorage,
type UploadSpec,
validateLimits,
@@ -134,9 +137,29 @@ export function createMultipartInterceptor(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const req = context.switchToHttp().getRequest<FastifyRequest>();
await processMultipart(req, spec, this.options, this.storage);
return next.handle();
const ctx = context.switchToHttp();
const req = ctx.getRequest<FastifyRequest>();
const stored = await processMultipart(
req,
spec,
this.options,
this.storage,
);
return next.handle().pipe(
// When the route's pipes reject the request (e.g. `ParseFilePipe`),
// the route handler never sees the files, and nothing else would
// remove them. They are removed once the response has been sent, as
// an exception filter may still use (or remove) them.
catchError(err => {
if (isRejectedByPipes(req)) {
const reply = ctx.getResponse<FastifyReply>();
finished(reply.raw, () => {
removeFiles(this.storage, req, stored).catch(() => undefined);
});
}
return throwError(() => err);
}),
);
}
}
return mixin(MixinInterceptor);
@@ -218,7 +218,7 @@ function storeFile(
);
}
function removeFiles(
export function removeFiles(
storage: MultipartStorageEngine,
req: FastifyRequest,
files: UploadedMultipartFile[],
@@ -410,20 +410,21 @@ function readFilePart(
/**
* Parses the multipart body with multer's semantics and populates
* `req.body` and `req.file` / `req.files`. Requests that are not multipart
* are passed through untouched, as multer does.
* `req.body` and `req.file` / `req.files`, and returns the stored files.
* Requests that are not multipart are passed through untouched, as multer
* does.
*/
export async function processMultipart(
request: FastifyRequest,
spec: UploadSpec,
options: MultipartOptions,
storage: MultipartStorageEngine,
): Promise<void> {
): Promise<UploadedMultipartFile[]> {
const req = request as MultipartRequest;
assertPluginRegistered(req);
resetFiles(req);
if (!req.isMultipart()) {
return;
return [];
}
const filesLeft = new Map<string, number>();
@@ -506,6 +507,7 @@ export async function processMultipart(
break;
}
}
return stored;
}
/**
@@ -3,7 +3,9 @@ import {
type CallHandler,
PayloadTooLargeException,
} from '@nestjs/common';
import { of } from 'rxjs';
import { markRejectedByPipes } from '@nestjs/core/router/rejected-by-pipes.js';
import { lastValueFrom, of, throwError } from 'rxjs';
import { Writable } from 'stream';
import { FileInterceptor } from '../../../multipart/interceptors/file.interceptor.js';
import { MISSING_PLUGIN_MESSAGE } from '../../../multipart/multipart/multipart.constants.js';
import {
@@ -320,4 +322,61 @@ describe('FileInterceptor', () => {
expect(req.file.size).toBe(42);
});
});
describe('when the route handler does not run', () => {
const error = new BadRequestException('Validation failed');
const failing: CallHandler = { handle: () => throwError(() => error) };
const createStorage = () => ({
_handleFile: vi.fn((_req, incoming, cb) => {
incoming.stream.resume();
incoming.stream.once('end', () => cb(null, { path: '/uploads/a' }));
}),
_removeFile: vi.fn((_req, _file, cb) => cb(null)),
});
const createReply = () => ({
raw: new Writable({ write: (_chunk, _encoding, cb) => cb() }),
});
it('should remove the stored file once the response is sent when the pipes rejected the request', async () => {
const storage = createStorage();
const target = new (FileInterceptor('avatar', { storage }))();
const req = createFakeRequest([file('avatar')]);
const reply = createReply();
markRejectedByPipes(req);
const result$ = await target.intercept(
createContext(req, reply),
failing,
);
await expect(lastValueFrom(result$)).rejects.toBe(error);
// An exception filter may still use the file until the response is out.
expect(storage._removeFile).not.toHaveBeenCalled();
reply.raw.end();
await vi.waitFor(() =>
expect(storage._removeFile).toHaveBeenCalledWith(
req,
expect.objectContaining({ path: '/uploads/a' }),
expect.any(Function),
),
);
});
it('should keep the stored file when the route handler fails', async () => {
const storage = createStorage();
const target = new (FileInterceptor('avatar', { storage }))();
const req = createFakeRequest([file('avatar')]);
const reply = createReply();
const result$ = await target.intercept(
createContext(req, reply),
failing,
);
await expect(lastValueFrom(result$)).rejects.toBe(error);
reply.raw.end();
await new Promise(resolve => setImmediate(resolve));
expect(storage._removeFile).not.toHaveBeenCalled();
});
});
});