From 8a6769dbfd6fbe19f8e29ef2fcddccfb0f091e54 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 06:00:52 +0200 Subject: [PATCH] feat(backend): Audit-Log-Abfrage (Modul 19) AuditModule um GET /audit-log?entityType=&entityId= erweitert (nur Administrator). Zusaetzlich Jest-E2E-Parallelitaet begrenzt (maxWorkers: 4), um wiederkehrende Verbindungsengpaesse gegen Postgres unter vielen parallelen Testsuiten zu vermeiden. --- .../src/audit/audit-log.service.spec.ts | 55 +++++++++++ .../src/audit/audit-log.service.ts | 15 ++- ApiCore-Backend/src/audit/audit.controller.ts | 21 ++++ ApiCore-Backend/src/audit/audit.module.ts | 2 + ApiCore-Backend/test/audit-log.e2e-spec.ts | 95 +++++++++++++++++++ ApiCore-Backend/test/jest-e2e.json | 1 + TASKS.md | 2 +- 7 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 ApiCore-Backend/src/audit/audit-log.service.spec.ts create mode 100644 ApiCore-Backend/src/audit/audit.controller.ts create mode 100644 ApiCore-Backend/test/audit-log.e2e-spec.ts diff --git a/ApiCore-Backend/src/audit/audit-log.service.spec.ts b/ApiCore-Backend/src/audit/audit-log.service.spec.ts new file mode 100644 index 0000000..d0e4404 --- /dev/null +++ b/ApiCore-Backend/src/audit/audit-log.service.spec.ts @@ -0,0 +1,55 @@ +import { AuditLogService } from './audit-log.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('AuditLogService', () => { + let service: AuditLogService; + let prisma: { + auditLog: { create: jest.Mock; findMany: jest.Mock }; + }; + + beforeEach(() => { + prisma = { + auditLog: { + create: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), + }, + }; + service = new AuditLogService(prisma as unknown as PrismaService); + }); + + it('record writes the entity type, action, and actor', async () => { + await service.record({ + entityType: 'Colony', + entityId: 'colony-1', + action: 'CREATE', + userId: 'user-1', + }); + + const [[createArg]] = prisma.auditLog.create.mock.calls as [ + [ + { + data: { + entityType: string; + entityId: string; + action: string; + userId?: string; + }; + }, + ], + ]; + expect(createArg.data.entityType).toBe('Colony'); + expect(createArg.data.entityId).toBe('colony-1'); + expect(createArg.data.action).toBe('CREATE'); + expect(createArg.data.userId).toBe('user-1'); + }); + + it('findAll filters by entity type and id', async () => { + await service.findAll({ entityType: 'Colony', entityId: 'colony-1' }); + + const [[queryArg]] = prisma.auditLog.findMany.mock.calls as [ + [{ where: { entityType?: string; entityId?: string } }], + ]; + expect(queryArg.where.entityType).toBe('Colony'); + expect(queryArg.where.entityId).toBe('colony-1'); + }); +}); diff --git a/ApiCore-Backend/src/audit/audit-log.service.ts b/ApiCore-Backend/src/audit/audit-log.service.ts index d3fcdf2..a838ce2 100644 --- a/ApiCore-Backend/src/audit/audit-log.service.ts +++ b/ApiCore-Backend/src/audit/audit-log.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { AuditAction, Prisma } from '../../generated/prisma'; +import { AuditAction, AuditLog, Prisma } from '../../generated/prisma'; interface RecordAuditLogInput { entityType: string; @@ -11,6 +11,11 @@ interface RecordAuditLogInput { afterData?: unknown; } +export interface AuditLogFilter { + entityType?: string; + entityId?: string; +} + @Injectable() export class AuditLogService { constructor(private readonly prisma: PrismaService) {} @@ -27,4 +32,12 @@ export class AuditLogService { }, }); } + + async findAll(filter: AuditLogFilter = {}): Promise { + return this.prisma.auditLog.findMany({ + where: { entityType: filter.entityType, entityId: filter.entityId }, + orderBy: { timestamp: 'desc' }, + take: 200, + }); + } } diff --git a/ApiCore-Backend/src/audit/audit.controller.ts b/ApiCore-Backend/src/audit/audit.controller.ts new file mode 100644 index 0000000..c6d405f --- /dev/null +++ b/ApiCore-Backend/src/audit/audit.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Role } from '../../generated/prisma'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { AuditLogService } from './audit-log.service'; + +@ApiTags('audit-log') +@ApiBearerAuth() +@Controller('audit-log') +export class AuditController { + constructor(private readonly auditLogService: AuditLogService) {} + + @Roles(Role.ADMIN) + @Get() + findAll( + @Query('entityType') entityType?: string, + @Query('entityId') entityId?: string, + ) { + return this.auditLogService.findAll({ entityType, entityId }); + } +} diff --git a/ApiCore-Backend/src/audit/audit.module.ts b/ApiCore-Backend/src/audit/audit.module.ts index a91777c..4d50394 100644 --- a/ApiCore-Backend/src/audit/audit.module.ts +++ b/ApiCore-Backend/src/audit/audit.module.ts @@ -1,8 +1,10 @@ import { Global, Module } from '@nestjs/common'; +import { AuditController } from './audit.controller'; import { AuditLogService } from './audit-log.service'; @Global() @Module({ + controllers: [AuditController], providers: [AuditLogService], exports: [AuditLogService], }) diff --git a/ApiCore-Backend/test/audit-log.e2e-spec.ts b/ApiCore-Backend/test/audit-log.e2e-spec.ts new file mode 100644 index 0000000..e34cbc4 --- /dev/null +++ b/ApiCore-Backend/test/audit-log.e2e-spec.ts @@ -0,0 +1,95 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +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 AuditLogEntryBody { + entityType: string; + entityId: string; + action: string; +} + +describe('Audit log (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const adminEmail = `audit-e2e-admin-${Date.now()}@example.com`; + const beekeeperEmail = `audit-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let adminId: string; + let beekeeperId: string; + let adminToken: string; + let beekeeperToken: string; + let apiaryId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const admin = await prisma.user.create({ + data: { email: adminEmail, passwordHash, name: 'Admin', role: 'ADMIN' }, + }); + adminId = admin.id; + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + adminToken = (await login(app, adminEmail, password)).accessToken; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + }); + + afterAll(async () => { + if (apiaryId) { + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + } + await prisma.auditLog.deleteMany({ + where: { userId: { in: [adminId, beekeeperId] } }, + }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [adminId, beekeeperId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [adminId, beekeeperId] } }, + }); + await app.close(); + }); + + it('rejects the query for a non-admin user', () => { + return request(app.getHttpServer()) + .get('/api/v1/audit-log') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(403); + }); + + it('lets an ADMIN query the audit log for a created apiary', async () => { + const createResponse = await request(app.getHttpServer()) + .post('/api/v1/apiaries') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ name: `Stand ${Date.now()}` }) + .expect(201); + const apiary = createResponse.body as { id: string }; + apiaryId = apiary.id; + + const response = await request(app.getHttpServer()) + .get(`/api/v1/audit-log?entityType=Apiary&entityId=${apiaryId}`) + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + const entries = response.body as AuditLogEntryBody[]; + expect( + entries.some( + (entry) => + entry.entityType === 'Apiary' && + entry.entityId === apiaryId && + entry.action === 'CREATE', + ), + ).toBe(true); + }); +}); diff --git a/ApiCore-Backend/test/jest-e2e.json b/ApiCore-Backend/test/jest-e2e.json index d4ffa05..91d397b 100644 --- a/ApiCore-Backend/test/jest-e2e.json +++ b/ApiCore-Backend/test/jest-e2e.json @@ -4,6 +4,7 @@ "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "setupFiles": ["/setup-env.ts"], + "maxWorkers": 4, "transform": { "^.+\\.(t|j)s$": "ts-jest" } diff --git a/TASKS.md b/TASKS.md index bcdbea0..41cbd21 100644 --- a/TASKS.md +++ b/TASKS.md @@ -139,7 +139,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `AuditModule` erweitert um Abfrage-Endpunkte (Grundstruktur bereits in Schritt 1 gebaut, hier UI + Query-API) - Endpunkte: `GET /api/v1/audit-log?entityType=&entityId=` - Frontend: `/settings/audit-log` (nur Administrator) -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 20. Backupfähigkeit