import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import * as fs from 'node:fs/promises'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; // The upload checks the file's magic bytes against its declared type, so // the fixture must start with a real PNG signature. const PNG_BYTES = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from('test image payload'), ]); interface DocumentBody { id: string; entityType: string; entityId: string; fileName: string; mimeType: string; sizeBytes: number; storagePath?: string; } describe('Documents (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const beekeeperEmail = `documents-e2e-beekeeper-${Date.now()}@example.com`; const readonlyEmail = `documents-e2e-readonly-${Date.now()}@example.com`; const password = 'SuperSecret123!'; let beekeeperId: string; let readonlyId: string; let beekeeperToken: string; let readonlyToken: string; let apiaryId: string; let colonyId: string; let documentId: string; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const beekeeper = await prisma.user.create({ data: { email: beekeeperEmail, passwordHash, name: 'Documents E2E Beekeeper', role: { connect: { key: 'BEEKEEPER' } }, }, }); beekeeperId = beekeeper.id; const readonly = await prisma.user.create({ data: { email: readonlyEmail, passwordHash, name: 'Documents E2E Readonly', role: { connect: { key: 'READONLY' } }, }, }); readonlyId = readonly.id; beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; readonlyToken = (await login(app, readonlyEmail, password)).accessToken; const apiary = await prisma.apiary.create({ data: { name: `Dokumente-Stand ${Date.now()}` }, }); apiaryId = apiary.id; const colony = await prisma.colony.create({ data: { code: `V-DOC-${Date.now()}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyId = colony.id; }); afterAll(async () => { await prisma.document.deleteMany({ where: { entityType: 'COLONY', entityId: colonyId }, }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: [beekeeperId, readonlyId] } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: [beekeeperId, readonlyId] } }, }); await prisma.user.deleteMany({ where: { id: { in: [beekeeperId, readonlyId] } }, }); await fs.rm('./test-uploads', { recursive: true, force: true }); await app.close(); }); it('rejects an upload from a role without write access', () => { return request(app.getHttpServer()) .post('/api/v1/documents') .set('Authorization', `Bearer ${readonlyToken}`) .field('entityType', 'COLONY') .field('entityId', colonyId) .attach('file', PNG_BYTES, { filename: 'bild.png', contentType: 'image/png', }) .expect(403); }); it('rejects an unsupported mime type', () => { return request(app.getHttpServer()) .post('/api/v1/documents') .set('Authorization', `Bearer ${beekeeperToken}`) .field('entityType', 'COLONY') .field('entityId', colonyId) .attach('file', Buffer.from('not an image'), { filename: 'script.exe', contentType: 'application/octet-stream', }) .expect(400); }); it('rejects an upload for a non-existent entity', () => { return request(app.getHttpServer()) .post('/api/v1/documents') .set('Authorization', `Bearer ${beekeeperToken}`) .field('entityType', 'COLONY') .field('entityId', 'does-not-exist') .attach('file', PNG_BYTES, { filename: 'bild.png', contentType: 'image/png', }) .expect(404); }); it('lets a beekeeper upload a document and omits storagePath from the response', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/documents') .set('Authorization', `Bearer ${beekeeperToken}`) .field('entityType', 'COLONY') .field('entityId', colonyId) .attach('file', PNG_BYTES, { filename: 'bild.png', contentType: 'image/png', }) .expect(201); const body = response.body as DocumentBody; documentId = body.id; expect(body.entityType).toBe('COLONY'); expect(body.entityId).toBe(colonyId); expect(body.fileName).toBe('bild.png'); expect(body.mimeType).toBe('image/png'); expect(body.storagePath).toBeUndefined(); }); it('lists documents for the entity', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/documents?entityType=COLONY&entityId=${colonyId}`) .set('Authorization', `Bearer ${readonlyToken}`) .expect(200); const body = response.body as DocumentBody[]; expect(body.some((entry) => entry.id === documentId)).toBe(true); }); it('downloads the uploaded file with matching content and headers', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/documents/${documentId}/download`) .set('Authorization', `Bearer ${readonlyToken}`) .expect(200); expect(response.headers['content-type']).toContain('image/png'); expect(response.headers['content-disposition']).toContain('bild.png'); const downloaded = response.body as Buffer; expect(Buffer.compare(downloaded, PNG_BYTES)).toBe(0); }); it('rejects a delete from a role without write access', () => { return request(app.getHttpServer()) .delete(`/api/v1/documents/${documentId}`) .set('Authorization', `Bearer ${readonlyToken}`) .expect(403); }); it('lets a beekeeper delete the document', () => { return request(app.getHttpServer()) .delete(`/api/v1/documents/${documentId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .expect(204); }); it('replays an upload with the same Idempotency-Key', async () => { const key = `idem-documents-${Date.now()}-upload-0001`; const upload = () => request(app.getHttpServer()) .post('/api/v1/documents') .set('Authorization', `Bearer ${beekeeperToken}`) .set('Idempotency-Key', key) .field('entityType', 'COLONY') .field('entityId', colonyId) .attach('file', PNG_BYTES, { filename: 'idempotent.png', contentType: 'image/png', }) .expect(201); const before = await prisma.document.count({ where: { entityType: 'COLONY', entityId: colonyId }, }); const first = await upload(); const second = await upload(); expect((second.body as DocumentBody).id).toBe( (first.body as DocumentBody).id, ); expect( await prisma.document.count({ where: { entityType: 'COLONY', entityId: colonyId }, }), ).toBe(before + 1); }); }); describe('Documents of specialist records (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}`; const password = 'SuperSecret123!'; const roleIds: string[] = []; const userIds: string[] = []; let apiaryId: string; let medicineId: string; let lotId: string; let noticeId: string; let restrictionId: string; let docsOnlyToken: string; let docsAndManageToken: string; let staffToken: string; let beekeeperToken: string; let lotDocumentId: string; let noticeDocumentId: string; let restrictionDocumentId: string; const api = () => request(app.getHttpServer()); const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); async function createUser( label: string, options: { permissions?: string[]; roleKey?: string }, ): Promise { let roleId: string; if (options.roleKey) { roleId = ( await prisma.role.findUniqueOrThrow({ where: { key: options.roleKey } }) ).id; } else { const role = await prisma.role.create({ data: { key: `DOC_SPECIAL_${label.toUpperCase()}_${suffix}`, name: `Documents special ${label}`, permissions: { create: (options.permissions ?? []).map((key) => ({ permission: { connect: { key } }, })), }, }, }); roleIds.push(role.id); roleId = role.id; } const email = `doc-special-${label}-${suffix}@example.com`; const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: `Documents special ${label}`, role: { connect: { id: roleId } }, }, }); userIds.push(user.id); return (await login(app, email, password)).accessToken; } const upload = ( token: string, entityType: string, entityId: string, fileName = 'beleg.png', ) => api() .post('/api/v1/documents') .set(auth(token)) .field('entityType', entityType) .field('entityId', entityId) .attach('file', PNG_BYTES, { filename: fileName, contentType: 'image/png', }); beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const crud = [ 'documents:read', 'documents:upload', 'documents:update', 'documents:delete', ]; docsOnlyToken = await createUser('docsonly', { permissions: crud }); docsAndManageToken = await createUser('docsmanage', { permissions: [...crud, 'compliance:read', 'compliance:manage'], }); staffToken = await createUser('staff', { roleKey: 'STAFF' }); beekeeperToken = await createUser('beekeeper', { roleKey: 'BEEKEEPER' }); const apiary = await prisma.apiary.create({ data: { name: `Dok-Spezial-Stand ${suffix}` }, }); apiaryId = apiary.id; const medicine = await prisma.veterinaryMedicine.create({ data: { name: `Dok-Mittel ${suffix}`, unit: 'ML' }, }); medicineId = medicine.id; const lot = await prisma.veterinaryMedicineLot.create({ data: { medicineId, purchasedOn: new Date('2026-05-01'), quantity: 10 }, }); lotId = lot.id; const notice = await prisma.authorityNotice.create({ data: { kind: 'REGISTRATION', referenceDate: new Date('2026-05-01'), countedAt: new Date('2026-05-01'), snapshot: {}, totalColonies: 0, }, }); noticeId = notice.id; const restriction = await prisma.apiaryRestriction.create({ data: { apiaryId, disease: 'Test', startsOn: new Date('2026-05-01'), }, }); restrictionId = restriction.id; }); afterAll(async () => { await prisma.document.deleteMany({ where: { entityId: { in: [lotId, noticeId, restrictionId] } }, }); await prisma.apiaryRestriction.deleteMany({ where: { id: restrictionId } }); await prisma.authorityNotice.deleteMany({ where: { id: noticeId } }); await prisma.veterinaryMedicineLot.deleteMany({ where: { id: lotId } }); await prisma.veterinaryMedicine.deleteMany({ where: { id: medicineId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); await fs.rm('./test-uploads', { recursive: true, force: true }); await app.close(); }); it('STAFF uploads a purchase receipt to a lot and downloads it again', async () => { const response = await upload(staffToken, 'MEDICINE_LOT', lotId).expect( 201, ); lotDocumentId = (response.body as DocumentBody).id; const list = await api() .get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`) .set(auth(staffToken)) .expect(200); expect( (list.body as DocumentBody[]).some((entry) => entry.id === lotDocumentId), ).toBe(true); const download = await api() .get(`/api/v1/documents/${lotDocumentId}/download`) .set(auth(staffToken)) .expect(200); expect(Buffer.compare(download.body as Buffer, PNG_BYTES)).toBe(0); expect(download.headers['cache-control']).toBe('no-store'); expect(list.headers['cache-control']).toBe('no-store'); }); it('a user with documents:read but without veterinary-medicines:read gets 403 listing and downloading a MEDICINE_LOT document', async () => { await api() .get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`) .set(auth(docsOnlyToken)) .expect(403); await api() .get(`/api/v1/documents/${lotDocumentId}/download`) .set(auth(docsOnlyToken)) .expect(403); }); it('a user with documents:read but without compliance:read gets 403 listing AUTHORITY_NOTICE and APIARY_RESTRICTION documents', async () => { await api() .get(`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`) .set(auth(docsOnlyToken)) .expect(403); await api() .get( `/api/v1/documents?entityType=APIARY_RESTRICTION&entityId=${restrictionId}`, ) .set(auth(docsOnlyToken)) .expect(403); }); it('a user with documents:upload but without compliance:manage gets 403 uploading to an AUTHORITY_NOTICE', async () => { const before = await prisma.document.count({ where: { entityId: noticeId }, }); await upload(docsOnlyToken, 'AUTHORITY_NOTICE', noticeId).expect(403); await upload(docsOnlyToken, 'APIARY_RESTRICTION', restrictionId).expect( 403, ); // STAFF has no compliance:manage either await upload(staffToken, 'AUTHORITY_NOTICE', noticeId).expect(403); expect(await prisma.document.count({ where: { entityId: noticeId } })).toBe( before, ); }); it('a user with compliance:manage uploads, lists and downloads authority documents', async () => { const notice = await upload( docsAndManageToken, 'AUTHORITY_NOTICE', noticeId, ).expect(201); noticeDocumentId = (notice.body as DocumentBody).id; const restriction = await upload( docsAndManageToken, 'APIARY_RESTRICTION', restrictionId, ).expect(201); restrictionDocumentId = (restriction.body as DocumentBody).id; await api() .get(`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`) .set(auth(docsAndManageToken)) .expect(200); await api() .get(`/api/v1/documents/${noticeDocumentId}/download`) .set(auth(docsAndManageToken)) .expect(200); // documents:read alone must not reach it await api() .get(`/api/v1/documents/${restrictionDocumentId}/download`) .set(auth(docsOnlyToken)) .expect(403); }); it('re-linking a document to an APIARY_RESTRICTION needs compliance:manage', async () => { // a document on a harmless record, owned by the docs-only user const apiaryDoc = await upload(docsOnlyToken, 'APIARY', apiaryId).expect( 201, ); const id = (apiaryDoc.body as DocumentBody).id; await api() .patch(`/api/v1/documents/${id}`) .set(auth(docsOnlyToken)) .send({ entityType: 'APIARY_RESTRICTION', entityId: restrictionId }) .expect(403); expect( (await prisma.document.findUniqueOrThrow({ where: { id } })).entityType, ).toBe('APIARY'); await api() .patch(`/api/v1/documents/${id}`) .set(auth(docsAndManageToken)) .send({ entityType: 'APIARY_RESTRICTION', entityId: restrictionId }) .expect(200); // moving it back out needs the right of the OLD type too await api() .patch(`/api/v1/documents/${id}`) .set(auth(docsOnlyToken)) .send({ entityType: 'APIARY', entityId: apiaryId }) .expect(403); await api() .delete(`/api/v1/documents/${id}`) .set(auth(docsAndManageToken)) .expect(204); }); it('renaming a restricted document without the right is denied', () => { return api() .patch(`/api/v1/documents/${lotDocumentId}`) .set(auth(docsOnlyToken)) .send({ fileName: 'neu.png' }) .expect(403); }); it('deleting a MEDICINE_LOT document needs veterinary-medicines:update', async () => { await api() .delete(`/api/v1/documents/${lotDocumentId}`) .set(auth(docsOnlyToken)) .expect(403); expect(await prisma.document.count({ where: { id: lotDocumentId } })).toBe( 1, ); await api() .delete(`/api/v1/documents/${lotDocumentId}`) .set(auth(beekeeperToken)) .expect(204); }); it('answers 403, not 404, for an unauthorised upload to a missing MEDICINE_LOT record', async () => { await upload(docsOnlyToken, 'MEDICINE_LOT', 'nope').expect(403); }); describe('read/write split over HTTP', () => { async function userWith(label: string, permissions: string[]) { return createUser(label, { permissions }); } it('compliance:read without compliance:manage lists (200) but cannot upload (403)', async () => { const token = await userWith('comp-read', [ 'documents:read', 'documents:upload', 'compliance:read', ]); await api() .get( `/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`, ) .set(auth(token)) .expect(200); await upload(token, 'AUTHORITY_NOTICE', noticeId).expect(403); await upload(token, 'APIARY_RESTRICTION', restrictionId).expect(403); }); it('veterinary-medicines:read without :update lists and downloads (200) but cannot delete or patch (403)', async () => { const token = await userWith('med-read', [ 'documents:read', 'documents:update', 'documents:delete', 'veterinary-medicines:read', ]); const created = await upload( beekeeperToken, 'MEDICINE_LOT', lotId, ).expect(201); const id = (created.body as DocumentBody).id; await api() .get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`) .set(auth(token)) .expect(200); await api() .get(`/api/v1/documents/${id}/download`) .set(auth(token)) .expect(200); await api() .delete(`/api/v1/documents/${id}`) .set(auth(token)) .expect(403); await api() .patch(`/api/v1/documents/${id}`) .set(auth(token)) .send({ fileName: 'x.png' }) .expect(403); expect(await prisma.document.count({ where: { id } })).toBe(1); }); it('compliance:manage without compliance:read uploads (201) but cannot list (403)', async () => { const token = await userWith('comp-manage', [ 'documents:read', 'documents:upload', 'compliance:manage', ]); await upload(token, 'AUTHORITY_NOTICE', noticeId).expect(201); await api() .get( `/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`, ) .set(auth(token)) .expect(403); }); }); it('answers 404 for a missing record of the new types when the user has the right', async () => { await upload(staffToken, 'MEDICINE_LOT', 'does-not-exist').expect(404); await upload(docsAndManageToken, 'AUTHORITY_NOTICE', 'nope').expect(404); await upload(docsAndManageToken, 'APIARY_RESTRICTION', 'nope').expect(404); }); it('uploads keep the MIME allowlist and signature check for the new types', async () => { await api() .post('/api/v1/documents') .set(auth(staffToken)) .field('entityType', 'MEDICINE_LOT') .field('entityId', lotId) .attach('file', Buffer.from('MZ not allowed'), { filename: 'x.exe', contentType: 'application/octet-stream', }) .expect(400); await api() .post('/api/v1/documents') .set(auth(staffToken)) .field('entityType', 'MEDICINE_LOT') .field('entityId', lotId) .attach('file', Buffer.from('plain text, not a png'), { filename: 'fake.png', contentType: 'image/png', }) .expect(400); }); });