feat(storage): add rate limiting, file versioning endpoints, and version tests

Rate Limiting:
- Add @nestjs/throttler with 100 req/min global limit
- Stricter limits for uploads: 20 req/min single, 10 req/min multi

File Versioning (Backend):
- GET /api/v1/files/:id/versions — list version history
- POST /api/v1/files/:id/versions — upload new version with optional comment
- Updates file metadata (size, name, storageKey) on new version
- 7 new tests for versioning service and controller

API Client (Frontend):
- Add FileVersion interface
- Add filesApi.getVersions() and filesApi.uploadVersion()

Total tests: 205 (140 backend + 65 web)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-21 21:23:28 +01:00
parent c7b105bc00
commit 9085dddfad
6 changed files with 387 additions and 4 deletions

View file

@ -29,6 +29,7 @@
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/throttler": "^6.2.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",

View file

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { FileModule } from './file/file.module';
@ -16,6 +17,12 @@ import { AdminModule } from './admin/admin.module';
ConfigModule.forRoot({
isGlobal: true,
}),
ThrottlerModule.forRoot([
{
ttl: 60000, // 60 seconds
limit: 100, // 100 requests per minute
},
]),
DatabaseModule,
HealthModule.forRoot({ serviceName: 'storage-backend', route: 'api/v1/health' }),
StorageModule,

View file

