feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- Veterinary medicine catalogue, lots and append-only movement ledger (rest always computed; corrections as counter-bookings; inactive medicines take no new stock; unit must match on lot moves). - Documents of medicine lots, authority notices and restrictions need the domain right on top of documents:* (403 before any lookup). - Label particulars: pure checkLabel with six mandatory gaps and four warnings, AFM font metrics, Berlin-day dates, geometric reading order for split addresses, new master-data fields with row-locked updates. - Herd events ColonyCreated, ColonyMoved, ApiaryChanged and TskReferenceDayReached (ids only, never forwarded to webhooks); CSV import emits ColonyCreated with audit. - ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
8a31c42578
commit
c18860c75c
73 files changed
+7121
-309
No files matched your search
@@ -43,6 +43,7 @@ export class DocumentsController {
|
||||
findAll(
|
||||
@Query('entityType') entityType: string,
|
||||
@Query('entityId') entityId: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
if (!entityType || !entityId) {
|
||||
throw new BadRequestException('entityType and entityId are required');
|
||||
@@ -57,6 +58,7 @@ export class DocumentsController {
|
||||
return this.documentsService.findAll(
|
||||
entityType as DocumentEntityType,
|
||||
entityId,
|
||||
actor.id,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,9 +99,15 @@ export class DocumentsController {
|
||||
|
||||
@RequirePermission('documents:read')
|
||||
@Get(':id/download')
|
||||
async download(@Param('id') id: string, @Res() res: Response): Promise<void> {
|
||||
const { buffer, fileName, mimeType } =
|
||||
await this.documentsService.readFile(id);
|
||||
async download(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { buffer, fileName, mimeType } = await this.documentsService.readFile(
|
||||
id,
|
||||
actor.id,
|
||||
);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
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';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
|
||||
@@ -21,7 +27,11 @@ describe('DocumentsService', () => {
|
||||
queen: { findUnique: jest.Mock };
|
||||
inspection: { findUnique: jest.Mock };
|
||||
honeyBatch: { findUnique: jest.Mock };
|
||||
veterinaryMedicineLot: { findUnique: jest.Mock };
|
||||
authorityNotice: { findUnique: jest.Mock };
|
||||
apiaryRestriction: { findUnique: jest.Mock };
|
||||
};
|
||||
let permissions: { getPermissions: jest.Mock };
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
const document = {
|
||||
@@ -50,11 +60,18 @@ describe('DocumentsService', () => {
|
||||
queen: { findUnique: jest.fn() },
|
||||
inspection: { findUnique: jest.fn() },
|
||||
honeyBatch: { findUnique: jest.fn() },
|
||||
veterinaryMedicineLot: { findUnique: jest.fn() },
|
||||
authorityNotice: { findUnique: jest.fn() },
|
||||
apiaryRestriction: { findUnique: jest.fn() },
|
||||
};
|
||||
permissions = {
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set<string>()),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new DocumentsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissions as unknown as PermissionResolverService,
|
||||
);
|
||||
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
@@ -64,7 +81,7 @@ describe('DocumentsService', () => {
|
||||
|
||||
it('findAll strips storagePath from results', async () => {
|
||||
prisma.document.findMany.mockResolvedValueOnce([document]);
|
||||
const result = await service.findAll('COLONY', 'colony-1');
|
||||
const result = await service.findAll('COLONY', 'colony-1', 'user-1');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 'doc-1',
|
||||
@@ -143,14 +160,14 @@ describe('DocumentsService', () => {
|
||||
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,
|
||||
);
|
||||
await expect(
|
||||
service.readFile('missing', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('reads the stored file from disk', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(document);
|
||||
const result = await service.readFile('doc-1');
|
||||
const result = await service.readFile('doc-1', 'user-1');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(document.storagePath);
|
||||
expect(result.fileName).toBe('bild.jpg');
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
@@ -178,6 +195,21 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete file cleanup', () => {
|
||||
it('still deletes the row and warns when the file cannot be removed', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(document);
|
||||
(fs.unlink as jest.Mock).mockRejectedValueOnce(new Error('EPERM'));
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await expect(service.delete('doc-1', 'user-1')).resolves.toBeUndefined();
|
||||
expect(prisma.document.delete).toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('doc-1'));
|
||||
expect(auditLog.record).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const existing = {
|
||||
id: 'doc-1',
|
||||
@@ -236,4 +268,178 @@ describe('DocumentsService', () => {
|
||||
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-type permissions', () => {
|
||||
beforeEach(() => {
|
||||
// the fs module mock is shared between tests; start each with no calls
|
||||
jest.mocked(fs.writeFile).mockClear();
|
||||
jest.mocked(fs.readFile).mockClear();
|
||||
jest.mocked(fs.unlink).mockClear();
|
||||
});
|
||||
|
||||
const grant = (...keys: string[]) =>
|
||||
permissions.getPermissions.mockResolvedValue(new Set(keys));
|
||||
const lotDocument = {
|
||||
...document,
|
||||
entityType: 'MEDICINE_LOT',
|
||||
entityId: 'lot-1',
|
||||
};
|
||||
const png = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.from('x'),
|
||||
]);
|
||||
const pngFile = {
|
||||
buffer: png,
|
||||
originalname: 'b.png',
|
||||
mimetype: 'image/png',
|
||||
size: png.length,
|
||||
} as Express.Multer.File;
|
||||
|
||||
it('assertEntityExists finds a medicine lot, an authority notice and an apiary restriction and 404s a missing one', async () => {
|
||||
grant(
|
||||
'documents:upload',
|
||||
'veterinary-medicines:update',
|
||||
'compliance:manage',
|
||||
);
|
||||
prisma.document.create.mockResolvedValue(document);
|
||||
prisma.veterinaryMedicineLot.findUnique.mockResolvedValueOnce({
|
||||
id: 'l',
|
||||
});
|
||||
prisma.authorityNotice.findUnique.mockResolvedValueOnce({ id: 'n' });
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValueOnce({ id: 'r' });
|
||||
|
||||
await service.create(pngFile, 'MEDICINE_LOT', 'l', 'user-1');
|
||||
await service.create(pngFile, 'AUTHORITY_NOTICE', 'n', 'user-1');
|
||||
await service.create(pngFile, 'APIARY_RESTRICTION', 'r', 'user-1');
|
||||
expect(prisma.veterinaryMedicineLot.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'l' },
|
||||
});
|
||||
expect(prisma.authorityNotice.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'n' },
|
||||
});
|
||||
expect(prisma.apiaryRestriction.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'r' },
|
||||
});
|
||||
|
||||
for (const type of [
|
||||
'MEDICINE_LOT',
|
||||
'AUTHORITY_NOTICE',
|
||||
'APIARY_RESTRICTION',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.create(pngFile, type, 'missing', 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
}
|
||||
});
|
||||
|
||||
it('assertEntityAccess is a no-op for the old entity types', async () => {
|
||||
for (const type of [
|
||||
'APIARY',
|
||||
'COLONY',
|
||||
'QUEEN',
|
||||
'INSPECTION',
|
||||
'HONEY_BATCH',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.assertEntityAccess('user-1', type, 'read'),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
service.assertEntityAccess('user-1', type, 'write'),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
expect(permissions.getPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assertEntityAccess maps each new type to its read and write right', async () => {
|
||||
grant('veterinary-medicines:read', 'compliance:read');
|
||||
await service.assertEntityAccess('u', 'MEDICINE_LOT', 'read');
|
||||
await service.assertEntityAccess('u', 'AUTHORITY_NOTICE', 'read');
|
||||
await service.assertEntityAccess('u', 'APIARY_RESTRICTION', 'read');
|
||||
for (const type of [
|
||||
'MEDICINE_LOT',
|
||||
'AUTHORITY_NOTICE',
|
||||
'APIARY_RESTRICTION',
|
||||
] as const) {
|
||||
await expect(
|
||||
service.assertEntityAccess('u', type, 'write'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
}
|
||||
grant('veterinary-medicines:update', 'compliance:manage');
|
||||
await service.assertEntityAccess('u', 'MEDICINE_LOT', 'write');
|
||||
await service.assertEntityAccess('u', 'AUTHORITY_NOTICE', 'write');
|
||||
await service.assertEntityAccess('u', 'APIARY_RESTRICTION', 'write');
|
||||
grant();
|
||||
await expect(
|
||||
service.assertEntityAccess('u', 'MEDICINE_LOT', 'read'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('findAll denies a restricted type before any database query', async () => {
|
||||
grant('documents:read');
|
||||
await expect(
|
||||
service.findAll('MEDICINE_LOT', 'lot-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.document.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create denies a restricted type before any check or write', async () => {
|
||||
grant('documents:upload');
|
||||
await expect(
|
||||
service.create(pngFile, 'AUTHORITY_NOTICE', 'n', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.authorityNotice.findUnique).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('readFile denies before reading the file from disk', async () => {
|
||||
grant('documents:read');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(service.readFile('doc-1', 'user-1')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delete denies before touching the row or the file', async () => {
|
||||
grant('documents:delete', 'veterinary-medicines:read');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(service.delete('doc-1', 'user-1')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(prisma.document.delete).not.toHaveBeenCalled();
|
||||
expect(fs.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update checks the permission of the old and of the new entity type', async () => {
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValue({ id: 'r' });
|
||||
prisma.document.update.mockResolvedValue(document);
|
||||
const relink = {
|
||||
entityType: 'APIARY_RESTRICTION' as const,
|
||||
entityId: 'r',
|
||||
};
|
||||
|
||||
grant('veterinary-medicines:update'); // old allowed, new not
|
||||
await expect(
|
||||
service.update('doc-1', relink, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
grant('compliance:manage'); // new allowed, old not
|
||||
await expect(
|
||||
service.update('doc-1', relink, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prisma.document.update).not.toHaveBeenCalled();
|
||||
|
||||
grant('veterinary-medicines:update', 'compliance:manage');
|
||||
await service.update('doc-1', relink, 'user-1');
|
||||
expect(prisma.document.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update of a restricted document without the right is denied even for a rename', async () => {
|
||||
grant('documents:update');
|
||||
prisma.document.findUnique.mockResolvedValue(lotDocument);
|
||||
await expect(
|
||||
service.update('doc-1', { fileName: 'x.pdf' }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
@@ -9,12 +11,32 @@ import * as path from 'node:path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { Document, DocumentEntityType } from '../../generated/prisma';
|
||||
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
|
||||
import { UpdateDocumentDto } from './dto/update-document.dto';
|
||||
|
||||
export type DocumentSummary = Omit<Document, 'storagePath'>;
|
||||
|
||||
/**
|
||||
* Documents of these entity types are specialist records (purchase receipts,
|
||||
* authority notices, restriction orders): on top of the generic documents:*
|
||||
* right the caller needs the right of the record they belong to. Types
|
||||
* without an entry have no additional requirement.
|
||||
*/
|
||||
export const DOCUMENT_ENTITY_PERMISSIONS: Partial<
|
||||
Record<DocumentEntityType, { read: PermissionKey; write: PermissionKey }>
|
||||
> = {
|
||||
MEDICINE_LOT: {
|
||||
read: 'veterinary-medicines:read',
|
||||
write: 'veterinary-medicines:update',
|
||||
},
|
||||
AUTHORITY_NOTICE: { read: 'compliance:read', write: 'compliance:manage' },
|
||||
APIARY_RESTRICTION: { read: 'compliance:read', write: 'compliance:manage' },
|
||||
};
|
||||
|
||||
function storageRoot(): string {
|
||||
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
||||
}
|
||||
@@ -34,15 +56,36 @@ function toSummary(document: Document): DocumentSummary {
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
private readonly logger = new Logger(DocumentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
/** 403 unless the user holds the type's specialist right (see map above). */
|
||||
async assertEntityAccess(
|
||||
userId: string,
|
||||
entityType: DocumentEntityType,
|
||||
mode: 'read' | 'write',
|
||||
): Promise<void> {
|
||||
const required = DOCUMENT_ENTITY_PERMISSIONS[entityType]?.[mode];
|
||||
if (!required) {
|
||||
return;
|
||||
}
|
||||
const granted = await this.permissions.getPermissions(userId);
|
||||
if (!granted.has(required)) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(
|
||||
entityType: DocumentEntityType,
|
||||
entityId: string,
|
||||
actingUserId: string,
|
||||
): Promise<DocumentSummary[]> {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'read');
|
||||
const documents = await this.prisma.document.findMany({
|
||||
where: { entityType, entityId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -70,6 +113,18 @@ export class DocumentsService {
|
||||
return this.prisma.honeyBatch.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'MEDICINE_LOT':
|
||||
return this.prisma.veterinaryMedicineLot.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'AUTHORITY_NOTICE':
|
||||
return this.prisma.authorityNotice.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
case 'APIARY_RESTRICTION':
|
||||
return this.prisma.apiaryRestriction.findUnique({
|
||||
where: { id: entityId },
|
||||
});
|
||||
default:
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
@@ -85,6 +140,7 @@ export class DocumentsService {
|
||||
entityId: string,
|
||||
actingUserId: string,
|
||||
): Promise<DocumentSummary> {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'write');
|
||||
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
|
||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
||||
}
|
||||
@@ -125,6 +181,7 @@ export class DocumentsService {
|
||||
|
||||
async readFile(
|
||||
id: string,
|
||||
actingUserId: string,
|
||||
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
|
||||
const document = await this.prisma.document.findUnique({
|
||||
where: { id },
|
||||
@@ -132,6 +189,7 @@ export class DocumentsService {
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, document.entityType, 'read');
|
||||
const buffer = await fs.readFile(document.storagePath);
|
||||
return {
|
||||
buffer,
|
||||
@@ -154,8 +212,12 @@ export class DocumentsService {
|
||||
if (!before) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, before.entityType, 'write');
|
||||
const entityType = dto.entityType ?? before.entityType;
|
||||
const entityId = dto.entityId ?? before.entityId;
|
||||
if (entityType !== before.entityType) {
|
||||
await this.assertEntityAccess(actingUserId, entityType, 'write');
|
||||
}
|
||||
if (entityType !== before.entityType || entityId !== before.entityId) {
|
||||
await this.assertEntityExists(entityType, entityId);
|
||||
}
|
||||
@@ -181,8 +243,14 @@ export class DocumentsService {
|
||||
if (!document) {
|
||||
throw new NotFoundException('Document not found');
|
||||
}
|
||||
await this.assertEntityAccess(actingUserId, document.entityType, 'write');
|
||||
await this.prisma.document.delete({ where: { id } });
|
||||
await fs.unlink(document.storagePath).catch(() => undefined);
|
||||
await fs.unlink(document.storagePath).catch((error: unknown) => {
|
||||
// The row is gone; an orphaned (possibly restricted) file must be visible.
|
||||
this.logger.warn(
|
||||
`Could not remove the file of document ${id}: ${loggableError(error).message}`,
|
||||
);
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Document',
|
||||
entityId: id,
|
||||
|
||||
Reference in new issue
Block a user