import { randomUUID } from 'node:crypto'; import { INestApplication } from '@nestjs/common'; import { NestExpressApplication } from '@nestjs/platform-express'; import { Test } from '@nestjs/testing'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { berlinToday, dayToDate } from '../src/common/dates'; import type { ReadinessItem, ReadinessReport, ReadinessRule, } from '../src/compliance/readiness/readiness.types'; import { READINESS_RULES, READINESS_RULES_TOKEN, } from '../src/compliance/readiness/rules'; import { AppModule } from '../src/app.module'; import { configureApp } from '../src/app.config'; import { THROTTLER_NAMESPACE } from '../src/common/throttler/prisma-throttler.storage'; import { PrismaService } from '../src/prisma/prisma.service'; import { login } from './utils/api'; const READINESS = '/api/v1/compliance/readiness'; const ACKNOWLEDGEMENTS = '/api/v1/compliance/acknowledgements'; const SOFT_KEY = 'e2e-soft-check'; interface SiteFields { holderName: string | null; holderAddress: string | null; foodBusinessName: string | null; foodBusinessAddress: string | null; } describe('Compliance readiness (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = Date.now(); const password = 'SuperSecret123!'; const tokens: Record = {}; let apiaryOnlyRoleId: string; let apiaryId: string; let keyCounter = 0; let softValue = 1; let originalSite: SiteFields; let originalVetOffice: string | null | undefined; // Only rows this file created are ever deleted (the database is shared). const noticeIds: string[] = []; const restrictionIds: string[] = []; const acknowledgementIds: string[] = []; // A rule that can be acknowledged: the real ones of this task cannot, the // later ones will. It gives the acknowledgement flow a real database. const softRule: ReadinessRule = { key: SOFT_KEY, area: 'ETIKETTEN', severity: 'PRUEFEN', title: 'Testpunkt', legalBasis: null, acknowledgeable: true, evaluate: () => [ { subjectId: 'global', detail: 'Bitte prüfen.', fingerprintParts: { value: softValue }, subjects: [], }, ], }; const server = () => app.getHttpServer(); const as = (name: string) => ({ Authorization: `Bearer ${tokens[name]}` }); const nextKey = () => `readiness-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`; async function report(user = 'beekeeper'): Promise { const response = await request(server()).get(READINESS).set(as(user)); expect(response.status).toBe(200); return response.body as ReadinessReport; } function itemOf( body: ReadinessReport, key: string, subjectId = 'global', ): ReadinessItem { const found = body.items.find( (item) => item.key === key && item.subjectId === subjectId, ); if (!found) { throw new Error(`No readiness item ${key}/${subjectId}`); } return found; } function acknowledge(body: Record, user = 'beekeeper') { return request(server()) .post(ACKNOWLEDGEMENTS) .set(as(user)) .set('Idempotency-Key', nextKey()) .send(body); } async function createUser( name: string, roleWhere: { key: string } | { id: string }, ) { const email = `readiness-e2e-${name}-${suffix}@example.com`; await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name, role: { connect: roleWhere }, }, }); tokens[name] = (await login(app, email, password)).accessToken; } // Settings rows are saved in beforeAll and put back in afterAll, never // deleted: other suites rely on the seeded rows. async function setSite(data: SiteFields) { await prisma.siteSettings.upsert({ where: { id: 'singleton' }, update: data, create: { id: 'singleton', ...data }, }); } async function setVetOffice(name: string | null) { await prisma.complianceSettings.upsert({ where: { id: 'singleton' }, update: { veterinaryOfficeName: name }, create: { id: 'singleton', veterinaryOfficeName: name }, }); } async function removeOwnRows() { await prisma.complianceAcknowledgement.deleteMany({ where: { id: { in: acknowledgementIds } }, }); await prisma.authorityNotice.deleteMany({ where: { id: { in: noticeIds } }, }); await prisma.apiaryRestriction.deleteMany({ where: { id: { in: restrictionIds } }, }); noticeIds.length = 0; restrictionIds.length = 0; acknowledgementIds.length = 0; } beforeAll(async () => { // The module gets the soft test rule through its token; the shared list // stays untouched. const moduleFixture = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(THROTTLER_NAMESPACE) .useValue(randomUUID()) .overrideProvider(READINESS_RULES_TOKEN) .useValue([...READINESS_RULES, softRule]) .compile(); const nest = moduleFixture.createNestApplication(); configureApp(nest); await nest.init(); app = nest; prisma = app.get(PrismaService); originalVetOffice = ( await prisma.complianceSettings.findUnique({ where: { id: 'singleton' } }) )?.veterinaryOfficeName; const site = await prisma.siteSettings.findUnique({ where: { id: 'singleton' }, }); originalSite = { holderName: site?.holderName ?? null, holderAddress: site?.holderAddress ?? null, foodBusinessName: site?.foodBusinessName ?? null, foodBusinessAddress: site?.foodBusinessAddress ?? null, }; await createUser('beekeeper', { key: 'BEEKEEPER' }); await createUser('staff', { key: 'STAFF' }); await createUser('readonly', { key: 'READONLY' }); await createUser('apiclient', { key: 'API_CLIENT' }); // compliance:read only, without apiaries:read. const role = await prisma.role.create({ data: { key: `READINESS_E2E_${suffix}`, name: 'Nur Prüfbereitschaft' }, }); apiaryOnlyRoleId = role.id; const permission = await prisma.permission.findFirstOrThrow({ where: { key: 'compliance:read' }, }); await prisma.rolePermission.create({ data: { roleId: role.id, permissionId: permission.id }, }); await createUser('complianceonly', { id: role.id }); const apiary = await prisma.apiary.create({ data: { name: `Readiness Stand ${suffix}` }, }); apiaryId = apiary.id; }); beforeEach(async () => { await removeOwnRows(); softValue = 1; }); afterAll(async () => { await removeOwnRows(); await setSite(originalSite); if (originalVetOffice !== undefined) { await setVetOffice(originalVetOffice); } await prisma.apiary.deleteMany({ where: { id: apiaryId } }); const mine = { email: { startsWith: 'readiness-e2e-', endsWith: `-${suffix}@example.com`, }, }; const userIds = ( await prisma.user.findMany({ where: mine, select: { id: true } }) ).map((user) => user.id); await prisma.refreshToken.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await prisma.rolePermission.deleteMany({ where: { roleId: apiaryOnlyRoleId }, }); await prisma.role.deleteMany({ where: { id: apiaryOnlyRoleId } }); await app.close(); }); it('without site data and without a SENT registration the Stammdaten and registration items are open and the status is OPEN', async () => { await setSite({ holderName: null, holderAddress: null, foodBusinessName: null, foodBusinessAddress: null, }); await setVetOffice(null); // Rows of other suites stay: the registration follows what exists. const sentRegistrations = await prisma.authorityNotice.count({ where: { kind: 'REGISTRATION', status: 'SENT' }, }); const body = await report(); for (const key of ['holder-data', 'food-business-data']) { expect(itemOf(body, key)).toMatchObject({ state: 'OPEN' }); } expect(itemOf(body, 'registration').state).toBe( sentRegistrations === 0 ? 'OPEN' : 'DONE', ); expect(itemOf(body, 'holder-data').subjects[0].missing).toEqual([ 'Name', 'Anschrift', ]); expect(itemOf(body, 'vet-office').state).toBe('OPEN'); expect(body.status).toBe('OPEN'); expect(body.openCount).toBeGreaterThanOrEqual(3); expect(Number.isNaN(Date.parse(body.generatedAt))).toBe(false); const summary = await request(server()) .get(`${READINESS}/summary`) .set(as('beekeeper')); expect(summary.status).toBe(200); expect(summary.body).toMatchObject({ status: 'OPEN' }); expect((summary.body as { firstItemKey: string }).firstItemKey).toEqual( expect.any(String), ); }); it('complete master data and a SENT registration make those items DONE', async () => { await setSite({ holderName: 'Imker E2E', holderAddress: 'Weg 1, 12345 Ort', foodBusinessName: 'Imkerei E2E', foodBusinessAddress: 'Weg 2, 12345 Ort', }); await setVetOffice('Amt E2E'); const sent = await prisma.authorityNotice.create({ data: { kind: 'REGISTRATION', status: 'SENT', referenceDate: dayToDate('2026-01-01'), countedAt: new Date('2026-01-01T10:00:00.000Z'), snapshot: { version: 1, takenAt: 'x', apiaries: [], totalLiving: 0 }, totalColonies: 0, sentOn: dayToDate('2026-01-02'), }, }); noticeIds.push(sent.id); const body = await report(); for (const key of [ 'holder-data', 'food-business-data', 'vet-office', 'registration', ]) { expect(itemOf(body, key)).toMatchObject({ state: 'DONE', subjects: [], more: 0, }); } }); it('READONLY may read the report but gets 403 acknowledging; STAFF gets 403 on acknowledgements; API_CLIENT gets 403 everywhere', async () => { const payload = { itemKey: SOFT_KEY, subjectId: 'global', fingerprint: 'a'.repeat(64), note: 'Geprüft und in Ordnung.', }; expect((await report('readonly')).items.length).toBeGreaterThan(0); expect((await report('staff')).items.length).toBeGreaterThan(0); expect((await acknowledge(payload, 'readonly')).status).toBe(403); expect((await acknowledge(payload, 'staff')).status).toBe(403); expect( ( await request(server()) .post(`${ACKNOWLEDGEMENTS}/someid/revoke`) .set(as('staff')) ).status, ).toBe(403); for (const path of [READINESS, `${READINESS}/summary`]) { expect( (await request(server()).get(path).set(as('apiclient'))).status, ).toBe(403); } expect((await acknowledge(payload, 'apiclient')).status).toBe(403); expect((await request(server()).get(READINESS)).status).toBe(401); }); it('shows the apiaries of an active restriction only with apiaries:read, but counts them for everyone', async () => { const created = await prisma.apiaryRestriction.create({ data: { apiaryId, disease: 'Faulbrut', authority: 'Amt Geheim', startsOn: dayToDate(berlinToday()), }, }); restrictionIds.push(created.id); const full = itemOf(await report('beekeeper'), 'restriction-active'); const masked = itemOf(await report('complianceonly'), 'restriction-active'); expect(full.state).toBe('OPEN'); expect(full.subjects.map((subject) => subject.id)).toContain(apiaryId); expect(masked.state).toBe('OPEN'); expect(masked.subjects).toEqual([]); expect(masked.more).toBeGreaterThanOrEqual(1); expect(JSON.stringify(masked)).not.toContain('Readiness Stand'); expect(JSON.stringify(masked)).not.toContain('Amt Geheim'); expect(JSON.stringify(full)).not.toContain('Amt Geheim'); }); it('an ended restriction does not keep the item open', async () => { const ended = await prisma.apiaryRestriction.create({ data: { apiaryId, disease: 'Faulbrut', startsOn: dayToDate('2020-01-01'), endsOn: dayToDate('2020-02-01'), }, }); restrictionIds.push(ended.id); const body = await report(); expect( body.items .find((item) => item.key === 'restriction-active') ?.subjects.map((subject) => subject.id) ?? [], ).not.toContain(apiaryId); }); describe('acknowledgements', () => { it('refuses a PFLICHT item, an unknown item and a short note with 400', async () => { const body = await report(); const holder = itemOf(body, 'holder-data'); const soft = itemOf(body, SOFT_KEY); const pflicht = await acknowledge({ itemKey: 'holder-data', subjectId: 'global', fingerprint: holder.fingerprint, note: 'Geprüft und in Ordnung.', }); const unknown = await acknowledge({ itemKey: 'no-such-item', subjectId: 'global', fingerprint: soft.fingerprint, note: 'Geprüft und in Ordnung.', }); const shortNote = await acknowledge({ itemKey: SOFT_KEY, subjectId: 'global', fingerprint: soft.fingerprint, note: 'kurz', }); expect(pflicht.status).toBe(400); expect(unknown.status).toBe(400); expect(shortNote.status).toBe(400); }); it('acknowledges with the current fingerprint, reopens on changed data, and a revoke reopens it', async () => { const before = itemOf(await report(), SOFT_KEY); const stale = await acknowledge({ itemKey: SOFT_KEY, subjectId: 'global', fingerprint: 'b'.repeat(64), note: 'Geprüft und in Ordnung.', }); const created = await acknowledge({ itemKey: SOFT_KEY, subjectId: 'global', fingerprint: before.fingerprint, note: 'Geprüft und in Ordnung.', }); expect(stale.status).toBe(409); expect(created.status).toBe(201); const createdItem = created.body as ReadinessItem; expect(createdItem.state).toBe('ACKNOWLEDGED'); const acknowledgementId = createdItem.acknowledgement!.id; acknowledgementIds.push(acknowledgementId); expect(itemOf(await report(), SOFT_KEY)).toMatchObject({ state: 'ACKNOWLEDGED', acknowledgement: { id: acknowledgementId }, }); expect( await prisma.auditLog.count({ where: { entityType: 'ComplianceAcknowledgement', entityId: acknowledgementId, action: 'CREATE', }, }), ).toBe(1); softValue = 2; expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN'); softValue = 1; expect(itemOf(await report(), SOFT_KEY).state).toBe('ACKNOWLEDGED'); const revoke = () => request(server()) .post(`${ACKNOWLEDGEMENTS}/${acknowledgementId}/revoke`) .set(as('beekeeper')); expect((await revoke()).status).toBe(201); expect(itemOf(await report(), SOFT_KEY).state).toBe('OPEN'); expect((await revoke()).status).toBe(409); const row = await prisma.complianceAcknowledgement.findUniqueOrThrow({ where: { id: acknowledgementId }, }); expect(row.revokedAt).not.toBeNull(); expect(row.revokedByUserId).not.toBeNull(); }); it('revoking an unknown acknowledgement is a 404', async () => { const response = await request(server()) .post(`${ACKNOWLEDGEMENTS}/does-not-exist/revoke`) .set(as('beekeeper')); expect(response.status).toBe(404); }); }); });