180 lines
7.3 KiB
TypeScript
180 lines
7.3 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { FilesService } from './files.service';
|
|
|
|
describe('FilesService', () => {
|
|
it('uploads file content to object storage before creating FileObject metadata', async () => {
|
|
const prisma = {
|
|
fileObject: {
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-1', ...data })),
|
|
},
|
|
};
|
|
const objectStorage = {
|
|
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
|
|
putObject: jest.fn().mockResolvedValue({ etag: 'etag-1' }),
|
|
presignedPutObject: jest.fn(),
|
|
};
|
|
const service = new FilesService(prisma as never, objectStorage as never);
|
|
const file = {
|
|
originalname: '营业执照.png',
|
|
mimetype: 'image/png',
|
|
size: 12,
|
|
buffer: Buffer.from('file-content'),
|
|
};
|
|
|
|
await expect(service.upload({ tenantId: 'tenant-1', purpose: 'signature_material', prefix: 'signature-materials/sig-1' }, file))
|
|
.resolves.toEqual(expect.objectContaining({
|
|
id: 'file-1',
|
|
tenantId: 'tenant-1',
|
|
bucket: 'cmpp-platform',
|
|
fileName: '营业执照.png',
|
|
contentType: 'image/png',
|
|
sizeBytes: '12',
|
|
purpose: 'signature_material',
|
|
}));
|
|
|
|
expect(objectStorage.putObject).toHaveBeenCalledWith(
|
|
expect.stringMatching(/^signature-materials\/sig-1\/\d+-[a-f0-9-]+-营业执照\.png$/),
|
|
file.buffer,
|
|
12,
|
|
'image/png',
|
|
);
|
|
expect(prisma.fileObject.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: 'tenant-1',
|
|
bucket: 'cmpp-platform',
|
|
fileName: '营业执照.png',
|
|
contentType: 'image/png',
|
|
sizeBytes: BigInt(12),
|
|
purpose: 'signature_material',
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('restores UTF-8 filenames that multipart parsing exposed as Latin-1', async () => {
|
|
const prisma = {
|
|
fileObject: {
|
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-1', ...data })),
|
|
},
|
|
};
|
|
const objectStorage = {
|
|
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
|
|
putObject: jest.fn().mockResolvedValue({ etag: 'etag-1' }),
|
|
presignedPutObject: jest.fn(),
|
|
};
|
|
const service = new FilesService(prisma as never, objectStorage as never);
|
|
const originalname = Buffer.from('营业执照.png', 'utf8').toString('latin1');
|
|
|
|
await service.upload({ purpose: 'enterprise_photo' }, {
|
|
originalname,
|
|
mimetype: 'image/png',
|
|
size: 12,
|
|
buffer: Buffer.from('file-content'),
|
|
});
|
|
|
|
expect(prisma.fileObject.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ fileName: '营业执照.png' }),
|
|
});
|
|
expect(objectStorage.putObject).toHaveBeenCalledWith(
|
|
expect.stringMatching(/营业执照\.png$/),
|
|
expect.any(Buffer),
|
|
12,
|
|
'image/png',
|
|
);
|
|
});
|
|
|
|
it('rejects images over 2MB and other files over 10MB before object storage writes', async () => {
|
|
const prisma = { fileObject: { create: jest.fn() } };
|
|
const objectStorage = { putObject: jest.fn(), getBucket: jest.fn().mockReturnValue('bucket') };
|
|
const service = new FilesService(prisma as never, objectStorage as never);
|
|
|
|
await expect(service.upload({ purpose: 'test' }, {
|
|
originalname: 'large.png',
|
|
mimetype: 'image/png',
|
|
size: 2 * 1024 * 1024 + 1,
|
|
buffer: Buffer.alloc(0),
|
|
})).rejects.toBeInstanceOf(BadRequestException);
|
|
await expect(service.upload({ purpose: 'test' }, {
|
|
originalname: 'large.pdf',
|
|
mimetype: 'application/pdf',
|
|
size: 10 * 1024 * 1024 + 1,
|
|
buffer: Buffer.alloc(0),
|
|
})).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(objectStorage.putObject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('downloads file content from object storage by FileObject id', async () => {
|
|
const fileObject = {
|
|
id: 'file-1',
|
|
tenantId: 'tenant-1',
|
|
bucket: 'cmpp-platform',
|
|
objectKey: 'signature-materials/sig-1/file.png',
|
|
fileName: 'file.png',
|
|
contentType: 'image/png',
|
|
sizeBytes: BigInt(12),
|
|
checksum: null,
|
|
purpose: 'signature_material',
|
|
createdAt: new Date('2026-07-06T00:00:00.000Z'),
|
|
};
|
|
const prisma = {
|
|
fileObject: {
|
|
findUnique: jest.fn().mockResolvedValue(fileObject),
|
|
},
|
|
};
|
|
const objectStorage = {
|
|
getObject: jest.fn().mockResolvedValue(Buffer.from('file-content')),
|
|
};
|
|
const service = new FilesService(prisma as never, objectStorage as never);
|
|
|
|
await expect(service.getDownload('file-1')).resolves.toEqual({
|
|
fileObject: expect.objectContaining({
|
|
id: 'file-1',
|
|
fileName: 'file.png',
|
|
sizeBytes: '12',
|
|
}),
|
|
content: Buffer.from('file-content'),
|
|
});
|
|
expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } });
|
|
expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png');
|
|
});
|
|
|
|
it('derives client upload tenant from the authenticated user and ignores tenant headers', async () => {
|
|
const prisma = {
|
|
user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-real' }) },
|
|
fileObject: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-client', ...data })) },
|
|
};
|
|
const objectStorage = {
|
|
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
|
|
putObject: jest.fn().mockResolvedValue({ etag: 'etag' }),
|
|
};
|
|
const service = new FilesService(prisma as never, objectStorage as never);
|
|
await expect(service.uploadForClient('user-1', {
|
|
purpose: 'enterprise_certification',
|
|
prefix: 'enterprise-certifications/license',
|
|
}, { originalname: 'license.pdf', mimetype: 'application/pdf', size: 4, buffer: Buffer.from('test') }))
|
|
.resolves.toEqual(expect.objectContaining({ tenantId: 'tenant-real', purpose: 'enterprise_certification' }));
|
|
expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'user-1' }) }));
|
|
});
|
|
|
|
it('rejects arbitrary client upload purposes and prefixes before object storage writes', async () => {
|
|
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }) } };
|
|
const objectStorage = { putObject: jest.fn() };
|
|
const service = new FilesService(prisma as never, objectStorage as never);
|
|
await expect(service.uploadForClient('user-1', { purpose: 'enterprise_certification', prefix: '../admin' }, {
|
|
originalname: 'file.pdf', mimetype: 'application/pdf', size: 4, buffer: Buffer.from('test'),
|
|
})).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(objectStorage.putObject).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prevents client users from downloading another tenant file', async () => {
|
|
const prisma = {
|
|
user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }) },
|
|
fileObject: { findFirst: jest.fn().mockResolvedValue(null) },
|
|
};
|
|
const objectStorage = { getObject: jest.fn() };
|
|
const service = new FilesService(prisma as never, objectStorage as never);
|
|
await expect(service.getClientDownload('user-1', 'foreign-file')).rejects.toThrow('File object not found');
|
|
expect(prisma.fileObject.findFirst).toHaveBeenCalledWith({ where: { id: 'foreign-file', tenantId: 'tenant-1' } });
|
|
expect(objectStorage.getObject).not.toHaveBeenCalled();
|
|
});
|
|
});
|