feat(backend): Dokumente und Bilder mit lokalem Datei-Upload (Modul 29)

DocumentsModule (Document: entityType/entityId, Dateiname, MIME-Typ,
Groesse, Speicherpfad, Hochgeladen-von) fuer Bienenstand/Volk/Koenigin/
Durchsicht/Honigcharge. Upload als multipart/form-data (Multer, 10 MB
Limit, MIME-Allowlist jpeg/png/webp/pdf), Speicherung unter
DOCUMENTS_STORAGE_PATH (austauschbar). ARCHITECTURE.md Annahme 7
dokumentiert die Multi-Replica-Einschraenkung ohne geteiltes Volume.
This commit is contained in:
Nick Szaunig committed 2026-08-23 11:29:22 +02:00
1 parent e4d5af5fa7
commit 4a9fdd5c71
16 files changed
+728

No files matched your search

+1
View File
@@ -95,6 +95,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN.
5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung.
6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt.
7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder.
## 7. Kubernetes
+5
View File
@@ -9,3 +9,8 @@ JWT_REFRESH_EXPIRES_IN="7d"
PORT=3001
FRONTEND_ORIGIN="http://localhost:3000"
# Lokales Verzeichnis fuer hochgeladene Dokumente/Bilder (Modul 29).
# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes
# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7.
DOCUMENTS_STORAGE_PATH="./uploads"
+4
View File
@@ -14,3 +14,7 @@ prisma/seed.d.ts
# Local dev/test PostgreSQL data dir (not for production use)
.devdb/
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
/uploads/
/test-uploads/
+11
View File
@@ -40,6 +40,7 @@
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
@@ -3543,6 +3544,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+1
View File
@@ -52,6 +52,7 @@
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/pdfkit": "^0.17.6",
@@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "DocumentEntityType" AS ENUM ('APIARY', 'COLONY', 'QUEEN', 'INSPECTION', 'HONEY_BATCH');
-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL,
"entityType" "DocumentEntityType" NOT NULL,
"entityId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"storagePath" TEXT NOT NULL,
"uploadedByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Document_entityType_entityId_idx" ON "Document"("entityType", "entityId");
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+28
View File
@@ -53,6 +53,7 @@ model User {
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
@@index([role])
}
@@ -773,3 +774,30 @@ model WebhookSubscription {
@@index([active])
}
// ---------------------------------------------------------------------------
// Dokumente und Bilder (Abschnitt 4.28) – v1.x Modul 29
// ---------------------------------------------------------------------------
enum DocumentEntityType {
APIARY
COLONY
QUEEN
INSPECTION
HONEY_BATCH
}
model Document {
id String @id @default(cuid())
entityType DocumentEntityType
entityId String
fileName String
mimeType String
sizeBytes Int
storagePath String
uploadedByUserId String?
uploadedByUser User? @relation(fields: [uploadedByUserId], references: [id])
createdAt DateTime @default(now())
@@index([entityType, entityId])
}
+2
View File
@@ -29,6 +29,7 @@ import { CalendarModule } from './calendar/calendar.module';
import { NotificationsModule } from './notifications/notifications.module';
import { StatisticsModule } from './statistics/statistics.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { DocumentsModule } from './documents/documents.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@@ -63,6 +64,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
NotificationsModule,
StatisticsModule,
WebhooksModule,
DocumentsModule,
],
controllers: [],
providers: [
@@ -0,0 +1,10 @@
export const ALLOWED_DOCUMENT_MIME_TYPES: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'application/pdf': '.pdf',
};
export function isAllowedDocumentMimeType(mimeType: string): boolean {
return mimeType in ALLOWED_DOCUMENT_MIME_TYPES;
}
@@ -0,0 +1,111 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { DocumentEntityType, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { isAllowedDocumentMimeType } from './document-mime-types';
import { CreateDocumentDto } from './dto/create-document.dto';
import { DocumentsService } from './documents.service';
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
function sanitizeFilenameHeader(fileName: string): string {
return fileName.replace(/[\r\n"]/g, '_');
}
@ApiTags('documents')
@ApiBearerAuth()
@Controller('documents')
export class DocumentsController {
constructor(private readonly documentsService: DocumentsService) {}
@Get()
findAll(
@Query('entityType') entityType: string,
@Query('entityId') entityId: string,
) {
if (!entityType || !entityId) {
throw new BadRequestException('entityType and entityId are required');
}
if (
!Object.values(DocumentEntityType).includes(
entityType as DocumentEntityType,
)
) {
throw new BadRequestException('Invalid entityType');
}
return this.documentsService.findAll(
entityType as DocumentEntityType,
entityId,
);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE_BYTES },
fileFilter: (_req, file, callback) => {
if (!isAllowedDocumentMimeType(file.mimetype)) {
callback(
new BadRequestException('Nicht unterstützter Dateityp'),
false,
);
return;
}
callback(null, true);
},
}),
)
create(
@UploadedFile() file: Express.Multer.File | undefined,
@Body() dto: CreateDocumentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.documentsService.create(
file,
dto.entityType,
dto.entityId,
actor.id,
);
}
@Get(':id/download')
async download(@Param('id') id: string, @Res() res: Response): Promise<void> {
const { buffer, fileName, mimeType } =
await this.documentsService.readFile(id);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${sanitizeFilenameHeader(fileName)}"`,
);
res.send(buffer);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Delete(':id')
@HttpCode(204)
delete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.documentsService.delete(id, actor.id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
import { DocumentsService } from './documents.service';
@Module({
controllers: [DocumentsController],
providers: [DocumentsService],
})
export class DocumentsModule {}
@@ -0,0 +1,170 @@
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('binary'),
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('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' }),
);
});
});
});
@@ -0,0 +1,153 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Document, DocumentEntityType } from '../../generated/prisma';
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
export type DocumentSummary = Omit<Document, 'storagePath'>;
function storageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
}
function toSummary(document: Document): DocumentSummary {
return {
id: document.id,
entityType: document.entityType,
entityId: document.entityId,
fileName: document.fileName,
mimeType: document.mimeType,
sizeBytes: document.sizeBytes,
uploadedByUserId: document.uploadedByUserId,
createdAt: document.createdAt,
};
}
@Injectable()
export class DocumentsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(
entityType: DocumentEntityType,
entityId: string,
): Promise<DocumentSummary[]> {
const documents = await this.prisma.document.findMany({
where: { entityType, entityId },
orderBy: { createdAt: 'desc' },
});
return documents.map(toSummary);
}
private async assertEntityExists(
entityType: DocumentEntityType,
entityId: string,
): Promise<void> {
const exists = await (() => {
switch (entityType) {
case 'APIARY':
return this.prisma.apiary.findUnique({ where: { id: entityId } });
case 'COLONY':
return this.prisma.colony.findUnique({ where: { id: entityId } });
case 'QUEEN':
return this.prisma.queen.findUnique({ where: { id: entityId } });
case 'INSPECTION':
return this.prisma.inspection.findUnique({
where: { id: entityId },
});
case 'HONEY_BATCH':
return this.prisma.honeyBatch.findUnique({
where: { id: entityId },
});
default:
return Promise.resolve(null);
}
})();
if (!exists) {
throw new NotFoundException(`${entityType} ${entityId} not found`);
}
}
async create(
file: Express.Multer.File,
entityType: DocumentEntityType,
entityId: string,
actingUserId: string,
): Promise<DocumentSummary> {
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
throw new BadRequestException('Nicht unterstützter Dateityp');
}
await this.assertEntityExists(entityType, entityId);
const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype];
const storageFileName = `${crypto.randomUUID()}${extension}`;
const directory = path.join(storageRoot(), entityType.toLowerCase());
await fs.mkdir(directory, { recursive: true });
const storagePath = path.join(directory, storageFileName);
await fs.writeFile(storagePath, file.buffer);
const document = await this.prisma.document.create({
data: {
entityType,
entityId,
fileName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
storagePath,
uploadedByUserId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'Document',
entityId: document.id,
action: 'CREATE',
userId: actingUserId,
afterData: toSummary(document),
});
return toSummary(document);
}
async readFile(
id: string,
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
const document = await this.prisma.document.findUnique({
where: { id },
});
if (!document) {
throw new NotFoundException('Document not found');
}
const buffer = await fs.readFile(document.storagePath);
return {
buffer,
fileName: document.fileName,
mimeType: document.mimeType,
};
}
async delete(id: string, actingUserId: string): Promise<void> {
const document = await this.prisma.document.findUnique({
where: { id },
});
if (!document) {
throw new NotFoundException('Document not found');
}
await this.prisma.document.delete({ where: { id } });
await fs.unlink(document.storagePath).catch(() => undefined);
await this.auditLog.record({
entityType: 'Document',
entityId: id,
action: 'DELETE',
userId: actingUserId,
beforeData: toSummary(document),
});
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { DocumentEntityType } from '../../../generated/prisma';
export class CreateDocumentDto {
@ApiProperty({ enum: DocumentEntityType })
@IsEnum(DocumentEntityType)
entityType!: DocumentEntityType;
@ApiProperty()
@IsString()
@IsNotEmpty()
entityId!: string;
}
+185
View File
@@ -0,0 +1,185 @@
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<App>;
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: 'BEEKEEPER',
},
});
beekeeperId = beekeeper.id;
const readonly = await prisma.user.create({
data: {
email: readonlyEmail,
passwordHash,
name: 'Documents E2E Readonly',
role: '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);
});
});
@@ -7,3 +7,4 @@ data:
JWT_ACCESS_EXPIRES_IN: "15m"
JWT_REFRESH_EXPIRES_IN: "7d"
FRONTEND_ORIGIN: "https://apicore.example.com"
DOCUMENTS_STORAGE_PATH: "/data/documents"