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 ShopSyncFeedBody { contractVersion: number; generatedAt: string; harvests: Record[]; products: Record[]; bottlings: Record[]; } /** * The shop sync contract: one endpoint, an explicit field allowlist, and a * permission that grants nothing else. Wabenhain relies on this shape. */ describe('Shop sync (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; let syncToken: string; let readonlyToken: string; let harvestId: string; let bottlingId: string; let productId: string; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); const passwordHash = await bcrypt.hash(password, 12); const syncRole = await prisma.role.create({ data: { key: `SHOP_SYNC_${suffix}`, name: 'Shop-Sync', permissions: { create: [{ permission: { connect: { key: 'shop-sync:read' } } }], }, }, }); await prisma.user.create({ data: { email: `shop-sync-${suffix}@example.com`, passwordHash, name: 'Shop Sync', roleId: syncRole.id, }, }); await prisma.user.create({ data: { email: `shop-sync-ro-${suffix}@example.com`, passwordHash, name: 'Readonly', role: { connect: { key: 'READONLY' } }, }, }); syncToken = (await login(app, `shop-sync-${suffix}@example.com`, password)) .accessToken; readonlyToken = ( await login(app, `shop-sync-ro-${suffix}@example.com`, password) ).accessToken; const owner = await prisma.user.create({ data: { email: `shop-sync-owner-${suffix}@example.com`, passwordHash, name: 'Owner', role: { connect: { key: 'ADMIN' } }, }, }); const apiary = await prisma.apiary.create({ data: { name: `Streuobst ${suffix}`, notes: 'Schlüssel unter dem Stein' }, }); const harvest = await prisma.harvest.create({ data: { apiaryId: apiary.id, userId: owner.id, date: new Date('2026-07-14T00:00:00.000Z'), honeyType: 'Sommertracht', netWeight: 41, notes: 'Volk 7 nach Varroa-Behandlung schwach', }, }); harvestId = harvest.id; const extraction = await prisma.extraction.create({ data: { date: new Date('2026-07-20T00:00:00.000Z'), userId: owner.id, notes: 'intern', harvests: { create: [{ harvestId }] }, }, }); const honeyBatch = await prisma.honeyBatch.create({ data: { batchNumber: `HB-${suffix}`, extractionId: extraction.id, amount: 50, }, }); const product = await prisma.product.create({ data: { name: `Sommertracht 500 g ${suffix}`, sku: `SKU-${suffix}` }, }); productId = product.id; const bottling = await prisma.bottlingBatch.create({ data: { honeyBatchId: honeyBatch.id, productId, date: new Date('2026-08-01T00:00:00.000Z'), bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'), jarSize: 500, jarCount: 60, totalAmount: 30, lotNumber: `L-${suffix}`, }, }); bottlingId = bottling.id; }); afterAll(async () => { await app.close(); }); it('delivers the versioned feed to an account holding only shop-sync:read', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${syncToken}`) .expect(200); const body = response.body as ShopSyncFeedBody; expect(body.contractVersion).toBe(1); const harvest = body.harvests.find((h) => h.id === harvestId); expect(harvest).toMatchObject({ apiaryName: `Streuobst ${suffix}`, honeyType: 'Sommertracht', netWeight: 41, }); const bottling = body.bottlings.find((b) => b.id === bottlingId); expect(bottling).toMatchObject({ lotNumber: `L-${suffix}`, jarSize: 500, jarCount: 60, productId, harvestIds: [harvestId], }); expect(body.products.some((p) => p.id === productId)).toBe(true); }); it('never carries internal notes or other unlisted fields', async () => { const response = await request(app.getHttpServer()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${syncToken}`) .expect(200); const raw = JSON.stringify(response.body); expect(raw).not.toContain('Varroa'); expect(raw).not.toContain('Schlüssel'); expect(raw).not.toContain('"notes"'); expect(raw).not.toContain('"userId"'); }); it('grants the sync account nothing beyond the feed', async () => { await request(app.getHttpServer()) .get('/api/v1/harvests') .set('Authorization', `Bearer ${syncToken}`) .expect(403); }); it('is not readable without the shop-sync permission', async () => { await request(app.getHttpServer()) .get('/api/v1/shop-sync') .set('Authorization', `Bearer ${readonlyToken}`) .expect(403); }); });