mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 05:41:09 +02:00
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:
parent
c7b105bc00
commit
9085dddfad
6 changed files with 387 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
267
apps/storage/apps/backend/src/file/file-versions.spec.ts
Normal file
267
apps/storage/apps/backend/src/file/file-versions.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue