Merge feat/erp-inventar-export: read-only inventory mirror for ERPNext
Brings the ERPNext inventory export (T8) into main after its review: strict seit format, settle window on the DB clock, BatchMovement (createdAt, id) index, one scope per new API key and the failed-attempt lockout on the shared throttler storage. Conflicts resolved in the permission catalog (System and ERP-Anbindung groups) and ARCHITECTURE.md (lockout is Annahme 30). Gates on the merged state: backend lint, typecheck, 1909 unit, 471 e2e, build, no schema drift; frontend 929 tests, lint, typecheck, build. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
2d5c740f7f
33 files changed
+3933
-106
No files matched your search
@@ -103,6 +103,14 @@ describe('API keys (e2e)', () => {
|
||||
expect(JSON.stringify(response.body)).toContain('getrennte Schlüssel');
|
||||
});
|
||||
|
||||
it('refuses a new key with more than one scope', async () => {
|
||||
const response = await createKey({
|
||||
name: `Two reads ${suffix}`,
|
||||
scopes: ['shop-sync:read', 'erp-inventory:read'],
|
||||
}).expect(400);
|
||||
expect(JSON.stringify(response.body)).toContain('nur eine Berechtigung');
|
||||
});
|
||||
|
||||
it('lets a key read exactly what its scopes allow', async () => {
|
||||
const { token } = (
|
||||
await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] })
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
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 { encodeCursor } from '../src/erp-export/erp-cursor';
|
||||
import { API_KEY_FAILURE_LIMIT } from '../src/api-keys/failed-attempt-limiter';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
interface CreatedKeyBody {
|
||||
apiKey: { id: string };
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface PageBody<T> {
|
||||
contractVersion: number;
|
||||
generatedAt: string;
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
settledUntil?: string;
|
||||
}
|
||||
|
||||
type Item = Record<string, unknown> & { id: string };
|
||||
|
||||
interface HarvestYearsBody {
|
||||
contractVersion: number;
|
||||
years: {
|
||||
year: number;
|
||||
netWeightKg: number;
|
||||
harvestCount: number;
|
||||
harvestsWithoutNetWeight: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
const BASE = '/api/v1/export/inventar';
|
||||
const ENDPOINTS = ['produkte', 'chargen', 'bewegungen', 'ernten'] as const;
|
||||
|
||||
/**
|
||||
* Inventar-Spiegel für ERPNext (T8): nur lesend, nur mit erp-inventory:read,
|
||||
* nur freigegebene Felder, stabil und seitenweise. Der ERPNext-Importer wird
|
||||
* nach docs/erp-inventar-export.md gebaut und verlässt sich auf diese Form.
|
||||
*/
|
||||
describe('ERP-Inventar-Export (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
// Eigene Zähler-Menge dieses Laufs; eine zweite Instanz mit derselben
|
||||
// Menge spielt einen zweiten api-Pod.
|
||||
const throttlerNamespace = randomUUID();
|
||||
const password = 'SuperSecret123!';
|
||||
let adminToken: string;
|
||||
let readonlyToken: string;
|
||||
let erpKey: string;
|
||||
let shopKey: string;
|
||||
let honeyProductId: string;
|
||||
let candleProductId: string;
|
||||
let bottlingId: string;
|
||||
let productionBatchId: string;
|
||||
// Laufeigener Zeitraum weit in der Vergangenheit: so finden sich die
|
||||
// Bewegungen dieses Laufs zwischen denen anderer Läufe und Tests.
|
||||
const movementBase = Date.UTC(2001, 0, 1) + (Date.now() % 1_000_000_000);
|
||||
const at = (seconds: number) => new Date(movementBase + seconds * 1000);
|
||||
const movementIds: Record<string, string> = {};
|
||||
|
||||
const server = () => app.getHttpServer();
|
||||
|
||||
// Jeder Test tritt als eigener Client auf (X-Forwarded-For, im Test wird
|
||||
// ein Proxy-Hop vertraut): diese Datei stellt mehr Anfragen, als der
|
||||
// globale Throttler einer einzelnen Adresse je Minute erlaubt.
|
||||
let clientCounter = 0;
|
||||
let clientIp = '198.51.100.1';
|
||||
beforeEach(() => {
|
||||
clientCounter += 1;
|
||||
clientIp = `198.51.100.${clientCounter}`;
|
||||
});
|
||||
|
||||
function get(path: string, token: string, ip = clientIp) {
|
||||
return request(server())
|
||||
.get(`${BASE}/${path}`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('X-Forwarded-For', ip);
|
||||
}
|
||||
|
||||
async function createKey(scopes: string[]): Promise<CreatedKeyBody> {
|
||||
const response = await request(server())
|
||||
.post('/api/v1/api-keys')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ name: `ERP-Test ${suffix}`, scopes })
|
||||
.expect(201);
|
||||
return response.body as CreatedKeyBody;
|
||||
}
|
||||
|
||||
async function allItems(path: string): Promise<Item[]> {
|
||||
const items: Item[] = [];
|
||||
let nach: string | null = null;
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const query = nach ? `?limit=500&nach=${nach}` : '?limit=500';
|
||||
const body = (await get(`${path}${query}`, erpKey).expect(200))
|
||||
.body as PageBody<Item>;
|
||||
items.push(...body.items);
|
||||
if (!body.hasMore) return items;
|
||||
nach = body.nextCursor;
|
||||
}
|
||||
throw new Error('zu viele Seiten');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp({ throttlerNamespace });
|
||||
prisma = app.get(PrismaService);
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
email: `erp-admin-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Erika Imkerin',
|
||||
role: { connect: { key: 'ADMIN' } },
|
||||
},
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: `erp-ro-${suffix}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Nur Lesen',
|
||||
role: { connect: { key: 'READONLY' } },
|
||||
},
|
||||
});
|
||||
adminToken = (await login(app, `erp-admin-${suffix}@example.com`, password))
|
||||
.accessToken;
|
||||
readonlyToken = (await login(app, `erp-ro-${suffix}@example.com`, password))
|
||||
.accessToken;
|
||||
erpKey = (await createKey(['erp-inventory:read'])).token;
|
||||
// Lesen und Buchen sind getrennte Schlüssel; zum Gegenprüfen reicht Lesen.
|
||||
shopKey = (await createKey(['shop-sync:read'])).token;
|
||||
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Waldrand ${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 schwach',
|
||||
},
|
||||
});
|
||||
const extraction = await prisma.extraction.create({
|
||||
data: {
|
||||
date: new Date('2026-07-20T00:00:00.000Z'),
|
||||
userId: owner.id,
|
||||
harvests: { create: [{ harvestId: harvest.id }] },
|
||||
},
|
||||
});
|
||||
const honeyBatch = await prisma.honeyBatch.create({
|
||||
data: {
|
||||
batchNumber: `HB-${suffix}`,
|
||||
extractionId: extraction.id,
|
||||
honeyType: 'Sommertracht',
|
||||
amount: 50,
|
||||
},
|
||||
});
|
||||
honeyProductId = (
|
||||
await prisma.product.create({
|
||||
data: {
|
||||
name: `Sommertracht 500 g ${suffix}`,
|
||||
sku: `ERP-${suffix}`,
|
||||
description: 'Interne Notiz: Lieferant Gläser Huber',
|
||||
packagingSize: 500,
|
||||
weight: 500,
|
||||
salesUnit: 'Glas',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
candleProductId = (
|
||||
await prisma.product.create({
|
||||
data: {
|
||||
name: `Bienenwachskerze ${suffix}`,
|
||||
kind: 'MANUFACTURED',
|
||||
shopCategory: 'KERZEN',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
bottlingId = (
|
||||
await prisma.bottlingBatch.create({
|
||||
data: {
|
||||
honeyBatchId: honeyBatch.id,
|
||||
productId: honeyProductId,
|
||||
userId: owner.id,
|
||||
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-ERP-${suffix}`,
|
||||
},
|
||||
})
|
||||
).id;
|
||||
productionBatchId = (
|
||||
await prisma.productionBatch.create({
|
||||
data: {
|
||||
productId: candleProductId,
|
||||
userId: owner.id,
|
||||
lotNumber: `K-ERP-${suffix}`,
|
||||
date: new Date('2026-09-01T00:00:00.000Z'),
|
||||
quantity: 20,
|
||||
notes: 'Docht von Firma Meier',
|
||||
},
|
||||
})
|
||||
).id;
|
||||
const rawLot = await prisma.rawMaterialLot.create({
|
||||
data: {
|
||||
kind: 'BEESWAX',
|
||||
lotNumber: `W-ERP-${suffix}`,
|
||||
date: new Date('2026-07-20T00:00:00.000Z'),
|
||||
quantity: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const book = async (
|
||||
key: string,
|
||||
data: Parameters<PrismaService['batchMovement']['create']>[0]['data'],
|
||||
) => {
|
||||
movementIds[key] = (await prisma.batchMovement.create({ data })).id;
|
||||
};
|
||||
const personal = {
|
||||
notes: 'an Nachbar Müller verschenkt',
|
||||
userId: owner.id,
|
||||
};
|
||||
await book('hofladen', {
|
||||
bottlingBatchId: bottlingId,
|
||||
type: 'WITHDRAWAL',
|
||||
reason: 'HOFLADEN',
|
||||
quantity: -6,
|
||||
createdAt: at(1),
|
||||
...personal,
|
||||
});
|
||||
await book('eigenbedarf', {
|
||||
bottlingBatchId: bottlingId,
|
||||
type: 'WITHDRAWAL',
|
||||
reason: 'EIGENBEDARF',
|
||||
quantity: -2,
|
||||
createdAt: at(2),
|
||||
...personal,
|
||||
});
|
||||
await book('verkauf', {
|
||||
bottlingBatchId: bottlingId,
|
||||
type: 'SHOP_SALE',
|
||||
quantity: -3,
|
||||
createdAt: at(2),
|
||||
});
|
||||
await book('bruch', {
|
||||
productionBatchId,
|
||||
type: 'WITHDRAWAL',
|
||||
reason: 'BRUCH',
|
||||
quantity: -1,
|
||||
createdAt: at(3),
|
||||
...personal,
|
||||
});
|
||||
await book('korrektur', {
|
||||
bottlingBatchId: bottlingId,
|
||||
type: 'CORRECTION',
|
||||
quantity: 1,
|
||||
createdAt: at(4),
|
||||
...personal,
|
||||
});
|
||||
await book('rohstoff', {
|
||||
rawMaterialLotId: rawLot.id,
|
||||
type: 'WITHDRAWAL',
|
||||
reason: 'SONSTIGES',
|
||||
quantity: -10,
|
||||
createdAt: at(5),
|
||||
});
|
||||
// Gerade eben gebucht: noch innerhalb der Wartezeit.
|
||||
await book('frisch', {
|
||||
bottlingBatchId: bottlingId,
|
||||
type: 'WITHDRAWAL',
|
||||
reason: 'PROBE',
|
||||
quantity: -1,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.throttlerHit.deleteMany({
|
||||
where: { key: { startsWith: `${throttlerNamespace}:` } },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('Zugang', () => {
|
||||
it.each(ENDPOINTS)('%s ist mit dem ERP-Schlüssel lesbar', async (path) => {
|
||||
await get(path, erpKey).expect(200);
|
||||
});
|
||||
|
||||
it.each(ENDPOINTS)('%s verlangt einen Schlüssel (401)', async (path) => {
|
||||
await request(server()).get(`${BASE}/${path}`).expect(401);
|
||||
});
|
||||
|
||||
it('lehnt fehlerhafte und unbekannte Schlüssel ab (401)', async () => {
|
||||
await get('produkte', 'ack_nope').expect(401);
|
||||
await get('produkte', `ack_abcd1234_${'Z'.repeat(43)}`).expect(401);
|
||||
});
|
||||
|
||||
it.each(ENDPOINTS)(
|
||||
'%s bleibt dem Shop-Schlüssel verschlossen (403)',
|
||||
async (path) => {
|
||||
await get(path, shopKey).expect(403);
|
||||
},
|
||||
);
|
||||
|
||||
it('verweigert Benutzern ohne das Recht den Export (403)', async () => {
|
||||
await get('produkte', readonlyToken).expect(403);
|
||||
});
|
||||
|
||||
it('öffnet dem ERP-Schlüssel nichts außer dem Export', async () => {
|
||||
const auth = { Authorization: `Bearer ${erpKey}` };
|
||||
for (const path of [
|
||||
'/api/v1/shop-sync',
|
||||
'/api/v1/shop-sync/stock',
|
||||
'/api/v1/products',
|
||||
'/api/v1/harvests',
|
||||
'/api/v1/bottling-batches',
|
||||
'/api/v1/export/colonies.csv',
|
||||
'/api/v1/users',
|
||||
'/api/v1/auth/me',
|
||||
]) {
|
||||
await request(server()).get(path).set(auth).expect(403);
|
||||
}
|
||||
await request(server())
|
||||
.post('/api/v1/shop-sync/movements')
|
||||
.set(auth)
|
||||
.send({ requests: [] })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('sperrt einen widerrufenen Schlüssel sofort (401)', async () => {
|
||||
const created = await createKey(['erp-inventory:read']);
|
||||
await get('produkte', created.token).expect(200);
|
||||
await request(server())
|
||||
.post(`/api/v1/api-keys/${created.apiKey.id}/revoke`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(201);
|
||||
await get('produkte', created.token).expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Produkte', () => {
|
||||
it('liefert nur die freigegebenen Felder, ohne Beschreibung', async () => {
|
||||
const items = await allItems('produkte');
|
||||
const honey = items.find((p) => p.id === honeyProductId);
|
||||
const candle = items.find((p) => p.id === candleProductId);
|
||||
|
||||
expect(Object.keys(honey ?? {}).sort()).toEqual(
|
||||
[
|
||||
'id',
|
||||
'kind',
|
||||
'name',
|
||||
'packagingSize',
|
||||
'salesUnit',
|
||||
'shopCategory',
|
||||
'sku',
|
||||
'status',
|
||||
'updatedAt',
|
||||
'weight',
|
||||
].sort(),
|
||||
);
|
||||
expect(honey).toMatchObject({
|
||||
name: `Sommertracht 500 g ${suffix}`,
|
||||
sku: `ERP-${suffix}`,
|
||||
kind: 'BOTTLED_HONEY',
|
||||
shopCategory: 'HONIG',
|
||||
status: 'ACTIVE',
|
||||
packagingSize: 500,
|
||||
weight: 500,
|
||||
salesUnit: 'Glas',
|
||||
});
|
||||
expect(candle).toMatchObject({
|
||||
kind: 'MANUFACTURED',
|
||||
shopCategory: 'KERZEN',
|
||||
});
|
||||
expect(JSON.stringify(items)).not.toContain('Lieferant Gläser Huber');
|
||||
});
|
||||
|
||||
it('blättert stabil nach id', async () => {
|
||||
const first = (await get('produkte?limit=1', erpKey).expect(200))
|
||||
.body as PageBody<Item>;
|
||||
expect(first.contractVersion).toBe(1);
|
||||
expect(first.items).toHaveLength(1);
|
||||
expect(first.hasMore).toBe(true);
|
||||
|
||||
const second = (
|
||||
await get(`produkte?limit=1&nach=${first.nextCursor}`, erpKey).expect(
|
||||
200,
|
||||
)
|
||||
).body as PageBody<Item>;
|
||||
expect(second.items).toHaveLength(1);
|
||||
expect(second.items[0].id > first.items[0].id).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chargen', () => {
|
||||
it('liefert Abfüllung und Herstellungscharge mit Rest aus dem Bewegungsbuch', async () => {
|
||||
const items = await allItems('chargen');
|
||||
const bottling = items.find((b) => b.id === bottlingId);
|
||||
const production = items.find((b) => b.id === productionBatchId);
|
||||
|
||||
expect(bottling).toEqual({
|
||||
id: bottlingId,
|
||||
batchType: 'BOTTLING',
|
||||
lotNumber: `L-ERP-${suffix}`,
|
||||
productId: honeyProductId,
|
||||
date: '2026-08-01T00:00:00.000Z',
|
||||
bestBeforeDate: '2028-08-01T00:00:00.000Z',
|
||||
initialQuantity: 60,
|
||||
// 60 - 6 Hofladen - 2 Eigenbedarf - 3 Shop + 1 Inventur - 1 Probe
|
||||
remainingQuantity: 49,
|
||||
fillWeightGrams: 500,
|
||||
honeyType: 'Sommertracht',
|
||||
});
|
||||
expect(production).toEqual({
|
||||
id: productionBatchId,
|
||||
batchType: 'PRODUCTION',
|
||||
lotNumber: `K-ERP-${suffix}`,
|
||||
productId: candleProductId,
|
||||
date: '2026-09-01T00:00:00.000Z',
|
||||
bestBeforeDate: null,
|
||||
initialQuantity: 20,
|
||||
remainingQuantity: 19,
|
||||
fillWeightGrams: null,
|
||||
honeyType: null,
|
||||
});
|
||||
// Abfüllungen vor Herstellungschargen.
|
||||
const kinds = items.map((b) => b.batchType);
|
||||
expect(kinds.lastIndexOf('BOTTLING')).toBeLessThan(
|
||||
kinds.indexOf('PRODUCTION'),
|
||||
);
|
||||
const raw = JSON.stringify(items);
|
||||
expect(raw).not.toContain('Docht von Firma Meier');
|
||||
expect(raw).not.toContain('Erika Imkerin');
|
||||
// Rohstoff-Chargen sind keine Ware.
|
||||
expect(raw).not.toContain(`W-ERP-${suffix}`);
|
||||
});
|
||||
|
||||
it('blättert ohne Lücken und Dubletten', async () => {
|
||||
const all = (await allItems('chargen')).map((b) => b.id);
|
||||
const limit = Math.max(1, Math.ceil(all.length / 3));
|
||||
const seen: string[] = [];
|
||||
let nach: string | null = null;
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const query: string = nach
|
||||
? `?limit=${limit}&nach=${nach}`
|
||||
: `?limit=${limit}`;
|
||||
const body = (await get(`chargen${query}`, erpKey).expect(200))
|
||||
.body as PageBody<Item>;
|
||||
expect(body.items.length).toBeLessThanOrEqual(limit);
|
||||
seen.push(...body.items.map((b) => b.id));
|
||||
nach = body.nextCursor;
|
||||
if (!body.hasMore) break;
|
||||
}
|
||||
expect(new Set(seen).size).toBe(seen.length);
|
||||
expect(seen).toEqual(
|
||||
expect.arrayContaining([bottlingId, productionBatchId]),
|
||||
);
|
||||
// Andere Testdateien legen parallel Chargen an oder löschen welche:
|
||||
// verglichen wird die Reihenfolge der Chargen, die beide Abrufe sahen.
|
||||
const inBoth = new Set(all.filter((id) => seen.includes(id)));
|
||||
expect(seen.filter((id) => inBoth.has(id))).toEqual(
|
||||
all.filter((id) => inBoth.has(id)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bewegungen', () => {
|
||||
async function myMovements(
|
||||
seit = at(0).toISOString(),
|
||||
limit = 2,
|
||||
): Promise<{ items: Item[]; cursor: string }> {
|
||||
const mine = new Set(Object.values(movementIds));
|
||||
const found: Item[] = [];
|
||||
let query = `bewegungen?limit=${limit}&seit=${encodeURIComponent(seit)}`;
|
||||
for (let page = 0; page < 40; page++) {
|
||||
const body = (await get(query, erpKey).expect(200))
|
||||
.body as PageBody<Item>;
|
||||
found.push(...body.items.filter((m) => mine.has(m.id)));
|
||||
const last = body.items[body.items.length - 1];
|
||||
const passedMine =
|
||||
last !== undefined &&
|
||||
new Date(last.createdAt as string).getTime() > at(5).getTime();
|
||||
if (!body.hasMore || passedMine) {
|
||||
return { items: found, cursor: body.nextCursor! };
|
||||
}
|
||||
query = `bewegungen?limit=${limit}&nach=${body.nextCursor}`;
|
||||
}
|
||||
throw new Error('zu viele Seiten');
|
||||
}
|
||||
|
||||
it('liefert Entnahmen mit Grund, Shop-Verkäufe und Inventur in stabiler Reihenfolge', async () => {
|
||||
const { items } = await myMovements();
|
||||
|
||||
// Zeitpunkt, dann id: eigenbedarf und verkauf haben denselben Zeitpunkt.
|
||||
const sameTime = [movementIds.eigenbedarf, movementIds.verkauf].sort();
|
||||
expect(items.map((m) => m.id)).toEqual([
|
||||
movementIds.hofladen,
|
||||
...sameTime,
|
||||
movementIds.bruch,
|
||||
movementIds.korrektur,
|
||||
]);
|
||||
expect(items.find((m) => m.id === movementIds.hofladen)).toEqual({
|
||||
id: movementIds.hofladen,
|
||||
createdAt: at(1).toISOString(),
|
||||
batchType: 'BOTTLING',
|
||||
batchId: bottlingId,
|
||||
lotNumber: `L-ERP-${suffix}`,
|
||||
productId: honeyProductId,
|
||||
type: 'WITHDRAWAL',
|
||||
reason: 'HOFLADEN',
|
||||
quantity: -6,
|
||||
});
|
||||
expect(items.find((m) => m.id === movementIds.bruch)).toMatchObject({
|
||||
batchType: 'PRODUCTION',
|
||||
batchId: productionBatchId,
|
||||
productId: candleProductId,
|
||||
reason: 'BRUCH',
|
||||
quantity: -1,
|
||||
});
|
||||
expect(items.find((m) => m.id === movementIds.verkauf)).toMatchObject({
|
||||
type: 'SHOP_SALE',
|
||||
reason: null,
|
||||
quantity: -3,
|
||||
});
|
||||
expect(items.find((m) => m.id === movementIds.korrektur)).toMatchObject({
|
||||
type: 'CORRECTION',
|
||||
quantity: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('lässt Rohstoffe, Notizen, Benutzer und noch offene Buchungen weg', async () => {
|
||||
const { items } = await myMovements();
|
||||
const ids = items.map((m) => m.id);
|
||||
expect(ids).not.toContain(movementIds.rohstoff);
|
||||
expect(ids).not.toContain(movementIds.frisch);
|
||||
const raw = JSON.stringify(items);
|
||||
expect(raw).not.toContain('Müller');
|
||||
expect(raw).not.toContain('Erika');
|
||||
expect(raw).not.toContain('userId');
|
||||
});
|
||||
|
||||
it('nimmt seit als Kalendertag (Mitternacht UTC) und mit Zone an', async () => {
|
||||
const expected = (await myMovements()).items.map((m) => m.id);
|
||||
expect(expected.length).toBeGreaterThan(0);
|
||||
|
||||
const day = at(0).toISOString().slice(0, 10);
|
||||
const fromDay = await myMovements(day, 500);
|
||||
expect(fromDay.items.map((m) => m.id)).toEqual(expected);
|
||||
|
||||
// Derselbe Zeitpunkt wie at(0), mit +01:00 geschrieben.
|
||||
const local = new Date(at(0).getTime() + 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 23);
|
||||
const fromOffset = await myMovements(`${local}+01:00`);
|
||||
expect(fromOffset.items.map((m) => m.id)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('meldet, bis wann die Liste vollständig ist', async () => {
|
||||
const before = Date.now();
|
||||
const body = (await get('bewegungen?limit=1', erpKey).expect(200))
|
||||
.body as PageBody<Item>;
|
||||
const settled = new Date(body.settledUntil!).getTime();
|
||||
expect(settled).toBeLessThanOrEqual(before - 4 * 60 * 1000);
|
||||
expect(settled).toBeGreaterThan(before - 6 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('setzt mit dem letzten Cursor fort, ohne etwas doppelt zu liefern', async () => {
|
||||
const { cursor } = await myMovements();
|
||||
const body = (
|
||||
await get(`bewegungen?limit=500&nach=${cursor}`, erpKey).expect(200)
|
||||
).body as PageBody<Item>;
|
||||
const mine = new Set(Object.values(movementIds));
|
||||
// Nur Bewegungen hinter dem Cursor - die eigenen lagen davor.
|
||||
expect(body.items.filter((m) => mine.has(m.id))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter', () => {
|
||||
it.each([
|
||||
['produkte?limit=0'],
|
||||
['produkte?limit=501'],
|
||||
['produkte?limit=zwei'],
|
||||
['produkte?nach=kaputt!'],
|
||||
['produkte?seite=2'],
|
||||
['chargen?nach=bm90LWpzb24'],
|
||||
['bewegungen?seit=gestern'],
|
||||
// Andere ISO-8601-Formen: früher 500 (Woche, Kurzform) oder falsch gelesen.
|
||||
['bewegungen?seit=2026-W01'],
|
||||
['bewegungen?seit=20260101'],
|
||||
['bewegungen?seit=-2026-01-01'],
|
||||
['bewegungen?seit=2026-001'],
|
||||
['bewegungen?seit=2026-01-01T24:00'],
|
||||
['bewegungen?seit=9999-12-31T00:00:00Z'],
|
||||
['ernten?jahr=1800'],
|
||||
['ernten?jahr=dieses'],
|
||||
])('lehnt %s ab (400)', async (path) => {
|
||||
await get(path, erpKey).expect(400);
|
||||
});
|
||||
|
||||
it('lehnt einen Cursor eines anderen Endpunkts ab (400)', async () => {
|
||||
const productCursor = encodeCursor({ k: 'produkte', id: honeyProductId });
|
||||
await get(`bewegungen?nach=${productCursor}`, erpKey).expect(400);
|
||||
});
|
||||
|
||||
it('lehnt einen Bewegungs-Cursor mit unmöglichem Jahr ab (400 statt 500)', async () => {
|
||||
const cursor = encodeCursor({
|
||||
k: 'bewegungen',
|
||||
ts: '+275760-09-13T00:00:00.000Z',
|
||||
id: movementIds.hofladen,
|
||||
});
|
||||
await get(`bewegungen?nach=${cursor}`, erpKey).expect(400);
|
||||
});
|
||||
|
||||
it('verträgt ein fernes seit innerhalb der Jahresgrenzen (leere Seite statt Fehler)', async () => {
|
||||
const body = (
|
||||
await get('bewegungen?seit=2100-12-31T00:00:00Z', erpKey).expect(200)
|
||||
).body as PageBody<Item>;
|
||||
expect(body.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('lehnt seit und nach zusammen ab (400)', async () => {
|
||||
const cursor = encodeCursor({
|
||||
k: 'bewegungen',
|
||||
ts: at(1).toISOString(),
|
||||
id: movementIds.hofladen,
|
||||
});
|
||||
await get(
|
||||
`bewegungen?seit=${at(0).toISOString()}&nach=${cursor}`,
|
||||
erpKey,
|
||||
).expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ernten', () => {
|
||||
it('summiert das Nettogewicht eines Jahres und weist Ernten ohne Gewicht aus', async () => {
|
||||
const year = 2003;
|
||||
const read = async () =>
|
||||
(
|
||||
(await get(`ernten?jahr=${year}`, erpKey).expect(200))
|
||||
.body as HarvestYearsBody
|
||||
).years;
|
||||
const before = await read();
|
||||
expect(before).toHaveLength(1);
|
||||
|
||||
const owner = await prisma.user.findFirstOrThrow({
|
||||
where: { email: `erp-admin-${suffix}@example.com` },
|
||||
});
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: { name: `Ernte-Stand ${suffix}` },
|
||||
});
|
||||
for (const [day, netWeight] of [
|
||||
['2003-06-01', 12.5],
|
||||
['2003-07-15', 7.25],
|
||||
['2003-08-30', null],
|
||||
['2004-01-01', 99],
|
||||
] as const) {
|
||||
await prisma.harvest.create({
|
||||
data: {
|
||||
apiaryId: apiary.id,
|
||||
userId: owner.id,
|
||||
date: new Date(`${day}T00:00:00.000Z`),
|
||||
netWeight,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [after] = await read();
|
||||
expect(after.year).toBe(year);
|
||||
expect(after.netWeightKg - before[0].netWeightKg).toBeCloseTo(19.75, 3);
|
||||
expect(after.harvestCount - before[0].harvestCount).toBe(3);
|
||||
expect(
|
||||
after.harvestsWithoutNetWeight - before[0].harvestsWithoutNetWeight,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('liefert ohne Jahr alle Jahre aufsteigend, ohne Standorte', async () => {
|
||||
const body = (await get('ernten', erpKey).expect(200))
|
||||
.body as HarvestYearsBody;
|
||||
const years = body.years.map((y) => y.year);
|
||||
expect(years).toEqual([...years].sort((a, b) => a - b));
|
||||
expect(years).toContain(2026);
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain('Waldrand');
|
||||
expect(raw).not.toContain('Volk 7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fehlversuche', () => {
|
||||
it('sperrt nach zu vielen unbekannten Schlüsseln weitere ungültige mit 429, andere Adressen nicht', async () => {
|
||||
const attacker = '203.0.113.50';
|
||||
const wrong = `ack_abcd1234_${'Y'.repeat(43)}`;
|
||||
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||
await get('produkte', wrong, attacker).expect(401);
|
||||
}
|
||||
const blocked = await get('produkte', wrong, attacker).expect(429);
|
||||
expect(blocked.body).toMatchObject({
|
||||
statusCode: 429,
|
||||
message: expect.stringContaining('Fehlversuche') as string,
|
||||
});
|
||||
await get('produkte', wrong, '203.0.113.51').expect(401);
|
||||
});
|
||||
|
||||
it('lässt einen gültigen Schlüssel auch von einer gesperrten Adresse durch', async () => {
|
||||
// Sonst könnte jeder, der die gemeinsame Adresse (Cluster-Ausgang,
|
||||
// Proxy) nutzt oder fälscht, ERPNext oder den Shop aussperren.
|
||||
const shared = '203.0.113.60';
|
||||
const wrong = `ack_abcd1234_${'X'.repeat(43)}`;
|
||||
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||
await get('produkte', wrong, shared).expect(401);
|
||||
}
|
||||
await get('produkte', wrong, shared).expect(429);
|
||||
await get('produkte', erpKey, shared).expect(200);
|
||||
await request(server())
|
||||
.get('/api/v1/shop-sync/stock')
|
||||
.set('Authorization', `Bearer ${shopKey}`)
|
||||
.set('X-Forwarded-For', shared)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('zählt Fehlversuche über alle API-Pods gemeinsam', async () => {
|
||||
// Die API läuft mit mehreren Pods: ein Angreifer darf sein Budget
|
||||
// nicht vervielfachen, indem der Load-Balancer ihn verteilt.
|
||||
const secondPod = await createTestApp({ throttlerNamespace });
|
||||
try {
|
||||
const ip = '203.0.113.80';
|
||||
const wrong = `ack_abcd1234_${'V'.repeat(43)}`;
|
||||
const via = (pod: INestApplication<App>) =>
|
||||
request(pod.getHttpServer())
|
||||
.get(`${BASE}/produkte`)
|
||||
.set('Authorization', `Bearer ${wrong}`)
|
||||
.set('X-Forwarded-For', ip);
|
||||
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||
await via(i % 2 ? secondPod : app).expect(401);
|
||||
}
|
||||
await via(app).expect(429);
|
||||
await via(secondPod).expect(429);
|
||||
// Ein gültiger Schlüssel kommt auf beiden Pods weiter durch.
|
||||
await request(secondPod.getHttpServer())
|
||||
.get(`${BASE}/produkte`)
|
||||
.set('Authorization', `Bearer ${erpKey}`)
|
||||
.set('X-Forwarded-For', ip)
|
||||
.expect(200);
|
||||
} finally {
|
||||
await secondPod.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('zählt fehlerhaft aufgebaute Schlüssel nicht', async () => {
|
||||
const ip = '203.0.113.70';
|
||||
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures + 5; i++) {
|
||||
await get('produkte', 'ack_x', ip).expect(401);
|
||||
}
|
||||
await get('produkte', `ack_abcd1234_${'W'.repeat(43)}`, ip).expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user