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'; 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', Buffer.from('fake image 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', Buffer.from('fake image 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', Buffer.from('fake image 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(downloaded.toString('utf-8')).toBe('fake image bytes'); }); 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); }); });