import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { addDays, berlinToday, dayToDate } from '../src/common/dates'; import { PrismaService } from '../src/prisma/prisma.service'; import { lockRowsForShare } from '../src/prisma/row-lock'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; interface ColonyBody { id: string; apiaryId: string; notes: string | null; } interface RestrictedBody { code: string; message: string; restrictionIds: string[]; restrictions: { id: string; apiaryId: string; apiaryName: string; disease: string; startsOn: string; }[]; } interface RestrictionBody { id: string; apiaryId: string; endsOn: string | null; } const LIFECYCLE_PERMISSIONS = [ 'colonies:read', 'colonies:create', 'colonies:update', 'colonies:manage-lifecycle', 'apiaries:read', ]; const REASON = 'Amtstierarzt hat die Verbringung am Telefon genehmigt'; const DISEASE = 'Amerikanische Faulbrut'; describe('Restricted zone (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = Date.now(); const password = 'SuperSecret123!'; const userIds: string[] = []; const roleIds: string[] = []; let beekeeperToken: string; let lifecycleToken: string; let staffToken: string; let readonlyToken: string; let apiariesOnlyToken: string; let apiaryRestrictedId: string; let apiaryFreeId: string; let apiaryOtherId: string; const colonyIds: string[] = []; async function user( label: string, role: { connect: { key: string } } | { connect: { id: string } }, ): Promise { const email = `restricted-zone-${label}-${suffix}@example.com`; const created = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: label, role, }, }); userIds.push(created.id); return (await login(app, email, password)).accessToken; } async function colonyIn(apiaryId: string): Promise { const colony = await prisma.colony.create({ data: { code: `RZ-${suffix}-${colonyIds.length}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyIds.push(colony.id); return colony.id; } function restrict(apiaryId: string, endsOn: string | null = null) { return prisma.apiaryRestriction.create({ data: { apiaryId, disease: DISEASE, startsOn: dayToDate('2000-01-01'), endsOn: endsOn ? dayToDate(endsOn) : null, }, }); } const apiaryIds = (): string[] => [ apiaryRestrictedId, apiaryFreeId, apiaryOtherId, ]; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const role = await prisma.role.create({ data: { key: `RZ_LIFECYCLE_${suffix}`, name: 'Voelker ohne Behoerden', permissions: { create: LIFECYCLE_PERMISSIONS.map((key) => ({ permission: { connect: { key } }, })), }, }, }); roleIds.push(role.id); beekeeperToken = await user('beekeeper', { connect: { key: 'BEEKEEPER' } }); lifecycleToken = await user('lifecycle', { connect: { id: role.id } }); staffToken = await user('staff', { connect: { key: 'STAFF' } }); readonlyToken = await user('readonly', { connect: { key: 'READONLY' } }); const apiariesOnly = await prisma.role.create({ data: { key: `RZ_APIARIES_ONLY_${suffix}`, name: 'Nur Staende', permissions: { create: [{ permission: { connect: { key: 'apiaries:read' } } }], }, }, }); roleIds.push(apiariesOnly.id); apiariesOnlyToken = await user('apiaries-only', { connect: { id: apiariesOnly.id }, }); apiaryRestrictedId = ( await prisma.apiary.create({ data: { name: `RZ Sperre ${suffix}` } }) ).id; apiaryFreeId = ( await prisma.apiary.create({ data: { name: `RZ Frei ${suffix}` } }) ).id; apiaryOtherId = ( await prisma.apiary.create({ data: { name: `RZ Weitere ${suffix}` } }) ).id; }); beforeEach(async () => { await prisma.apiaryRestriction.deleteMany({ where: { apiaryId: { in: apiaryIds() } }, }); }); afterAll(async () => { await prisma.apiaryRestriction.deleteMany({ where: { apiaryId: { in: apiaryIds() } }, }); await prisma.domainEvent.deleteMany({ where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } }, }); const harvests = await prisma.harvest.findMany({ where: { apiaryId: { in: apiaryIds() } }, select: { id: true }, }); const harvestIds = harvests.map((harvest) => harvest.id); await prisma.harvestColony.deleteMany({ where: { harvestId: { in: harvestIds } }, }); await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } }); await prisma.colonyLineage.deleteMany({ where: { OR: [ { fromColonyId: { in: colonyIds } }, { toColonyId: { in: colonyIds } }, ], }, }); await prisma.colonyApiaryHistory.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.colony.deleteMany({ where: { OR: [{ id: { in: colonyIds } }, { apiaryId: { in: apiaryIds() } }], }, }); await prisma.apiary.deleteMany({ where: { id: { in: apiaryIds() } } }); 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 app.close(); }); it('update with a changed apiary, move, merge across apiaries and create each answer 409 RESTRICTED_ZONE', async () => { await restrict(apiaryRestrictedId); const inFree = await colonyIn(apiaryFreeId); const inRestricted = await colonyIn(apiaryRestrictedId); const http = () => request(app.getHttpServer()); const auth = { Authorization: `Bearer ${beekeeperToken}` }; const created = await http() .post('/api/v1/colonies') .set(auth) .send({ code: `RZ-new-${suffix}`, apiaryId: apiaryRestrictedId, colonyType: 'PRODUCTION_COLONY', }) .expect(409); const body = created.body as RestrictedBody; expect(body.code).toBe('RESTRICTED_ZONE'); expect(body.restrictionIds).toHaveLength(1); expect(body.restrictions[0]).toMatchObject({ apiaryId: apiaryRestrictedId, apiaryName: `RZ Sperre ${suffix}`, disease: DISEASE, startsOn: '2000-01-01', }); expect(body.message).toContain(`RZ Sperre ${suffix}`); expect( await prisma.colony.count({ where: { code: `RZ-new-${suffix}` } }), ).toBe(0); await http() .patch(`/api/v1/colonies/${inFree}`) .set(auth) .send({ apiaryId: apiaryRestrictedId }) .expect(409); await http() .post(`/api/v1/colonies/${inFree}/move`) .set(auth) .send({ apiaryId: apiaryRestrictedId }) .expect(409); await http() .post(`/api/v1/colonies/${inRestricted}/move`) .set(auth) .send({ apiaryId: apiaryFreeId }) .expect(409); await http() .patch(`/api/v1/colonies/${inRestricted}`) .set(auth) .send({ apiaryId: apiaryFreeId }) .expect(409); await http() .post(`/api/v1/colonies/${inFree}/merge`) .set(auth) .send({ mergedColonyIds: [inRestricted] }) .expect(409); const after = await prisma.colony.findMany({ where: { id: { in: [inFree, inRestricted] } }, orderBy: { id: 'asc' }, }); expect( after.map((colony) => [colony.id, colony.apiaryId, colony.status]), ).toEqual( [ [inFree, apiaryFreeId, 'ACTIVE'], [inRestricted, apiaryRestrictedId, 'ACTIVE'], ].sort(([a], [b]) => (a < b ? -1 : 1)), ); }); it('an override with compliance:manage answers 200, writes audit and the history note, and never stores the reason on the colony', async () => { const restriction = await restrict(apiaryRestrictedId); const colonyId = await colonyIn(apiaryFreeId); const auth = { Authorization: `Bearer ${beekeeperToken}` }; const patched = await request(app.getHttpServer()) .patch(`/api/v1/colonies/${colonyId}`) .set(auth) .send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: REASON }) .expect(200); expect((patched.body as ColonyBody).apiaryId).toBe(apiaryRestrictedId); const moved = await request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/move`) .set(auth) .send({ apiaryId: apiaryFreeId, notes: 'Rueckfuehrung', restrictionOverrideReason: REASON, }) .expect(201); expect((moved.body as ColonyBody).apiaryId).toBe(apiaryFreeId); const colony = await prisma.colony.findUniqueOrThrow({ where: { id: colonyId }, }); expect(JSON.stringify(colony)).not.toContain(REASON); const history = await prisma.colonyApiaryHistory.findMany({ where: { colonyId }, orderBy: { movedAt: 'asc' }, }); expect(history.map((entry) => entry.notes)).toEqual([ `Ausnahme Sperrbezirk: ${REASON}\nKorrektur über Volk bearbeiten`, `Ausnahme Sperrbezirk: ${REASON}\nRueckfuehrung`, ]); const audits = await prisma.auditLog.findMany({ where: { entityType: 'Colony', entityId: colonyId, action: 'UPDATE' }, }); expect(audits).toHaveLength(2); for (const audit of audits) { expect( (audit.afterData as { restrictionOverride: unknown }) .restrictionOverride, ).toEqual({ reason: REASON, restrictionIds: [restriction.id] }); } }); it('an override without compliance:manage answers 403 and an override reason of 5 characters answers 400', async () => { await restrict(apiaryRestrictedId); const colonyId = await colonyIn(apiaryFreeId); await request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/move`) .set('Authorization', `Bearer ${lifecycleToken}`) .send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: REASON }) .expect(403); await request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/move`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: 'kurz!', }) .expect(400); await request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/move`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ apiaryId: apiaryRestrictedId, restrictionOverrideReason: ' '.repeat(12), }) .expect(400); // A reason without compliance:manage is a 403 even when nothing is restricted. await request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/move`) .set('Authorization', `Bearer ${lifecycleToken}`) .send({ apiaryId: apiaryOtherId, restrictionOverrideReason: REASON }) .expect(403); expect( (await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } })) .apiaryId, ).toBe(apiaryFreeId); }); it('split inside the apiary and a harvest at a restricted apiary succeed', async () => { await restrict(apiaryRestrictedId); const colonyId = await colonyIn(apiaryRestrictedId); const split = await request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/split`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ code: `RZ-split-${suffix}`, colonyType: 'NUCLEUS_COLONY' }) .expect(201); colonyIds.push((split.body as ColonyBody).id); expect((split.body as ColonyBody).apiaryId).toBe(apiaryRestrictedId); await request(app.getHttpServer()) .post('/api/v1/harvests') .set('Authorization', `Bearer ${staffToken}`) .send({ apiaryId: apiaryRestrictedId, date: new Date().toISOString(), colonyIds: [colonyId], netWeight: 12, }) .expect(201); }); it('a restriction ending yesterday no longer blocks, one ending today still does', async () => { const today = berlinToday(); const colonyId = await colonyIn(apiaryFreeId); const auth = { Authorization: `Bearer ${beekeeperToken}` }; await restrict(apiaryRestrictedId, today); await request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/move`) .set(auth) .send({ apiaryId: apiaryRestrictedId }) .expect(409); await prisma.apiaryRestriction.updateMany({ where: { apiaryId: apiaryRestrictedId }, data: { endsOn: dayToDate(addDays(today, -1)) }, }); await request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/move`) .set(auth) .send({ apiaryId: apiaryRestrictedId }) .expect(201); }); it('GET /public routes never contain restriction data', async () => { const user = await prisma.user.findFirstOrThrow({ where: { id: { in: userIds } }, }); const lotNumber = `RZ-LOT-${suffix}`; await restrict(apiaryRestrictedId); const colonyId = await colonyIn(apiaryRestrictedId); const harvest = await prisma.harvest.create({ data: { apiaryId: apiaryRestrictedId, date: new Date('2026-08-12'), userId: user.id, }, }); await prisma.harvestColony.create({ data: { harvestId: harvest.id, colonyId }, }); const extraction = await prisma.extraction.create({ data: { date: new Date('2026-08-13'), userId: user.id }, }); await prisma.extractionHarvest.create({ data: { extractionId: extraction.id, harvestId: harvest.id }, }); const honeyBatch = await prisma.honeyBatch.create({ data: { batchNumber: `RZ-HC-${suffix}`, extractionId: extraction.id, amount: 25, honeyType: 'Sommerhonig', }, }); const product = await prisma.product.create({ data: { name: `RZ Honig ${suffix}` }, }); const bottling = await prisma.bottlingBatch.create({ data: { honeyBatchId: honeyBatch.id, productId: product.id, date: new Date('2026-08-20'), jarSize: 500, jarCount: 10, totalAmount: 5, bestBeforeDate: new Date('2028-08-20'), lotNumber, userId: user.id, }, }); try { const response = await request(app.getHttpServer()) .get(`/api/v1/public/batches/${lotNumber}`) .expect(200); const text = JSON.stringify(response.body).toLowerCase(); for (const secret of [DISEASE, 'faulbrut', 'restriction', 'sperr']) { expect(text).not.toContain(secret.toLowerCase()); } } finally { await prisma.bottlingBatch.delete({ where: { id: bottling.id } }); await prisma.product.delete({ where: { id: product.id } }); await prisma.honeyBatch.delete({ where: { id: honeyBatch.id } }); await prisma.extractionHarvest.deleteMany({ where: { extractionId: extraction.id }, }); await prisma.extraction.delete({ where: { id: extraction.id } }); } }); it('READONLY may read restrictions but not create them (403)', async () => { const auth = { Authorization: `Bearer ${readonlyToken}` }; const restriction = await restrict(apiaryRestrictedId); const active = await request(app.getHttpServer()) .get('/api/v1/apiary-restrictions?active=true') .set(auth) .expect(200); expect( (active.body as RestrictedBody['restrictions']).map((entry) => entry.id), ).toContain(restriction.id); const ofApiary = await request(app.getHttpServer()) .get(`/api/v1/apiaries/${apiaryRestrictedId}/restrictions`) .set(auth) .expect(200); expect( (ofApiary.body as RestrictionBody[]).map((entry) => entry.id), ).toEqual([restriction.id]); await request(app.getHttpServer()) .post('/api/v1/apiary-restrictions') .set(auth) .send({ apiaryIds: [apiaryFreeId], disease: DISEASE, startsOn: '2026-10-01', }) .expect(403); await request(app.getHttpServer()) .patch(`/api/v1/apiary-restrictions/${restriction.id}`) .set(auth) .send({ endsOn: '2026-12-01' }) .expect(403); }); it('creates one restriction per apiary, ends it by endsOn and keeps the row', async () => { const auth = { Authorization: `Bearer ${beekeeperToken}` }; const created = await request(app.getHttpServer()) .post('/api/v1/apiary-restrictions') .set(auth) .send({ apiaryIds: [apiaryFreeId, apiaryOtherId], disease: DISEASE, startsOn: '2026-10-01', authority: 'Veterinaeramt', }) .expect(201); const rows = created.body as RestrictionBody[]; expect(rows.map((row) => row.apiaryId).sort()).toEqual( [apiaryFreeId, apiaryOtherId].sort(), ); await request(app.getHttpServer()) .post('/api/v1/apiary-restrictions') .set(auth) .send({ apiaryIds: [apiaryFreeId, 'does-not-exist'], disease: DISEASE, startsOn: '2026-10-01', }) .expect(404); expect( await prisma.apiaryRestriction.count({ where: { apiaryId: { in: apiaryIds() } }, }), ).toBe(2); await request(app.getHttpServer()) .post('/api/v1/apiary-restrictions') .set(auth) .send({ apiaryIds: [apiaryFreeId], disease: DISEASE, startsOn: '2026-10-05', endsOn: '2026-10-01', }) .expect(400); const ended = await request(app.getHttpServer()) .patch(`/api/v1/apiary-restrictions/${rows[0].id}`) .set(auth) .send({ endsOn: '2026-10-20' }) .expect(200); expect((ended.body as RestrictionBody).endsOn).toContain('2026-10-20'); await request(app.getHttpServer()) .patch(`/api/v1/apiary-restrictions/${rows[0].id}`) .set(auth) .send({ endsOn: '2026-09-01' }) .expect(400); await request(app.getHttpServer()) .patch(`/api/v1/apiary-restrictions/${rows[0].id}`) .set(auth) .send({ endsOn: null }) .expect(200); expect( await prisma.auditLog.count({ where: { entityType: 'ApiaryRestriction', entityId: rows[0].id }, }), ).toBe(3); }); it('a restriction created while a move into that apiary is in flight: the move commits first, the restriction waits (FOR UPDATE vs FOR SHARE)', async () => { let letMoveCommit!: () => void; const commitSignal = new Promise((resolve) => { letMoveCommit = resolve; }); let moveHoldsLock!: () => void; const lockHeld = new Promise((resolve) => { moveHoldsLock = resolve; }); // What a move into the apiary holds between its lock and its commit. const moveInFlight = prisma.$transaction( async (tx) => { await lockRowsForShare(tx, 'Apiary', [apiaryFreeId]); moveHoldsLock(); await commitSignal; }, { timeout: 15_000 }, ); await lockHeld; let answered = false; const creating = request(app.getHttpServer()) .post('/api/v1/apiary-restrictions') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ apiaryIds: [apiaryFreeId], disease: DISEASE, startsOn: '2026-10-01', }) .then((response) => { answered = true; return response; }); await new Promise((resolve) => setTimeout(resolve, 600)); expect(answered).toBe(false); letMoveCommit(); await moveInFlight; expect((await creating).status).toBe(201); }); it('details (authority, reference, notes, creator) need compliance:read; apiaries:read alone gets the summary', async () => { const restriction = await prisma.apiaryRestriction.create({ data: { apiaryId: apiaryRestrictedId, disease: DISEASE, authority: 'Veterinaeramt Geheim', reference: 'AZ-GEHEIM-1', notes: 'interne Notiz', startsOn: dayToDate('2000-01-01'), createdByUserId: userIds[0], }, }); const paths = [ '/api/v1/apiary-restrictions', `/api/v1/apiaries/${apiaryRestrictedId}/restrictions`, ]; for (const path of paths) { const minimal = await request(app.getHttpServer()) .get(path) .set('Authorization', `Bearer ${apiariesOnlyToken}`) .expect(200); const rows = (minimal.body as RestrictionBody[]).filter( (row) => row.id === restriction.id, ); expect(rows).toHaveLength(1); expect(Object.keys(rows[0]).sort()).toEqual([ 'apiaryId', 'disease', 'endsOn', 'id', 'startsOn', ]); const text = JSON.stringify(minimal.body); for (const secret of ['Geheim', 'AZ-GEHEIM-1', 'interne Notiz']) { expect(text).not.toContain(secret); } const full = await request(app.getHttpServer()) .get(path) .set('Authorization', `Bearer ${readonlyToken}`) .expect(200); const detailed = (full.body as Record[]).find( (row) => row.id === restriction.id, ); expect(detailed).toMatchObject({ authority: 'Veterinaeramt Geheim', reference: 'AZ-GEHEIM-1', notes: 'interne Notiz', }); } }); it('a move and a restriction on the target apiary racing each other end consistently', async () => { for (let round = 0; round < 4; round += 1) { await prisma.apiaryRestriction.deleteMany({ where: { apiaryId: { in: apiaryIds() } }, }); const colonyId = await colonyIn(apiaryFreeId); const [move, created] = await Promise.all([ request(app.getHttpServer()) .post(`/api/v1/colonies/${colonyId}/move`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ apiaryId: apiaryOtherId }), request(app.getHttpServer()) .post('/api/v1/apiary-restrictions') .set('Authorization', `Bearer ${beekeeperToken}`) .send({ apiaryIds: [apiaryOtherId], disease: DISEASE, startsOn: '2000-01-01', }), ]); // Either the move committed first and the restriction followed, or the // restriction was first and the move was refused - never a move that // slipped past a restriction that was already in force. expect(created.status).toBe(201); expect([201, 409]).toContain(move.status); const colony = await prisma.colony.findUniqueOrThrow({ where: { id: colonyId }, }); expect(colony.apiaryId).toBe( move.status === 201 ? apiaryOtherId : apiaryFreeId, ); } }); });