Brings the ERP inventory export up to date with the platform backbone and field capture work. Conflicts in ARCHITECTURE.md, the API key service and the API key settings tab resolved by keeping both sides: - read and write keys stay separate; erp-inventory:read is read-only and cannot be combined with a write scope - the failed-attempt lockout counts in the shared ThrottlerHit table so it holds across API pods - the UI offers only scopes the user holds and creates one scope per key Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
740 lines
25 KiB
TypeScript
740 lines
25 KiB
TypeScript
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(): Promise<{ items: Item[]; cursor: string }> {
|
|
const mine = new Set(Object.values(movementIds));
|
|
const found: Item[] = [];
|
|
let query = `bewegungen?limit=2&seit=${at(0).toISOString()}`;
|
|
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=2&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('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'],
|
|
['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 (leere Seite statt Fehler)', async () => {
|
|
const body = (
|
|
await get('bewegungen?seit=9999-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);
|
|
});
|
|
});
|
|
});
|