@ -0,0 +1,267 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { DATABASE_CONNECTION } from '../db/database.module';
import { FileService } from './file.service';
import { FileController } from './file.controller';
import { StorageService } from '../storage/storage.service';
import { createMockDb, mockFileFactory } from '../__tests__/utils/mock-factories';
import { randomUUID } from 'crypto';
// FileVersion mock factory
const mockFileVersionFactory = {
create: (overrides: Record<string, any> = {}) => ({
id: randomUUID(),
fileId: randomUUID(),
versionNumber: 1,
storagePath: 'users/test-user-id/versions/test-file.pdf',
storageKey: `users/test-user-id/versions/${randomUUID()}-test-file.pdf`,
size: 1024,
checksum: null,
comment: null,
createdBy: 'test-user-id',
createdAt: new Date(),
...overrides,
}),
};
describe('FileService - Versions', () => {
let service: FileService;
let mockDb: ReturnType<typeof createMockDb>;
let mockStorageService: Record<string, jest.Mock>;
beforeEach(async () => {
mockDb = createMockDb();
mockStorageService = {
uploadFile: jest.fn(),
downloadFile: jest.fn(),
deleteFile: jest.fn(),
deleteFiles: jest.fn(),
getDownloadUrl: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
FileService,
{ provide: DATABASE_CONNECTION, useValue: mockDb },
{ provide: StorageService, useValue: mockStorageService },
],
}).compile();
service = module.get<FileService>(FileService);
});
describe('getVersions', () => {
it('should return versions sorted by version number desc', async () => {
const fileId = randomUUID();
const file = mockFileFactory.create({ id: fileId, currentVersion: 3 });
// findOne query
mockDb.where.mockResolvedValueOnce([file]);
const versions = [
mockFileVersionFactory.create({ fileId, versionNumber: 3 }),
mockFileVersionFactory.create({ fileId, versionNumber: 2 }),
mockFileVersionFactory.create({ fileId, versionNumber: 1 }),
];
// getVersions query (select -> from -> where -> orderBy)
mockDb.orderBy.mockResolvedValueOnce(versions);
const result = await service.getVersions('test-user-id', fileId);
expect(result).toEqual(versions);
expect(result[0].versionNumber).toBe(3);
expect(result[2].versionNumber).toBe(1);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
});
it('should throw NotFoundException for non-existent file', async () => {
mockDb.where.mockResolvedValueOnce([]);
await expect(service.getVersions('test-user-id', 'nonexistent-id')).rejects.toThrow(
NotFoundException
);
});
});
describe('uploadVersion', () => {
it('should create new version and update file', async () => {
const fileId = randomUUID();
const file = mockFileFactory.create({ id: fileId, currentVersion: 2 });
// findOne query
mockDb.where.mockResolvedValueOnce([file]);
const uploadResult = {
storageKey: 'users/test-user-id/versions/new-file.pdf',
storagePath: 'users/test-user-id/versions/new-file.pdf',
};
mockStorageService.uploadFile.mockResolvedValueOnce(uploadResult);
const createdVersion = mockFileVersionFactory.create({
fileId,
versionNumber: 3,
comment: 'Updated layout',
});
// insert().values().returning() for version record
mockDb.returning.mockResolvedValueOnce([createdVersion]);
// update().set().where() for file update
mockDb.where.mockResolvedValueOnce(undefined);
const multerFile = {
buffer: Buffer.from('new version content'),
originalname: 'updated-file.pdf',
mimetype: 'application/pdf',
size: 2048,
} as Express.Multer.File;
const result = await service.uploadVersion(
'test-user-id',
fileId,
multerFile,
'Updated layout'
);
expect(result).toEqual(createdVersion);
expect(mockStorageService.uploadFile).toHaveBeenCalledWith(
'test-user-id',
multerFile.buffer,
'updated-file.pdf',
'application/pdf',
'versions'
);
});
it('should increment version number', async () => {
const fileId = randomUUID();
const file = mockFileFactory.create({ id: fileId, currentVersion: 5 });
// findOne query
mockDb.where.mockResolvedValueOnce([file]);
const uploadResult = {
storageKey: 'users/test-user-id/versions/file.pdf',
storagePath: 'users/test-user-id/versions/file.pdf',
};
mockStorageService.uploadFile.mockResolvedValueOnce(uploadResult);
const createdVersion = mockFileVersionFactory.create({
fileId,
versionNumber: 6,
});
// insert().values().returning()
mockDb.returning.mockResolvedValueOnce([createdVersion]);
// update().set().where()
mockDb.where.mockResolvedValueOnce(undefined);
const multerFile = {
buffer: Buffer.from('content'),
originalname: 'file.pdf',
mimetype: 'application/pdf',
size: 512,
} as Express.Multer.File;
const result = await service.uploadVersion('test-user-id', fileId, multerFile);
expect(result.versionNumber).toBe(6);
expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({
currentVersion: 6,
})
);
});
});
});
describe('FileController - Versions', () => {
let controller: FileController;
let fileService: jest.Mocked<FileService>;
const mockUser = { userId: 'test-user-id', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const mockFileService = {
findAll: jest.fn(),
findOne: jest.fn(),
upload: jest.fn(),
update: jest.fn(),
move: jest.fn(),
delete: jest.fn(),
toggleFavorite: jest.fn(),
download: jest.fn(),
getDownloadUrl: jest.fn(),
getStats: jest.fn(),
getVersions: jest.fn(),
uploadVersion: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [FileController],
providers: [{ provide: FileService, useValue: mockFileService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FileController>(FileController);
fileService = module.get(FileService);
});
describe('getVersions', () => {
it('should call service getVersions with correct params', async () => {
const fileId = 'file-123';
const versions = [
mockFileVersionFactory.create({ fileId, versionNumber: 2 }),
mockFileVersionFactory.create({ fileId, versionNumber: 1 }),
];
fileService.getVersions.mockResolvedValue(versions);
const result = await controller.getVersions(mockUser, fileId);
expect(fileService.getVersions).toHaveBeenCalledWith('test-user-id', fileId);
expect(result).toEqual(versions);
});
});
describe('uploadVersion', () => {
it('should call service uploadVersion with file and comment', async () => {
const fileId = 'file-123';
const mockFile = {
originalname: 'updated.pdf',
mimetype: 'application/pdf',
size: 2048,
buffer: Buffer.from('updated content'),
} as Express.Multer.File;
const comment = 'Fixed typos';
const createdVersion = mockFileVersionFactory.create({
fileId,
versionNumber: 2,
comment,
});
fileService.uploadVersion.mockResolvedValue(createdVersion);
const result = await controller.uploadVersion(mockUser, fileId, mockFile, comment);
expect(fileService.uploadVersion).toHaveBeenCalledWith(
'test-user-id',
fileId,
mockFile,
comment
);
expect(result).toEqual(createdVersion);
});
it('should throw BadRequestException when no file is provided', async () => {
await expect(
controller.uploadVersion(mockUser, 'file-123', undefined as any)
).rejects.toThrow(BadRequestException);
});
});
});

View file

@ -16,6 +16,7 @@ import {
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
import { FileService } from './file.service';
@ -42,12 +43,32 @@ export class FileController {
return this.fileService.getStats(user.userId);
}
@Get(':id/versions')
async getVersions(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.fileService.getVersions(user.userId, id);
}
@Post(':id/versions')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_FILE_SIZE } }))
async uploadVersion(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
@Body('comment') comment?: string
) {
if (!file) {
throw new BadRequestException('No file provided');
}
return this.fileService.uploadVersion(user.userId, id, file, comment);
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.fileService.findOne(user.userId, id);
}
@Post('upload')
@Throttle({ default: { ttl: 60000, limit: 20 } })
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE },
@ -65,6 +86,7 @@ export class FileController {
}
@Post('upload-multiple')
@Throttle({ default: { ttl: 60000, limit: 10 } })
@UseInterceptors(
FilesInterceptor('files', MAX_FILES, {
limits: { fileSize: MAX_FILE_SIZE },

View file

@ -3,7 +3,7 @@ import { eq, and, isNull } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { files, fileVersions } from '../db/schema';
import type { File, NewFile, NewFileVersion } from '../db/schema';
import type { File, FileVersion, NewFile, NewFileVersion } from '../db/schema';
import { StorageService } from '../storage/storage.service';
import { CreateFileDto, UpdateFileDto, MoveFileDto } from './dto/create-file.dto';
@ -164,6 +164,67 @@ export class FileService {
return this.storageService.getDownloadUrl(file.storageKey);
}
async getVersions(userId: string, fileId: string): Promise<FileVersion[]> {
// Verify file belongs to user
await this.findOne(userId, fileId);
const { desc } = await import('drizzle-orm');
return this.db
.select()
.from(fileVersions)
.where(eq(fileVersions.fileId, fileId))
.orderBy(desc(fileVersions.versionNumber));
}
async uploadVersion(
userId: string,
fileId: string,
file: Express.Multer.File,
comment?: string
): Promise<FileVersion> {
const existingFile = await this.findOne(userId, fileId);
// Upload new version to S3
const uploadResult = await this.storageService.uploadFile(
userId,
file.buffer,
file.originalname,
file.mimetype,
'versions'
);
const newVersionNumber = existingFile.currentVersion + 1;
// Create version record
const version: NewFileVersion = {
fileId,
versionNumber: newVersionNumber,
storagePath: uploadResult.storagePath,
storageKey: uploadResult.storageKey,
size: file.size,
comment,
createdBy: userId,
};
const [createdVersion] = await this.db.insert(fileVersions).values(version).returning();
// Update file's current version and size
await this.db
.update(files)
.set({
currentVersion: newVersionNumber,
size: file.size,
name: file.originalname,
mimeType: file.mimetype,
storagePath: uploadResult.storagePath,
storageKey: uploadResult.storageKey,
updatedAt: new Date(),
})
.where(eq(files.id, fileId));
return createdVersion;
}
async getStats(userId: string): Promise<{
totalFiles: number;
totalSize: number;
@ -187,9 +248,7 @@ export class FileService {
count: count(files.id),
})
.from(files)
.where(
and(eq(files.userId, userId), eq(files.isDeleted, false), eq(files.isFavorite, true))
);
.where(and(eq(files.userId, userId), eq(files.isDeleted, false), eq(files.isFavorite, true)));
// Get recent files (last 5)
const { desc } = await import('drizzle-orm');

View file

@ -116,6 +116,19 @@ export interface Share {
createdAt: string;
}
export interface FileVersion {
id: string;
fileId: string;
versionNumber: number;
storagePath: string;
storageKey: string;
size: number;
checksum: string | null;
comment: string | null;
createdBy: string;
createdAt: string;
}
export interface Tag {
id: string;
userId: string;
@ -172,6 +185,20 @@ export const filesApi = {
delete: (id: string) => request<{ success: boolean }>(`/files/${id}`, { method: 'DELETE' }),
toggleFavorite: (id: string) => request<StorageFile>(`/files/${id}/favorite`, { method: 'POST' }),
getVersions: (fileId: string) => request<FileVersion[]>(`/files/${fileId}/versions`),
uploadVersion: async (
fileId: string,
file: File,
comment?: string
): Promise<ApiResponse<FileVersion>> => {
const formData = new FormData();
formData.append('file', file);
if (comment) formData.append('comment', comment);
const result = await api.upload<FileVersion>(`/files/${fileId}/versions`, formData);
return toLegacyResponse(result);
},
};
// Folders API