Weitere vom Sicherheitsaudit gemeldete Luecken behoben, die bisher als niedrige Prioritaet offen gelassen wurden: - SSO-Login (Entra ID) prüft jetzt lokale MFA genauso wie der Passwort-Login: ist mfaEnabled gesetzt, liefert /auth/sso/complete denselben mfaRequired-Codepfad wie /auth/login, bevor Tokens ausgestellt werden. Frontend (Login-Seite, AuthContext) zeigt in dem Fall den bestehenden Code-Eingabe-Schritt statt die Anmeldung stillschweigend abzuschliessen. - Avatar- und Dokument-Uploads pruefen jetzt die tatsaechlichen Datei-Bytes (Magic Numbers fuer PNG/JPEG/WEBP/PDF) statt nur dem client-angegebenen MIME-Typ/data:-URL-Praefix zu vertrauen. - Swagger-UI (/api/v1/docs) ist jetzt standardmaessig deaktiviert (ENABLE_SWAGGER=true erforderlich), da sie zuvor das komplette API-Schema ungeschuetzt oeffentlich preisgab. npm-audit-Warnungen (deepmerge-ts/mysql2 unter prisma) bewusst nicht gefixt: kein stabiles Prisma-Release loest sie bislang auf, nur eine Release-Candidate-Hauptversion - das Risiko eines Downgrades/RC-Upgrades der Datenbankschicht uebersteigt den Nutzen fuer eine Kette, die zur Laufzeit ohnehin nicht erreichbar ist (nur Prisma-CLI, nur MySQL-Treiber, dieses Projekt nutzt Postgres). Co-Authored-By: Claude Sonnet 5 <[email protected]>
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import * as fs from 'node:fs/promises';
|
|
import { DocumentsService } from './documents.service';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
|
|
jest.mock('node:fs/promises');
|
|
|
|
describe('DocumentsService', () => {
|
|
let service: DocumentsService;
|
|
let prisma: {
|
|
document: {
|
|
findMany: jest.Mock;
|
|
findUnique: jest.Mock;
|
|
create: jest.Mock;
|
|
delete: jest.Mock;
|
|
};
|
|
apiary: { findUnique: jest.Mock };
|
|
colony: { findUnique: jest.Mock };
|
|
queen: { findUnique: jest.Mock };
|
|
inspection: { findUnique: jest.Mock };
|
|
honeyBatch: { findUnique: jest.Mock };
|
|
};
|
|
let auditLog: { record: jest.Mock };
|
|
|
|
const document = {
|
|
id: 'doc-1',
|
|
entityType: 'COLONY',
|
|
entityId: 'colony-1',
|
|
fileName: 'bild.jpg',
|
|
mimeType: 'image/jpeg',
|
|
sizeBytes: 1234,
|
|
storagePath: '/tmp/uploads/colony/abc.jpg',
|
|
uploadedByUserId: 'user-1',
|
|
createdAt: new Date('2026-01-01'),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
prisma = {
|
|
document: {
|
|
findMany: jest.fn().mockResolvedValue([]),
|
|
findUnique: jest.fn(),
|
|
create: jest.fn(),
|
|
delete: jest.fn(),
|
|
},
|
|
apiary: { findUnique: jest.fn() },
|
|
colony: { findUnique: jest.fn() },
|
|
queen: { findUnique: jest.fn() },
|
|
inspection: { findUnique: jest.fn() },
|
|
honeyBatch: { findUnique: jest.fn() },
|
|
};
|
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
|
service = new DocumentsService(
|
|
prisma as unknown as PrismaService,
|
|
auditLog as unknown as AuditLogService,
|
|
);
|
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
|
(fs.readFile as jest.Mock).mockResolvedValue(Buffer.from('data'));
|
|
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('findAll strips storagePath from results', async () => {
|
|
prisma.document.findMany.mockResolvedValueOnce([document]);
|
|
const result = await service.findAll('COLONY', 'colony-1');
|
|
expect(result).toEqual([
|
|
{
|
|
id: 'doc-1',
|
|
entityType: 'COLONY',
|
|
entityId: 'colony-1',
|
|
fileName: 'bild.jpg',
|
|
mimeType: 'image/jpeg',
|
|
sizeBytes: 1234,
|
|
uploadedByUserId: 'user-1',
|
|
createdAt: document.createdAt,
|
|
},
|
|
]);
|
|
});
|
|
|
|
describe('create', () => {
|
|
const file = {
|
|
buffer: Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x01, 0x02]),
|
|
originalname: 'bild.jpg',
|
|
mimetype: 'image/jpeg',
|
|
size: 1234,
|
|
} as Express.Multer.File;
|
|
|
|
it('rejects an unsupported mime type', async () => {
|
|
const badFile = {
|
|
...file,
|
|
mimetype: 'application/zip',
|
|
};
|
|
await expect(
|
|
service.create(badFile, 'COLONY', 'colony-1', 'user-1'),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('rejects a file whose bytes do not match the declared mime type', async () => {
|
|
const mislabeled = { ...file, buffer: Buffer.from('not-a-jpeg') };
|
|
await expect(
|
|
service.create(mislabeled, 'COLONY', 'colony-1', 'user-1'),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(fs.writeFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws NotFoundException when the referenced entity does not exist', async () => {
|
|
prisma.colony.findUnique.mockResolvedValue(null);
|
|
await expect(
|
|
service.create(file, 'COLONY', 'missing', 'user-1'),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('writes the file to disk and persists a document row', async () => {
|
|
prisma.colony.findUnique.mockResolvedValue({ id: 'colony-1' });
|
|
prisma.document.create.mockResolvedValue(document);
|
|
|
|
const result = await service.create(file, 'COLONY', 'colony-1', 'user-1');
|
|
|
|
expect(fs.mkdir).toHaveBeenCalled();
|
|
expect(fs.writeFile).toHaveBeenCalled();
|
|
const [[createArg]] = prisma.document.create.mock.calls as [
|
|
[
|
|
{
|
|
data: {
|
|
entityType: string;
|
|
entityId: string;
|
|
fileName: string;
|
|
uploadedByUserId: string;
|
|
};
|
|
},
|
|
],
|
|
];
|
|
expect(createArg.data.entityType).toBe('COLONY');
|
|
expect(createArg.data.entityId).toBe('colony-1');
|
|
expect(createArg.data.fileName).toBe('bild.jpg');
|
|
expect(createArg.data.uploadedByUserId).toBe('user-1');
|
|
expect(result).not.toHaveProperty('storagePath');
|
|
});
|
|
});
|
|
|
|
describe('readFile', () => {
|
|
it('throws NotFoundException when the document does not exist', async () => {
|
|
prisma.document.findUnique.mockResolvedValue(null);
|
|
await expect(service.readFile('missing')).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('reads the stored file from disk', async () => {
|
|
prisma.document.findUnique.mockResolvedValue(document);
|
|
const result = await service.readFile('doc-1');
|
|
expect(fs.readFile).toHaveBeenCalledWith(document.storagePath);
|
|
expect(result.fileName).toBe('bild.jpg');
|
|
expect(result.mimeType).toBe('image/jpeg');
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('throws NotFoundException when the document does not exist', async () => {
|
|
prisma.document.findUnique.mockResolvedValue(null);
|
|
await expect(service.delete('missing', 'user-1')).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('deletes the db row and unlinks the file', async () => {
|
|
prisma.document.findUnique.mockResolvedValue(document);
|
|
await service.delete('doc-1', 'user-1');
|
|
expect(prisma.document.delete).toHaveBeenCalledWith({
|
|
where: { id: 'doc-1' },
|
|
});
|
|
expect(fs.unlink).toHaveBeenCalledWith(document.storagePath);
|
|
expect(auditLog.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({ entityType: 'Document', action: 'DELETE' }),
|
|
);
|
|
});
|
|
});
|
|
});
|