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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:44:03 +02:00
commit 2d5c740f7f
33 files changed
+3933 -106

No files matched your search

@@ -0,0 +1,3 @@
-- ERP-Export /bewegungen sortiert und blättert nach (createdAt, id).
-- CreateIndex
CREATE INDEX "BatchMovement_createdAt_id_idx" ON "BatchMovement"("createdAt", "id");
+2
View File
@@ -1210,6 +1210,8 @@ model BatchMovement {
@@index([productionBatchId])
@@index([rawMaterialLotId])
@@index([shopRequestId])
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
@@index([createdAt, id])
}
enum ShopStockRequestKind {
+23 -2
View File
@@ -6,16 +6,37 @@ import { PermissionKey } from '../auth/permissions/permission-catalog';
* only ever gets what a machine integration needs. shop-sync:write lets the
* shop book its own sales and returns - nothing else - and every booking is
* idempotent and kept in the ledger, so a leaked key cannot erase history.
* Removing an entry here also takes it away from existing keys (see
* ApiKeysService.authenticate).
* erp-inventory:read lets ERPNext read the inventory mirror (articles,
* batches with remaining stock, stock movements, harvest totals) - read
* only, no people, no notes. Removing an entry here also takes it away from
* existing keys (see ApiKeysService.authenticate).
*/
export const API_KEY_SCOPES = [
'shop-sync:read',
'shop-sync:write',
'erp-inventory:read',
] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
/**
* Scopes that book (change data). Every other scope only reads. A new key
* never mixes both kinds: a leaked read key cannot book, a leaked booking
* key cannot read (ARCHITECTURE.md, Annahme 14).
*/
export const API_KEY_WRITE_SCOPES = [
'shop-sync:write',
] as const satisfies readonly ApiKeyScope[];
export function isApiKeyScope(value: string): value is ApiKeyScope {
return (API_KEY_SCOPES as readonly string[]).includes(value);
}
function isWriteScope(scope: string): boolean {
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
}
/** True if the scopes hold at least one reading and one booking right. */
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
}
@@ -1,12 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
// ThrottlerStorageModule: the failure counters share the rate-limit table,
// so the limit holds across all api pods.
@Global()
@Module({
imports: [ThrottlerStorageModule],
controllers: [ApiKeysController],
providers: [ApiKeysService],
providers: [ApiKeysService, FailedAttemptLimiter],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
@@ -1,8 +1,265 @@
import { BadRequestException } from '@nestjs/common';
import { ApiKeysService } from './api-keys.service';
import {
BadRequestException,
ForbiddenException,
HttpException,
Logger,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
function storedKey(overrides: Record<string, unknown> = {}) {
return {
id: 'key-1',
name: 'ERPNext',
prefix: 'ack_abcd1234',
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
scopes: ['erp-inventory:read'],
createdByUserId: 'user-1',
createdAt: new Date('2026-10-01T00:00:00.000Z'),
lastUsedAt: null,
lastUsedIp: null,
allowedIps: [],
expiresAt: null,
revokedAt: null,
...overrides,
};
}
/**
* Stand-in for the shared counter table with the same counting rule as
* PrismaThrottlerStorage within one window: count up to the limit, the hit
* after it blocks, a blocked source keeps its count. Time and the database
* itself are covered by the e2e tests.
*/
function inMemoryStorage(): PrismaThrottlerStorage {
const rows = new Map<string, { hits: number; blocked: boolean }>();
return {
increment: (
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
) => {
const id = `${throttlerName}|${key}`;
const row = rows.get(id) ?? { hits: 0, blocked: false };
const next = row.blocked
? row
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
rows.set(id, next);
return Promise.resolve({
totalHits: next.hits,
timeToExpire: ttl / 1000,
isBlocked: next.blocked,
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
});
},
} as unknown as PrismaThrottlerStorage;
}
describe('ApiKeysService.authenticate', () => {
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
let service: ApiKeysService;
let warn: jest.SpyInstance;
beforeEach(() => {
prisma = {
apiKey: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
},
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});
function loggedText(): string {
return JSON.stringify(warn.mock.calls);
}
it('sucht den Schlüssel nur über seinen Hash', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
});
});
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
null,
);
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
});
it('lehnt einen unbekannten Schlüssel ab', async () => {
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
});
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('revoked');
});
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('expired');
});
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date() }),
);
await service.authenticate(TOKEN, '203.0.113.7');
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
const text = loggedText();
expect(text).not.toContain(TOKEN);
expect(text).not.toContain(OTHER_TOKEN);
expect(text).not.toContain('A'.repeat(43));
expect(text).not.toContain('B'.repeat(43));
expect(text).toContain('ack_abcd1234');
});
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
);
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal?.scopes).toEqual(['erp-inventory:read']);
});
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ allowedIps: ['198.51.100.0/24'] }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toBeInstanceOf(ForbiddenException);
});
async function failFrom(ip: string, times: number): Promise<void> {
for (let i = 0; i < times; i++) {
// Unbekannter, aber richtig aufgebauter Schlüssel.
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
}
}
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
await expect(blocked).rejects.toBeInstanceOf(HttpException);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
expect(loggedText()).toContain('203.0.113.7');
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
// Andere Adressen sind nicht betroffen.
await expect(
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
).resolves.toBe(null);
});
it('meldet das Erreichen der Grenze einmal im Log', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
const limitMessages = warn.mock.calls.filter(([message]) =>
String(message).includes('get 429'),
);
expect(limitMessages).toHaveLength(1);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
id: 'key-1',
scopes: ['erp-inventory:read'],
});
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
null,
);
}
// Weder gesperrt noch eine Datenbankabfrage.
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).resolves.toBe(null);
});
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await service.authenticate(
OTHER_TOKEN,
`2001:db8:1:2::${i.toString(16)}`,
);
}
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
).rejects.toMatchObject({ status: 429 });
// Ein anderes /64 ist frei.
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
).resolves.toBe(null);
});
});
describe('ApiKeysService.create', () => {
let service: ApiKeysService;
@@ -19,12 +276,15 @@ describe('ApiKeysService.create', () => {
const permissionResolver = {
getPermissions: jest
.fn()
.mockResolvedValue(new Set(['shop-sync:read', 'shop-sync:write'])),
.mockResolvedValue(
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
),
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
});
@@ -45,7 +305,46 @@ describe('ApiKeysService.create', () => {
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it.each(['shop-sync:read', 'shop-sync:write'])(
it('refuses the read-only ERP scope together with booking', async () => {
await expect(
service.create(
{
name: 'ERPNext',
scopes: ['erp-inventory:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('refuses a new key with more than one scope, even if all of them read', async () => {
await expect(
service.create(
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('treats the same scope given twice as one', async () => {
const created = await service.create(
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
'user-1',
);
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
});
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
'still creates a key with only %s',
async (scope) => {
const created = await service.create(
@@ -56,3 +355,11 @@ describe('ApiKeysService.create', () => {
},
);
});
describe('extractApiKeyToken', () => {
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
expect(extractApiKeyToken(undefined)).toBeNull();
});
});
@@ -1,6 +1,8 @@
import {
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
Logger,
NotFoundException,
@@ -10,10 +12,19 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
import {
ApiKeyScope,
combinesReadAndWrite,
isApiKeyScope,
} from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
return [
@@ -31,6 +42,11 @@ const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
// lastUsedAt is informational; writing it on every request would turn each
// read into a write for no benefit.
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
// "ack_" plus the 8-character prefix: identifies a key in logs without
// revealing its secret part.
const LOGGABLE_PREFIX_LENGTH = 12;
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */
export interface ApiKeyPrincipal {
@@ -73,6 +89,8 @@ export class ApiKeysService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
// Counters shared by all api pods (ThrottlerHit), not process memory.
private readonly failures: FailedAttemptLimiter,
) {}
async findAll(): Promise<ApiKeySummary[]> {
@@ -86,16 +104,19 @@ export class ApiKeysService {
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// Reading and booking are separate keys: a leaked read key cannot book,
// a leaked booking key cannot read. Existing combined keys stay valid.
if (
dto.scopes.includes('shop-sync:read') &&
dto.scopes.includes('shop-sync:write')
) {
// One scope per new key: a leaked read key cannot book, a leaked booking
// key cannot read, and each connected system gets its own key.
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
if (combinesReadAndWrite(dto.scopes)) {
throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
);
}
if (new Set(dto.scopes).size > 1) {
throw new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
);
}
// Nobody hands a machine more than they may do themselves.
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
@@ -187,6 +208,13 @@ export class ApiKeysService {
/**
* Resolves a presented key to what it may do, or null for anything that
* must not pass: malformed, unknown, revoked or expired.
*
* The key is found by the SHA-256 of the presented token through a unique
* index - never by comparing secrets byte by byte, so response timing says
* nothing about the stored key. Order matters: the lookup comes first, and
* only an invalid key counts against (or is stopped by) the failure limit
* of the client network. A malformed token is not a guess at a key and is
* refused without lookup and without counting.
*/
async authenticate(
token: string,
@@ -199,13 +227,53 @@ export class ApiKeysService {
where: { keyHash: hashToken(token) },
});
const now = new Date();
if (
!apiKey ||
apiKey.revokedAt ||
(apiKey.expiresAt && apiKey.expiresAt <= now)
) {
return null;
const reason = rejectReason(apiKey, now);
if (reason === null && apiKey) {
return this.accept(apiKey, clientIp, now);
}
await this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private async recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): Promise<void> {
// Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
const verdict = await this.failures.recordFailure(subject);
if (verdict.blocked) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly.
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
error: 'Too Many Requests',
message:
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
// Only the public prefix is ever logged, never the key itself.
this.logger.warn(
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (verdict.limitReached) {
this.logger.warn(
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
);
}
}
private async accept(
apiKey: ApiKey,
clientIp: string | undefined,
now: Date,
): Promise<ApiKeyPrincipal> {
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
// The key itself is right: say so, so the admin finds the cause - a
// thief learns nothing new, they already hold the key.
@@ -232,6 +300,13 @@ export class ApiKeysService {
}
}
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
if (!apiKey) return 'unknown';
if (apiKey.revokedAt) return 'revoked';
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
return null;
}
/** The key from an `Authorization: Bearer ack_…` header, or null. */
export function extractApiKeyToken(
authorization: string | undefined,
@@ -0,0 +1,91 @@
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import {
API_KEY_FAILURE_LIMIT,
API_KEY_FAILURE_THROTTLER,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
describe('FailedAttemptLimiter', () => {
const increment = jest.fn();
const limiter = new FailedAttemptLimiter({
increment,
} as unknown as PrismaThrottlerStorage);
function record(totalHits: number, isBlocked: boolean) {
return {
totalHits,
timeToExpire: 600,
isBlocked,
timeToBlockExpire: isBlocked ? 900 : 0,
};
}
beforeEach(() => {
increment.mockReset();
increment.mockResolvedValue(record(1, false));
});
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
await limiter.recordFailure('203.0.113.7');
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
expect(increment).toHaveBeenCalledTimes(1);
expect(increment).toHaveBeenCalledWith(
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_LIMIT.maxFailures,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_THROTTLER,
);
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
});
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
expect(API_KEY_FAILURE_LIMIT).toEqual({
maxFailures: 20,
windowMs: 15 * 60 * 1000,
});
});
it('sperrt nicht unterhalb der Grenze', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: false,
});
});
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: true,
});
});
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: true,
limitReached: false,
});
});
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
increment.mockRejectedValueOnce(new Error('db down'));
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
'db down',
);
});
});
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys - the same budget the ERP export
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
} as const;
/**
* Throttler name and key prefix of these counters in "ThrottlerHit", so
* they never mix with the request rate limits stored in the same table.
*/
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
export interface FailedAttemptVerdict {
/** The source is over the limit: refuse the request with 429. */
blocked: boolean;
/** This failure is the one that reached the limit (once per window). */
limitReached: boolean;
}
/**
* Counts invalid API keys per source (client network) in the shared,
* database-backed rate-limit storage, so the limit holds across all api
* pods. One upsert per failure, every time comparison on the database
* clock (PrismaThrottlerStorage). The window starts with the first failure;
* the failure after the limit blocks the source for one window length.
* A database error propagates: the request fails instead of passing.
*/
@Injectable()
export class FailedAttemptLimiter {
constructor(private readonly storage: PrismaThrottlerStorage) {}
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
const record = await this.storage.increment(
`${API_KEY_FAILURE_THROTTLER}:${source}`,
windowMs,
maxFailures,
windowMs,
API_KEY_FAILURE_THROTTLER,
);
return {
blocked: record.isBlocked,
limitReached: !record.isBlocked && record.totalHits === maxFailures,
};
}
}
+53
View File
@@ -65,3 +65,56 @@ describe('configureApp trust proxy logging', () => {
log.mockRestore();
});
});
describe('configureApp trust proxy warning', () => {
const originalProxy = process.env.TRUST_PROXY;
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = originalProxy;
if (originalEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalEnv;
});
function configure(): jest.SpyInstance {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
return warn;
}
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
const calls = warn.mock.calls as unknown[][];
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
}
afterEach(() => jest.restoreAllMocks());
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
process.env.NODE_ENV = 'production';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(1);
});
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
process.env.NODE_ENV = 'production';
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
it('warnt nicht in der Entwicklung', () => {
process.env.NODE_ENV = 'development';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
});
+11 -3
View File
@@ -50,9 +50,17 @@ export function configureApp(app: NestExpressApplication): void {
// to work correctly rather than rate-limiting all traffic together.
const trustProxy = trustProxySetting();
app.set('trust proxy', trustProxy);
new Logger('Bootstrap').log(
describeTrustProxy(trustProxy, !!process.env.TRUST_PROXY?.trim()),
);
const bootstrapLogger = new Logger('Bootstrap');
const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
// proxy, so rate limits, API-key failure limits and IP allowlists act
// on a shared address instead of the client.
bootstrapLogger.warn(
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml',
);
}
// No ETags: they let caches and proxies revalidate (and keep) personal
// responses that are meant to be no-store.
app.set('etag', false);
+2
View File
@@ -51,6 +51,7 @@ import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { SystemModule } from './system/system.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
@@ -116,6 +117,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
SiteSettingsModule,
BackupModule,
ShopSyncModule,
ErpExportModule,
ApiKeysModule,
SystemModule,
],
@@ -370,6 +370,15 @@ export const PERMISSION_CATALOG = [
],
3500,
),
// Like shop-sync: a narrow read-only allowlist for one machine consumer
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
// which return descriptions and full records.
...group(
'ERP-Anbindung',
'erp-inventory',
[['read', 'Inventar-Spiegel für ERPNext lesen']],
3600,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -0,0 +1,110 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
MAX_PAGE_SIZE,
} from './erp-query.dto';
async function errorsOf<T extends object>(
cls: new () => T,
query: Record<string, string>,
): Promise<string[]> {
const errors = await validate(plainToInstance(cls, query), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('ERP-Abfrageparameter', () => {
it('nimmt eine leere Abfrage an', async () => {
expect(await errorsOf(ErpPageQueryDto, {})).toEqual([]);
expect(await errorsOf(ErpMovementsQueryDto, {})).toEqual([]);
expect(await errorsOf(ErpHarvestsQueryDto, {})).toEqual([]);
});
it('wandelt limit in eine Zahl um', () => {
const dto = plainToInstance(ErpPageQueryDto, { limit: '25' });
expect(dto.limit).toBe(25);
});
it.each(['0', '-1', String(MAX_PAGE_SIZE + 1), '2.5', 'viele'])(
'lehnt limit=%s ab',
async (limit) => {
expect(await errorsOf(ErpPageQueryDto, { limit })).toEqual(['limit']);
},
);
it('nimmt limit an der Obergrenze an', async () => {
expect(
await errorsOf(ErpPageQueryDto, { limit: String(MAX_PAGE_SIZE) }),
).toEqual([]);
});
it.each(["abc'def", 'a b', 'a'.repeat(513), 'abc=='])(
'lehnt den Cursor %s ab',
async (nach) => {
expect(await errorsOf(ErpPageQueryDto, { nach })).toEqual(['nach']);
},
);
it.each([
'2026-01-01',
'2026-01-01T00:00Z',
'2026-01-01T00:00:00Z',
'2026-01-01T00:00:00.123Z',
'2026-01-01T01:00:00+01:00',
'2025-12-31T22:30:00.5-02:30',
'2000-01-01',
'2100-12-31T23:59:59Z',
])('nimmt seit=%s an', async (seit) => {
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual([]);
});
it.each([
'gestern',
'2026-13-01',
'01.01.2026',
// Andere ISO-8601-Formen: führten zu 500 oder wurden falsch gelesen.
'2026-W01',
'20260101',
'-2026-01-01',
'+002026-01-01',
'2026-001',
// Uhrzeit ohne Zone wäre Ortszeit des Servers.
'2026-01-01T00:00',
'2026-01-01T24:00',
'2026-01-01T24:00Z',
'2026-01-01T23:60Z',
'2026-01-01T23:59:60Z',
'2026-01-01T00:00:00+24:00',
'2026-01-01 00:00:00Z',
'2026-02-30',
// Gleiche Jahresgrenzen wie der Cursor: sonst 500 in der Datenbank.
'1999-12-31',
'2101-01-01',
'2100-12-31T23:00:00-02:00',
'9999-12-31T00:00:00Z',
])('lehnt seit=%s ab', async (seit) => {
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual(['seit']);
});
it.each(['1899', '2101', '2026.5', 'dieses'])(
'lehnt jahr=%s ab',
async (jahr) => {
expect(await errorsOf(ErpHarvestsQueryDto, { jahr })).toEqual(['jahr']);
},
);
it('wandelt jahr in eine Zahl um', () => {
expect(plainToInstance(ErpHarvestsQueryDto, { jahr: '2026' }).jahr).toBe(
2026,
);
});
it('lehnt unbekannte Parameter ab', async () => {
expect(await errorsOf(ErpPageQueryDto, { seite: '2' })).toEqual(['seite']);
});
});
@@ -0,0 +1,86 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateBy,
} from 'class-validator';
import {
ERP_MAX_YEAR,
ERP_MIN_YEAR,
parseSeit,
SEIT_PATTERN,
} from '../erp-time';
export const DEFAULT_PAGE_SIZE = 100;
export const MAX_PAGE_SIZE = 500;
const MAX_CURSOR_LENGTH = 512;
/** Seitenweises Lesen: `nach` = nextCursor der vorigen Seite, `limit` = Seitengröße. */
export class ErpPageQueryDto {
@ApiPropertyOptional({
description:
'nextCursor der vorigen Antwort, unverändert. Ohne Angabe beginnt die Liste von vorn.',
})
@IsOptional()
@IsString()
@MaxLength(MAX_CURSOR_LENGTH)
@Matches(/^[A-Za-z0-9_-]+$/, {
message: 'nach muss ein Cursor aus einer vorigen Antwort sein',
})
nach?: string;
@ApiPropertyOptional({
description: `Einträge je Seite, 1 bis ${MAX_PAGE_SIZE}`,
default: DEFAULT_PAGE_SIZE,
minimum: 1,
maximum: MAX_PAGE_SIZE,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_PAGE_SIZE)
limit?: number;
}
export class ErpMovementsQueryDto extends ErpPageQueryDto {
@ApiPropertyOptional({
description: `Nur Bewegungen ab diesem Zeitpunkt (einschließlich): JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm, Jahr ${ERP_MIN_YEAR}–${ERP_MAX_YEAR}. Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".`,
example: '2026-01-01T00:00:00Z',
})
@IsOptional()
@IsString()
@Matches(SEIT_PATTERN, {
message:
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
})
@ValidateBy({
name: 'isErpSeit',
validator: {
validate: (value: unknown) =>
typeof value === 'string' && parseSeit(value) !== null,
defaultMessage: () =>
`seit muss ein gültiger Zeitpunkt in den Jahren ${ERP_MIN_YEAR} bis ${ERP_MAX_YEAR} sein`,
},
})
seit?: string;
}
export class ErpHarvestsQueryDto {
@ApiPropertyOptional({
description: 'Nur dieses Kalenderjahr; ohne Angabe alle Jahre',
example: 2026,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1900)
@Max(2100)
jahr?: number;
}
@@ -0,0 +1,93 @@
import { BadRequestException } from '@nestjs/common';
import { decodeCursor, encodeCursor } from './erp-cursor';
function raw(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
describe('ERP-Cursor', () => {
it('liefert jeden Cursor unverändert zurück (Rundreise)', () => {
const product = { k: 'produkte', id: 'cmabc123' } as const;
const batch = { k: 'chargen', t: 'P', id: 'cmdef456' } as const;
const movement = {
k: 'bewegungen',
ts: '2026-10-01T08:15:30.123Z',
id: 'cmghi789',
} as const;
expect(decodeCursor(encodeCursor(product), 'produkte')).toEqual(product);
expect(decodeCursor(encodeCursor(batch), 'chargen')).toEqual(batch);
expect(decodeCursor(encodeCursor(movement), 'bewegungen')).toEqual(
movement,
);
});
it('ist URL-sicher (base64url ohne Auffüllzeichen)', () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T08:15:30.123Z',
id: 'cmghi789',
});
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('lehnt einen Cursor eines anderen Endpunkts ab', () => {
const productCursor = encodeCursor({ k: 'produkte', id: 'cmabc123' });
expect(() => decodeCursor(productCursor, 'bewegungen')).toThrow(
BadRequestException,
);
});
it.each([
['kein base64/JSON', 'produkte', 'nicht-json'],
['JSON, aber kein Objekt', 'produkte', raw([1, 2])],
['fehlende id', 'produkte', raw({ k: 'produkte' })],
[
'id mit fremden Zeichen',
'produkte',
raw({ k: 'produkte', id: "x' OR 1" }),
],
['id zu lang', 'produkte', raw({ k: 'produkte', id: 'a'.repeat(65) })],
['zusätzliche Felder', 'produkte', raw({ k: 'produkte', id: 'c1', x: 1 })],
[
'unbekannte Chargenart',
'chargen',
raw({ k: 'chargen', t: 'X', id: 'c1' }),
],
[
'ungültiger Zeitstempel',
'bewegungen',
raw({ k: 'bewegungen', ts: 'gestern', id: 'c1' }),
],
[
'Zeitstempel nicht kanonisch',
'bewegungen',
raw({ k: 'bewegungen', ts: '2026-10-01', id: 'c1' }),
],
[
'Jahr jenseits des Datenbankbereichs',
'bewegungen',
raw({ k: 'bewegungen', ts: '+275760-09-13T00:00:00.000Z', id: 'c1' }),
],
[
'Jahr vor 2000',
'bewegungen',
raw({ k: 'bewegungen', ts: '1999-12-31T23:59:59.999Z', id: 'c1' }),
],
[
'Jahr nach 2100',
'bewegungen',
raw({ k: 'bewegungen', ts: '2101-01-01T00:00:00.000Z', id: 'c1' }),
],
] as const)('lehnt ab: %s', (_label, kind, cursor) => {
expect(() => decodeCursor(cursor, kind)).toThrow(BadRequestException);
});
it.each(['2000-01-01T00:00:00.000Z', '2100-12-31T23:59:59.999Z'])(
'nimmt den Rand des erlaubten Bereichs an: %s',
(ts) => {
const cursor = encodeCursor({ k: 'bewegungen', ts, id: 'c1' });
expect(decodeCursor(cursor, 'bewegungen').ts).toBe(ts);
},
);
});
@@ -0,0 +1,87 @@
import { BadRequestException } from '@nestjs/common';
import { isPlausibleErpDate } from './erp-time';
/**
* Blätter-Cursor des ERP-Exports: die Position hinter dem letzten gelieferten
* Eintrag, als base64url-kodiertes JSON. Für den Aufrufer undurchsichtig - er
* reicht ihn nur unverändert als `nach` zurück.
*
* Jeder Cursor nennt seinen Endpunkt (`k`): ein Produkt-Cursor an den
* Bewegungen wäre sonst stillschweigend eine falsche Position. Was hier
* herauskommt, landet in einer Datenbankabfrage, deshalb wird jedes Feld
* streng geprüft statt nur gecastet.
*/
export type ErpCursor =
| { k: 'produkte'; id: string }
| { k: 'chargen'; t: 'B' | 'P'; id: string }
| { k: 'bewegungen'; ts: string; id: string };
export type ErpCursorKind = ErpCursor['k'];
type CursorOf<K extends ErpCursorKind> = Extract<ErpCursor, { k: K }>;
// cuid-artige Kennungen; großzügig, aber ohne Zeichen, die in einer Abfrage
// etwas anderes bedeuten könnten.
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const FIELDS: Record<ErpCursorKind, readonly string[]> = {
produkte: ['id', 'k'],
chargen: ['id', 'k', 't'],
bewegungen: ['id', 'k', 'ts'],
};
function invalid(): BadRequestException {
return new BadRequestException(
'Ungültiger Cursor - bitte nextCursor der vorigen Seite unverändert als "nach" übergeben',
);
}
export function encodeCursor(cursor: ErpCursor): string {
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
}
function parse(raw: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
throw invalid();
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalid();
}
return value as Record<string, unknown>;
}
function isCanonicalTimestamp(value: unknown): value is string {
if (typeof value !== 'string') return false;
// Nur die eigene Form (toISOString) und nur plausible Jahre (erp-time.ts).
const date = new Date(value);
return isPlausibleErpDate(date) && date.toISOString() === value;
}
/** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */
export function decodeCursor<K extends ErpCursorKind>(
raw: string,
kind: K,
): CursorOf<K> {
const value = parse(raw);
const keys = Object.keys(value).sort();
const expected = FIELDS[kind];
if (
value.k !== kind ||
keys.length !== expected.length ||
keys.some((key, index) => key !== expected[index]) ||
typeof value.id !== 'string' ||
!ID_PATTERN.test(value.id)
) {
throw invalid();
}
if (kind === 'chargen' && value.t !== 'B' && value.t !== 'P') {
throw invalid();
}
if (kind === 'bewegungen' && !isCanonicalTimestamp(value.ts)) {
throw invalid();
}
return value as CursorOf<K>;
}
@@ -0,0 +1,82 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiForbiddenResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import {
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
} from './dto/erp-query.dto';
import {
ErpBatch,
ErpExportService,
ErpHarvestYears,
ErpMovementPage,
ErpPage,
ErpProduct,
} from './erp-export.service';
/**
* Inventar-Spiegel für ERPNext, nur lesend. Vertrag mit Feldern, Einheiten,
* Paginierung und Fehlercodes: docs/erp-inventar-export.md.
*/
@ApiTags('erp-export')
@ApiBearerAuth()
@ApiUnauthorizedResponse({
description: 'Kein, unbekannter, widerrufener oder abgelaufener Schlüssel',
})
@ApiForbiddenResponse({
description:
'Schlüssel ohne erp-inventory:read oder von einer nicht freigegebenen IP-Adresse',
})
@ApiTooManyRequestsResponse({
description: 'Zu viele Anfragen oder Fehlversuche von dieser Adresse',
})
@ApiBadRequestResponse({ description: 'Ungültiger Parameter oder Cursor' })
@RequirePermission('erp-inventory:read')
@Controller('export/inventar')
export class ErpExportController {
constructor(private readonly erpExport: ErpExportService) {}
@ApiOperation({ summary: 'Artikel (ApiCore-Produkte), nach id sortiert' })
@ApiOkResponse({ description: 'Eine Seite Artikel' })
@Get('produkte')
products(@Query() query: ErpPageQueryDto): Promise<ErpPage<ErpProduct>> {
return this.erpExport.products(query);
}
@ApiOperation({
summary:
'Chargen (Abfüllungen, dann Herstellungschargen) mit Ausgangsmenge und Rest',
})
@ApiOkResponse({ description: 'Eine Seite Chargen' })
@Get('chargen')
batches(@Query() query: ErpPageQueryDto): Promise<ErpPage<ErpBatch>> {
return this.erpExport.batches(query);
}
@ApiOperation({
summary:
'Bestandsbewegungen (Shop-Verkauf, Retoure, Entnahme mit Grund, Inventur), älteste zuerst',
})
@ApiOkResponse({ description: 'Eine Seite Bewegungen' })
@Get('bewegungen')
movements(@Query() query: ErpMovementsQueryDto): Promise<ErpMovementPage> {
return this.erpExport.movements(query);
}
@ApiOperation({ summary: 'Geerntete kg (netto) je Kalenderjahr' })
@ApiOkResponse({ description: 'Erntemengen je Jahr' })
@Get('ernten')
harvests(@Query() query: ErpHarvestsQueryDto): Promise<ErpHarvestYears> {
return this.erpExport.harvestYears(query);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ErpExportController } from './erp-export.controller';
import { ErpExportService } from './erp-export.service';
@Module({
imports: [StockModule],
controllers: [ErpExportController],
providers: [ErpExportService],
})
export class ErpExportModule {}
@@ -0,0 +1,572 @@
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { StockService } from '../stock/stock.service';
import { decodeCursor, encodeCursor } from './erp-cursor';
import {
ERP_INVENTORY_CONTRACT_VERSION,
ErpExportService,
MOVEMENT_SETTLE_MS,
} from './erp-export.service';
type FindManyArgs = {
where?: Record<string, unknown>;
select?: Record<string, unknown>;
orderBy?: unknown;
take?: number;
};
const NOW = new Date('2026-10-12T02:00:00.000Z');
// Die Datenbank-Uhr geht hier bewusst anders als die Uhr des Pods (NOW):
// die Wartezeit muss von der Datenbank kommen.
const DB_SETTLED_UNTIL = new Date('2026-10-12T01:52:30.000Z');
function bottlingRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
lotNumber: `L-${id}`,
productId: 'prod-honig',
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
jarCount: 60,
jarSize: 500,
honeyBatch: { honeyType: 'Sommertracht' },
...overrides,
};
}
function productionRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
lotNumber: `K-${id}`,
productId: 'prod-kerze',
date: new Date('2026-09-01T00:00:00.000Z'),
bestBeforeDate: null,
quantity: 20,
...overrides,
};
}
function movementRow(
id: string,
createdAt: string,
overrides: Record<string, unknown> = {},
) {
return {
id,
createdAt: new Date(createdAt),
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -2,
bottlingBatchId: 'b1',
productionBatchId: null,
bottlingBatch: { lotNumber: 'L-b1', productId: 'prod-honig' },
productionBatch: null,
...overrides,
};
}
describe('ErpExportService', () => {
let prisma: {
product: { findMany: jest.Mock };
bottlingBatch: { findMany: jest.Mock };
productionBatch: { findMany: jest.Mock };
batchMovement: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
$queryRaw: jest.Mock;
};
let stock: {
remainingByBottling: jest.Mock;
remainingByProductionBatch: jest.Mock;
};
let service: ErpExportService;
beforeEach(() => {
jest.useFakeTimers().setSystemTime(NOW);
prisma = {
product: { findMany: jest.fn().mockResolvedValue([]) },
bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) },
productionBatch: { findMany: jest.fn().mockResolvedValue([]) },
batchMovement: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
$queryRaw: jest
.fn()
.mockResolvedValue([{ settledUntil: DB_SETTLED_UNTIL }]),
};
stock = {
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
remainingByProductionBatch: jest.fn().mockResolvedValue(new Map()),
};
service = new ErpExportService(
prisma as unknown as PrismaService,
stock as unknown as StockService,
);
});
afterEach(() => {
jest.useRealTimers();
});
function lastArgs(mock: jest.Mock): FindManyArgs {
const calls = mock.mock.calls as FindManyArgs[][];
return calls[calls.length - 1][0];
}
describe('products', () => {
it('liest nur die freigegebenen Felder, stabil nach id sortiert', async () => {
prisma.product.findMany.mockResolvedValueOnce([
{
id: 'p1',
name: 'Sommertracht 500 g',
sku: 'WH-500',
kind: 'BOTTLED_HONEY',
shopCategory: 'HONIG',
status: 'ACTIVE',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
updatedAt: new Date('2026-09-01T00:00:00.000Z'),
},
]);
const page = await service.products({});
const args = lastArgs(prisma.product.findMany);
expect(Object.keys(args.select ?? {}).sort()).toEqual(
[
'id',
'kind',
'name',
'packagingSize',
'salesUnit',
'shopCategory',
'sku',
'status',
'updatedAt',
'weight',
].sort(),
);
// Die Beschreibung ist in ApiCore eine interne Notiz.
expect(args.select).not.toHaveProperty('description');
expect(args.orderBy).toEqual({ id: 'asc' });
expect(args.take).toBe(101);
expect(page).toMatchObject({
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
hasMore: false,
items: [{ id: 'p1', name: 'Sommertracht 500 g', sku: 'WH-500' }],
});
expect(decodeCursor(page.nextCursor!, 'produkte')).toEqual({
k: 'produkte',
id: 'p1',
});
});
it('blättert mit Cursor und meldet weitere Seiten', async () => {
prisma.product.findMany.mockResolvedValueOnce([
{ id: 'p3' },
{ id: 'p4' },
{ id: 'p5' },
]);
const page = await service.products({
limit: 2,
nach: encodeCursor({ k: 'produkte', id: 'p2' }),
});
const args = lastArgs(prisma.product.findMany);
expect(args.where).toEqual({ id: { gt: 'p2' } });
expect(args.take).toBe(3);
expect(page.items.map((p) => p.id)).toEqual(['p3', 'p4']);
expect(page.hasMore).toBe(true);
expect(decodeCursor(page.nextCursor!, 'produkte').id).toBe('p4');
});
it('gibt bei leerer Seite den übergebenen Cursor zurück', async () => {
const nach = encodeCursor({ k: 'produkte', id: 'p9' });
const page = await service.products({ nach });
expect(page).toMatchObject({
items: [],
hasMore: false,
nextCursor: nach,
});
});
it('lehnt einen fremden Cursor ab', async () => {
await expect(
service.products({
nach: encodeCursor({ k: 'chargen', t: 'B', id: 'b1' }),
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.product.findMany).not.toHaveBeenCalled();
});
});
describe('batches', () => {
it('liefert Abfüllungen vor Herstellungschargen, mit Rest aus dem Bewegungsbuch', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([
bottlingRow('b1'),
bottlingRow('b2', { productId: null, honeyBatch: { honeyType: null } }),
]);
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k1', { bestBeforeDate: new Date('2027-09-01') }),
]);
stock.remainingByBottling.mockResolvedValueOnce(
new Map([
['b1', 41],
['b2', 0],
]),
);
stock.remainingByProductionBatch.mockResolvedValueOnce(
new Map([['k1', 17]]),
);
const page = await service.batches({ limit: 10 });
expect(page.items).toEqual([
{
id: 'b1',
batchType: 'BOTTLING',
lotNumber: 'L-b1',
productId: 'prod-honig',
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
initialQuantity: 60,
remainingQuantity: 41,
fillWeightGrams: 500,
honeyType: 'Sommertracht',
},
{
id: 'b2',
batchType: 'BOTTLING',
lotNumber: 'L-b2',
productId: null,
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
initialQuantity: 60,
remainingQuantity: 0,
fillWeightGrams: 500,
honeyType: null,
},
{
id: 'k1',
batchType: 'PRODUCTION',
lotNumber: 'K-k1',
productId: 'prod-kerze',
date: new Date('2026-09-01T00:00:00.000Z'),
bestBeforeDate: new Date('2027-09-01'),
initialQuantity: 20,
remainingQuantity: 17,
fillWeightGrams: null,
honeyType: null,
},
]);
expect(stock.remainingByBottling).toHaveBeenCalledWith(['b1', 'b2']);
expect(stock.remainingByProductionBatch).toHaveBeenCalledWith(['k1']);
expect(page.hasMore).toBe(false);
expect(decodeCursor(page.nextCursor!, 'chargen')).toEqual({
k: 'chargen',
t: 'P',
id: 'k1',
});
});
it('liest keine Notizen und keine Benutzer', async () => {
await service.batches({});
for (const mock of [
prisma.bottlingBatch.findMany,
prisma.productionBatch.findMany,
]) {
const select = lastArgs(mock).select ?? {};
expect(select).not.toHaveProperty('notes');
expect(select).not.toHaveProperty('user');
expect(select).not.toHaveProperty('userId');
}
});
it('fragt Herstellungschargen nicht ab, solange die Seite mit Abfüllungen voll ist', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([
bottlingRow('b1'),
bottlingRow('b2'),
bottlingRow('b3'),
]);
const page = await service.batches({ limit: 2 });
expect(prisma.productionBatch.findMany).not.toHaveBeenCalled();
expect(page.items.map((b) => b.id)).toEqual(['b1', 'b2']);
expect(page.hasMore).toBe(true);
expect(decodeCursor(page.nextCursor!, 'chargen')).toMatchObject({
t: 'B',
id: 'b2',
});
});
it('füllt den Rest der Seite mit Herstellungschargen und erkennt weitere', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([bottlingRow('b9')]);
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k1'),
productionRow('k2'),
]);
const page = await service.batches({
limit: 2,
nach: encodeCursor({ k: 'chargen', t: 'B', id: 'b8' }),
});
expect(lastArgs(prisma.bottlingBatch.findMany).where).toEqual({
id: { gt: 'b8' },
});
// Nach den Abfüllungen beginnen die Herstellungschargen von vorn.
expect(lastArgs(prisma.productionBatch.findMany).where).toBeUndefined();
expect(lastArgs(prisma.productionBatch.findMany).take).toBe(2);
expect(page.items.map((b) => b.id)).toEqual(['b9', 'k1']);
expect(page.hasMore).toBe(true);
});
it('setzt hinter einer Herstellungscharge fort, ohne Abfüllungen erneut zu lesen', async () => {
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k3'),
]);
const page = await service.batches({
nach: encodeCursor({ k: 'chargen', t: 'P', id: 'k2' }),
});
expect(prisma.bottlingBatch.findMany).not.toHaveBeenCalled();
expect(lastArgs(prisma.productionBatch.findMany).where).toEqual({
id: { gt: 'k2' },
});
expect(page.items.map((b) => b.id)).toEqual(['k3']);
});
it('nimmt die Ausgangsmenge als Rest, wenn das Bewegungsbuch nichts liefert', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([bottlingRow('b1')]);
const page = await service.batches({});
expect(page.items[0].remainingQuantity).toBe(60);
});
});
describe('movements', () => {
const settledUntil = DB_SETTLED_UNTIL;
it('nimmt die Wartezeit von der Datenbank-Uhr, nicht von der Uhr des Pods', async () => {
const page = await service.movements({});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const [strings, ...values] = prisma.$queryRaw.mock.calls[0] as [
readonly string[],
...unknown[],
];
expect(strings.join('?')).toContain('now()');
expect(values).toEqual([MOVEMENT_SETTLE_MS]);
expect(values.some((value) => value instanceof Date)).toBe(false);
expect(page.settledUntil).toEqual(DB_SETTLED_UNTIL);
expect(page.settledUntil).not.toEqual(
new Date(NOW.getTime() - MOVEMENT_SETTLE_MS),
);
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({ createdAt: { lte: DB_SETTLED_UNTIL } });
});
it('liest "seit" mit Zone als UTC-Zeitpunkt', async () => {
await service.movements({ seit: '2026-01-01T01:00:00+01:00' });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({
createdAt: {
lte: settledUntil,
gte: new Date('2026-01-01T00:00:00.000Z'),
},
});
});
it('lehnt ein "seit" außerhalb der Vertragsformen ab, ohne die Datenbank zu fragen', async () => {
await expect(
service.movements({ seit: '2026-W01' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.batchMovement.findMany).not.toHaveBeenCalled();
});
it('liefert nur Bewegungen von Abfüllungen und Herstellungschargen, die älter als die Wartezeit sind', async () => {
await service.movements({});
const args = lastArgs(prisma.batchMovement.findMany);
expect(args.where).toEqual({
AND: [
{
OR: [
{ bottlingBatchId: { not: null } },
{ productionBatchId: { not: null } },
],
},
{ createdAt: { lte: settledUntil } },
],
});
expect(args.orderBy).toEqual([{ createdAt: 'asc' }, { id: 'asc' }]);
expect(args.take).toBe(101);
});
it('liest weder Notizen noch Benutzer noch Shop-Anfragen', async () => {
await service.movements({});
const select = lastArgs(prisma.batchMovement.findMany).select ?? {};
for (const field of [
'notes',
'user',
'userId',
'apiKeyId',
'shopRequest',
'shopRequestId',
'rawMaterialLotId',
]) {
expect(select).not.toHaveProperty(field);
}
});
it('bildet Bewegungen auf den Vertrag ab', async () => {
prisma.batchMovement.findMany.mockResolvedValueOnce([
movementRow('m1', '2026-10-01T10:00:00.000Z'),
movementRow('m2', '2026-10-01T11:00:00.000Z', {
type: 'SHOP_SALE',
reason: null,
quantity: -3,
bottlingBatchId: null,
productionBatchId: 'k1',
bottlingBatch: null,
productionBatch: { lotNumber: 'K-k1', productId: 'prod-kerze' },
}),
]);
const page = await service.movements({});
expect(page.items).toEqual([
{
id: 'm1',
createdAt: new Date('2026-10-01T10:00:00.000Z'),
batchType: 'BOTTLING',
batchId: 'b1',
lotNumber: 'L-b1',
productId: 'prod-honig',
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -2,
},
{
id: 'm2',
createdAt: new Date('2026-10-01T11:00:00.000Z'),
batchType: 'PRODUCTION',
batchId: 'k1',
lotNumber: 'K-k1',
productId: 'prod-kerze',
type: 'SHOP_SALE',
reason: null,
quantity: -3,
},
]);
expect(page.settledUntil).toEqual(settledUntil);
expect(decodeCursor(page.nextCursor!, 'bewegungen')).toEqual({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
});
});
it('beginnt mit "seit" einschließlich des Zeitpunkts', async () => {
await service.movements({ seit: '2026-01-01T00:00:00.000Z' });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({
createdAt: {
lte: settledUntil,
gte: new Date('2026-01-01T00:00:00.000Z'),
},
});
});
it('setzt hinter dem Cursor fort (Zeitpunkt, dann id)', async () => {
const nach = encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
});
const page = await service.movements({ nach });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
const ts = new Date('2026-10-01T11:00:00.000Z');
expect(where.AND[2]).toEqual({
OR: [{ createdAt: { gt: ts } }, { createdAt: ts, id: { gt: 'm2' } }],
});
// Nichts Neues: der Cursor bleibt, der Abruf in der nächsten Nacht setzt dort fort.
expect(page).toMatchObject({
items: [],
hasMore: false,
nextCursor: nach,
});
});
it('meldet weitere Seiten', async () => {
prisma.batchMovement.findMany.mockResolvedValueOnce([
movementRow('m1', '2026-10-01T10:00:00.000Z'),
movementRow('m2', '2026-10-01T10:00:00.000Z'),
]);
const page = await service.movements({ limit: 1 });
expect(page.items.map((m) => m.id)).toEqual(['m1']);
expect(page.hasMore).toBe(true);
});
it('lehnt "seit" und "nach" zusammen ab', async () => {
await expect(
service.movements({
seit: '2026-01-01T00:00:00.000Z',
nach: encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
}),
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.batchMovement.findMany).not.toHaveBeenCalled();
});
});
describe('harvestYears', () => {
it('liest für ein Jahr nur Datum und Nettogewicht in den UTC-Jahresgrenzen', async () => {
prisma.harvest.findMany.mockResolvedValueOnce([
{ date: new Date('2026-07-14T00:00:00.000Z'), netWeight: 41 },
{ date: new Date('2026-08-02T00:00:00.000Z'), netWeight: null },
]);
const result = await service.harvestYears({ jahr: 2026 });
const args = lastArgs(prisma.harvest.findMany);
expect(args.select).toEqual({ date: true, netWeight: true });
expect(args.where).toEqual({
date: {
gte: new Date('2026-01-01T00:00:00.000Z'),
lt: new Date('2027-01-01T00:00:00.000Z'),
},
});
expect(result).toEqual({
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: NOW,
years: [
{
year: 2026,
netWeightKg: 41,
harvestCount: 2,
harvestsWithoutNetWeight: 1,
},
],
});
});
it('liefert ohne Jahr alle Jahre', async () => {
await service.harvestYears({});
expect(lastArgs(prisma.harvest.findMany).where).toBeUndefined();
});
});
});
@@ -0,0 +1,446 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { StockService } from '../stock/stock.service';
import {
BatchMovementType,
Prisma,
ProductKind,
ProductStatus,
ShopCategory,
WithdrawalReason,
} from '../../generated/prisma';
import { decodeCursor, encodeCursor, type ErpCursor } from './erp-cursor';
import {
DEFAULT_PAGE_SIZE,
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
} from './dto/erp-query.dto';
import { summarizeHarvestYears, type HarvestYear } from './harvest-years';
import { parseSeit } from './erp-time';
/**
* Erhöht nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in
* der Bedeutung geändert). Neue Felder kommen ohne Versionswechsel dazu; der
* Importer ignoriert Unbekanntes. Vertrag: docs/erp-inventar-export.md.
*/
export const ERP_INVENTORY_CONTRACT_VERSION = 1;
/**
* Bewegungen erscheinen erst, wenn sie so alt sind. Eine Buchung bekommt
* ihren Zeitstempel beim Einfügen, sichtbar wird sie aber erst beim Commit
* ihrer Transaktion. Ohne Wartezeit könnte ein Abruf eine spätere Buchung
* schon sehen und den Cursor über eine frühere, noch offene hinwegschieben -
* die frühere ginge dem Spiegel dann für immer verloren. Die Grenze rechnet
* die Datenbank-Uhr (now()), nicht die Uhr des Pods: mehrere api-Pods geben
* so dieselbe Grenze, auch wenn ihre Uhren auseinanderlaufen.
*/
export const MOVEMENT_SETTLE_MS = 5 * 60 * 1000;
export type ErpBatchType = 'BOTTLING' | 'PRODUCTION';
export interface ErpPage<T> {
contractVersion: number;
generatedAt: Date;
items: T[];
/** Position hinter dem letzten Eintrag; bei leerer Seite der übergebene Cursor. */
nextCursor: string | null;
hasMore: boolean;
}
export interface ErpProduct {
id: string;
name: string;
sku: string | null;
kind: ProductKind;
shopCategory: ShopCategory;
status: ProductStatus;
packagingSize: number | null;
weight: number | null;
salesUnit: string | null;
updatedAt: Date;
}
export interface ErpBatch {
id: string;
batchType: ErpBatchType;
lotNumber: string;
productId: string | null;
/** Abfüll- bzw. Herstelldatum. */
date: Date;
bestBeforeDate: Date | null;
/** Abgefüllte Gläser bzw. hergestellte Stück. */
initialQuantity: number;
/** Ausgangsmenge plus Summe aller Bewegungen; kann negativ sein (zu prüfen). */
remainingQuantity: number;
/** Füllmenge je Glas in Gramm; nur bei Abfüllungen. */
fillWeightGrams: number | null;
/** Sorte der Honigcharge hinter der Abfüllung; nur bei Abfüllungen. */
honeyType: string | null;
}
export interface ErpMovement {
id: string;
createdAt: Date;
batchType: ErpBatchType;
batchId: string;
lotNumber: string;
productId: string | null;
type: BatchMovementType;
reason: WithdrawalReason | null;
/** Stück mit Vorzeichen: minus = aus dem Bestand, plus = zurück. */
quantity: number;
}
export interface ErpMovementPage extends ErpPage<ErpMovement> {
/** Bis hierhin ist die Liste vollständig (jetzt minus Wartezeit). */
settledUntil: Date;
}
export interface ErpHarvestYears {
contractVersion: number;
generatedAt: Date;
years: HarvestYear[];
}
// Feld-Allowlists: nur was hier steht, verlässt ApiCore. Notizen, Benutzer,
// Beschreibungen (in ApiCore interne Notizen) und alles, was später an die
// Modelle kommt, bleiben drin.
const PRODUCT_SELECT = {
id: true,
name: true,
sku: true,
kind: true,
shopCategory: true,
status: true,
packagingSize: true,
weight: true,
salesUnit: true,
updatedAt: true,
} satisfies Prisma.ProductSelect;
const BOTTLING_SELECT = {
id: true,
lotNumber: true,
productId: true,
date: true,
bestBeforeDate: true,
jarCount: true,
jarSize: true,
honeyBatch: { select: { honeyType: true } },
} satisfies Prisma.BottlingBatchSelect;
const PRODUCTION_SELECT = {
id: true,
lotNumber: true,
productId: true,
date: true,
bestBeforeDate: true,
quantity: true,
} satisfies Prisma.ProductionBatchSelect;
const BATCH_REF_SELECT = { select: { lotNumber: true, productId: true } };
const MOVEMENT_SELECT = {
id: true,
createdAt: true,
type: true,
reason: true,
quantity: true,
bottlingBatchId: true,
productionBatchId: true,
bottlingBatch: BATCH_REF_SELECT,
productionBatch: BATCH_REF_SELECT,
} satisfies Prisma.BatchMovementSelect;
type BottlingRow = Prisma.BottlingBatchGetPayload<{
select: typeof BOTTLING_SELECT;
}>;
type ProductionRow = Prisma.ProductionBatchGetPayload<{
select: typeof PRODUCTION_SELECT;
}>;
type MovementRow = Prisma.BatchMovementGetPayload<{
select: typeof MOVEMENT_SELECT;
}>;
/** Chargen ohne Rest - der kommt nach dem Zuschnitt der Seite dazu. */
type BatchWithoutRemaining = Omit<ErpBatch, 'remainingQuantity'>;
function toPage<T>(
rows: readonly T[],
limit: number,
cursorOf: (last: T) => ErpCursor,
given: string | undefined,
): Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'> {
const items = rows.slice(0, limit);
const last = items[items.length - 1];
return {
items,
hasMore: rows.length > limit,
nextCursor:
last !== undefined ? encodeCursor(cursorOf(last)) : (given ?? null),
};
}
function fromBottling(row: BottlingRow): BatchWithoutRemaining {
return {
id: row.id,
batchType: 'BOTTLING',
lotNumber: row.lotNumber,
productId: row.productId,
date: row.date,
bestBeforeDate: row.bestBeforeDate,
initialQuantity: row.jarCount,
fillWeightGrams: row.jarSize,
honeyType: row.honeyBatch.honeyType,
};
}
function fromProduction(row: ProductionRow): BatchWithoutRemaining {
return {
id: row.id,
batchType: 'PRODUCTION',
lotNumber: row.lotNumber,
productId: row.productId,
date: row.date,
bestBeforeDate: row.bestBeforeDate,
initialQuantity: row.quantity,
fillWeightGrams: null,
honeyType: null,
};
}
function fromMovement(row: MovementRow): ErpMovement {
const batch = row.bottlingBatch ?? row.productionBatch;
const batchId = row.bottlingBatchId ?? row.productionBatchId;
if (!batch || !batchId) {
// Durch die Abfrage ausgeschlossen (nur Bewegungen mit Charge).
throw new Error(
`Bewegung ${row.id} ohne Abfüllung oder Herstellungscharge`,
);
}
return {
id: row.id,
createdAt: row.createdAt,
batchType: row.bottlingBatchId ? 'BOTTLING' : 'PRODUCTION',
batchId,
lotNumber: batch.lotNumber,
productId: batch.productId,
type: row.type,
reason: row.reason,
quantity: row.quantity,
};
}
function idAfter(id: string | undefined): { id: { gt: string } } | undefined {
return id !== undefined ? { id: { gt: id } } : undefined;
}
/**
* Nur lesender Inventar-Export für ERPNext (Teilprojekt T8 des
* ERPNext-Ausbaus): Artikel, Chargen mit Rest, Bestandsbewegungen und
* Erntemengen. ApiCore bleibt führend; ERPNext spiegelt nur.
*
* Zugang über einen API-Schlüssel mit dem Recht erp-inventory:read, das nur
* diese Endpunkte öffnet. Alle Listen sind stabil sortiert und seitenweise.
*/
@Injectable()
export class ErpExportService {
constructor(
private readonly prisma: PrismaService,
private readonly stock: StockService,
) {}
async products(query: ErpPageQueryDto): Promise<ErpPage<ErpProduct>> {
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'produkte') : null;
const rows = await this.prisma.product.findMany({
where: idAfter(after?.id),
select: PRODUCT_SELECT,
orderBy: { id: 'asc' },
take: limit + 1,
});
return this.envelope(
toPage(
rows,
limit,
(last) => ({ k: 'produkte', id: last.id }),
query.nach,
),
);
}
/** Erst alle Abfüllungen, dann alle Herstellungschargen, je nach id. */
async batches(query: ErpPageQueryDto): Promise<ErpPage<ErpBatch>> {
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'chargen') : null;
const bottlings =
after?.t === 'P'
? []
: await this.prisma.bottlingBatch.findMany({
where: idAfter(after?.id),
select: BOTTLING_SELECT,
orderBy: { id: 'asc' },
take: limit + 1,
});
// Erst wenn die Abfüllungen auf dieser Seite enden, kommen
// Herstellungschargen dazu - eine mehr als Platz ist, um weitere zu erkennen.
const productions =
bottlings.length > limit
? []
: await this.prisma.productionBatch.findMany({
where: idAfter(after?.t === 'P' ? after.id : undefined),
select: PRODUCTION_SELECT,
orderBy: { id: 'asc' },
take: limit - bottlings.length + 1,
});
const page = toPage(
[...bottlings.map(fromBottling), ...productions.map(fromProduction)],
limit,
(last) => ({
k: 'chargen',
t: last.batchType === 'BOTTLING' ? 'B' : 'P',
id: last.id,
}),
query.nach,
);
return this.envelope({
...page,
items: await this.withRemaining(page.items),
});
}
async movements(query: ErpMovementsQueryDto): Promise<ErpMovementPage> {
if (query.seit !== undefined && query.nach !== undefined) {
throw new BadRequestException(
'Entweder "seit" (erster Abruf) oder "nach" (weiterlesen) angeben, nicht beides',
);
}
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'bewegungen') : null;
const since = query.seit !== undefined ? seitDate(query.seit) : undefined;
const settledUntil = await this.settledUntil();
const afterCursor: Prisma.BatchMovementWhereInput[] = after
? [
{
OR: [
{ createdAt: { gt: new Date(after.ts) } },
{ createdAt: new Date(after.ts), id: { gt: after.id } },
],
},
]
: [];
const conditions: Prisma.BatchMovementWhereInput[] = [
// Nur Ware: Rohstoff-Bewegungen (Wachs, Propolis) gehören nicht in den Spiegel.
{
OR: [
{ bottlingBatchId: { not: null } },
{ productionBatchId: { not: null } },
],
},
{
createdAt: {
lte: settledUntil,
...(since !== undefined ? { gte: since } : {}),
},
},
...afterCursor,
];
const rows = await this.prisma.batchMovement.findMany({
where: { AND: conditions },
select: MOVEMENT_SELECT,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: limit + 1,
});
const page = toPage(
rows.map(fromMovement),
limit,
(last) => ({
k: 'bewegungen',
ts: last.createdAt.toISOString(),
id: last.id,
}),
query.nach,
);
return { ...this.envelope(page), settledUntil };
}
async harvestYears(query: ErpHarvestsQueryDto): Promise<ErpHarvestYears> {
const year = query.jahr;
const rows = await this.prisma.harvest.findMany({
where:
year !== undefined
? {
date: {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
},
}
: undefined,
select: { date: true, netWeight: true },
});
return {
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: new Date(),
years: summarizeHarvestYears(rows, year),
};
}
/** Bis hierhin ist das Bewegungsbuch vollständig: DB-Uhr minus Wartezeit. */
private async settledUntil(): Promise<Date> {
const [row] = await this.prisma.$queryRaw<{ settledUntil: Date }[]>`
SELECT now() - (${MOVEMENT_SETTLE_MS}::double precision * interval '1 millisecond') AS "settledUntil"
`;
return row.settledUntil;
}
private envelope<T>(
page: Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'>,
): ErpPage<T> {
return {
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: new Date(),
...page,
};
}
private async withRemaining(
batches: readonly BatchWithoutRemaining[],
): Promise<ErpBatch[]> {
const idsOf = (type: ErpBatchType) =>
batches.filter((b) => b.batchType === type).map((b) => b.id);
const bottlingIds = idsOf('BOTTLING');
const productionIds = idsOf('PRODUCTION');
const [bottlingRest, productionRest] = await Promise.all([
bottlingIds.length > 0
? this.stock.remainingByBottling(bottlingIds)
: new Map<string, number>(),
productionIds.length > 0
? this.stock.remainingByProductionBatch(productionIds)
: new Map<string, number>(),
]);
return batches.map((batch) => {
const rest =
batch.batchType === 'BOTTLING' ? bottlingRest : productionRest;
return {
...batch,
remainingQuantity: rest.get(batch.id) ?? batch.initialQuantity,
};
});
}
}
/** "seit" ist im DTO schon geprüft; hier nur noch die Umwandlung, sicherheitshalber mit 400. */
function seitDate(seit: string): Date {
const date = parseSeit(seit);
if (!date) {
throw new BadRequestException(
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
);
}
return date;
}
@@ -0,0 +1,51 @@
import { isPlausibleErpDate, parseSeit } from './erp-time';
describe('parseSeit', () => {
it.each([
['2026-01-01', '2026-01-01T00:00:00.000Z'],
['2026-01-01T00:00Z', '2026-01-01T00:00:00.000Z'],
['2026-01-01T08:15:30Z', '2026-01-01T08:15:30.000Z'],
['2026-01-01T08:15:30.1Z', '2026-01-01T08:15:30.100Z'],
['2026-01-01T08:15:30.123Z', '2026-01-01T08:15:30.123Z'],
['2026-01-01T01:00:00+01:00', '2026-01-01T00:00:00.000Z'],
['2025-12-31T21:30:00-02:30', '2026-01-01T00:00:00.000Z'],
])('liest %s als %s (UTC)', (seit, iso) => {
expect(parseSeit(seit)?.toISOString()).toBe(iso);
});
it.each([
'2026-W01',
'20260101',
'-2026-01-01',
'2026-001',
'2026-01-01T00:00',
'2026-01-01T24:00Z',
'2026-02-29',
'2026-04-31',
'1999-12-31T23:59:59Z',
'2101-01-01',
'2100-12-31T23:00:00-02:00',
])('lehnt %s ab', (seit) => {
expect(parseSeit(seit)).toBeNull();
});
it('kennt Schalttage', () => {
expect(parseSeit('2028-02-29')?.toISOString()).toBe(
'2028-02-29T00:00:00.000Z',
);
});
});
describe('isPlausibleErpDate', () => {
it('lässt nur die Jahre 2000 bis 2100 (UTC) zu', () => {
expect(isPlausibleErpDate(new Date('2000-01-01T00:00:00.000Z'))).toBe(true);
expect(isPlausibleErpDate(new Date('2100-12-31T23:59:59.999Z'))).toBe(true);
expect(isPlausibleErpDate(new Date('1999-12-31T23:59:59.999Z'))).toBe(
false,
);
expect(isPlausibleErpDate(new Date('2101-01-01T00:00:00.000Z'))).toBe(
false,
);
expect(isPlausibleErpDate(new Date(Number.NaN))).toBe(false);
});
});
@@ -0,0 +1,69 @@
/**
* Zeitpunkte, die der ERP-Export aus Anfragen übernimmt (`seit`, Cursor).
* Nur plausible Jahre: JavaScript kennt Jahre bis +275760, die
* Datenbankabfrage nicht - das wäre ein 500 statt eines 400.
*/
export const ERP_MIN_YEAR = 2000;
export const ERP_MAX_YEAR = 2100;
export function isPlausibleErpDate(date: Date): boolean {
if (Number.isNaN(date.getTime())) return false;
const year = date.getUTCFullYear();
return year >= ERP_MIN_YEAR && year <= ERP_MAX_YEAR;
}
/**
* Die einzigen Formen für `seit`: ein Kalendertag (`JJJJ-MM-TT`, gilt als
* Mitternacht UTC) oder ein Zeitpunkt mit ausdrücklicher Zone
* (`JJJJ-MM-TTThh:mm[:ss[.sss]]` mit `Z` oder `±hh:mm`). Andere ISO-8601-
* Formen (Wochen, Ordinaltage, Kurzform ohne Striche, erweiterte Jahre,
* Uhrzeit ohne Zone = Ortszeit des Servers) werden abgelehnt, statt falsch
* gelesen zu werden oder als 500 in der Datenbank zu enden.
*/
export const SEIT_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|([+-])(\d{2}):(\d{2})))?$/;
const MS_PER_MINUTE = 60_000;
/** Der Zeitpunkt zu `seit`, oder null, wenn Form, Kalender oder Jahr nicht passen. */
export function parseSeit(value: string): Date | null {
const match = SEIT_PATTERN.exec(value);
if (!match) return null;
// Optionale Gruppen ohne Treffer sind undefined.
const groups: (string | undefined)[] = [...match];
const [, y, mo, d, h, mi, s, frac, , sign, oh, om] = groups;
const year = Number(y);
const month = Number(mo) - 1;
const day = Number(d);
const hours = Number(h ?? 0);
const minutes = Number(mi ?? 0);
const seconds = Number(s ?? 0);
const offsetHours = Number(oh ?? 0);
const offsetMinutes = Number(om ?? 0);
if (
hours > 23 ||
minutes > 59 ||
seconds > 59 ||
offsetHours > 23 ||
offsetMinutes > 59
) {
return null;
}
const calendarDay = new Date(Date.UTC(year, month, day));
if (
calendarDay.getUTCFullYear() !== year ||
calendarDay.getUTCMonth() !== month ||
calendarDay.getUTCDate() !== day
) {
return null; // z. B. 2026-02-30
}
const millis = Number((frac ?? '').padEnd(3, '0'));
const offset =
(sign === '-' ? -1 : 1) *
(offsetHours * 60 + offsetMinutes) *
MS_PER_MINUTE;
const date = new Date(
Date.UTC(year, month, day, hours, minutes, seconds, millis) - offset,
);
return isPlausibleErpDate(date) ? date : null;
}
@@ -0,0 +1,96 @@
import { summarizeHarvestYears } from './harvest-years';
function harvest(date: string, netWeight: number | null) {
return { date: new Date(date), netWeight };
}
describe('summarizeHarvestYears', () => {
it('summiert das Nettogewicht je Kalenderjahr (UTC), älteste Jahre zuerst', () => {
const years = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 41),
harvest('2025-06-01T00:00:00.000Z', 12.5),
harvest('2026-05-20T00:00:00.000Z', 18.25),
]);
expect(years).toEqual([
{
year: 2025,
netWeightKg: 12.5,
harvestCount: 1,
harvestsWithoutNetWeight: 0,
},
{
year: 2026,
netWeightKg: 59.25,
harvestCount: 2,
harvestsWithoutNetWeight: 0,
},
]);
});
it('zählt Ernten ohne Nettogewicht mit, rechnet sie aber nicht ein', () => {
const [year] = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 20),
harvest('2026-08-01T00:00:00.000Z', null),
]);
expect(year).toEqual({
year: 2026,
netWeightKg: 20,
harvestCount: 2,
harvestsWithoutNetWeight: 1,
});
});
it('rundet auf Gramm, damit Gleitkomma-Reste nicht im Vertrag landen', () => {
const [year] = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 0.1),
harvest('2026-07-15T00:00:00.000Z', 0.2),
harvest('2026-07-16T00:00:00.000Z', 12.3456),
]);
expect(year.netWeightKg).toBe(12.646);
});
it('ordnet Silvester und Neujahr nach UTC zu', () => {
const years = summarizeHarvestYears([
harvest('2025-12-31T23:59:59.999Z', 1),
harvest('2026-01-01T00:00:00.000Z', 2),
]);
expect(years.map((y) => [y.year, y.netWeightKg])).toEqual([
[2025, 1],
[2026, 2],
]);
});
it('liefert für ein angefragtes Jahr ohne Ernten eine Nullzeile statt nichts', () => {
expect(summarizeHarvestYears([], 2024)).toEqual([
{
year: 2024,
netWeightKg: 0,
harvestCount: 0,
harvestsWithoutNetWeight: 0,
},
]);
});
it('beschränkt sich auf das angefragte Jahr', () => {
const years = summarizeHarvestYears(
[
harvest('2025-06-01T00:00:00.000Z', 5),
harvest('2026-06-01T00:00:00.000Z', 7),
],
2026,
);
expect(years).toEqual([
{
year: 2026,
netWeightKg: 7,
harvestCount: 1,
harvestsWithoutNetWeight: 0,
},
]);
});
it('liefert ohne Ernten und ohne Jahr eine leere Liste', () => {
expect(summarizeHarvestYears([])).toEqual([]);
});
});
@@ -0,0 +1,72 @@
/**
* Geerntete Honigmenge je Kalenderjahr - Grundlage für die Honigkosten je kg
* in ERPNext (Imkerei-Ausgaben des Jahres / geerntete kg).
*
* Gleiche Definition wie die Statistik in ApiCore (Honigertrag je Jahr):
* Summe der Nettogewichte (kg) der Ernten, Jahr aus dem Erntedatum. Das
* Jahr wird in UTC bestimmt, weil Erntedaten als Kalendertag (Mitternacht
* UTC) gespeichert werden. Ernten ohne Nettogewicht zählen nicht zur Menge,
* werden aber ausgewiesen - sonst sähe eine unvollständig erfasste Ernte
* wie ein schlechtes Jahr aus und die Kosten je kg wären zu hoch.
*/
export interface HarvestWeight {
date: Date;
/** Kilogramm; null, wenn nicht erfasst. */
netWeight: number | null;
}
export interface HarvestYear {
year: number;
/** Summe der Nettogewichte in kg, auf Gramm gerundet. */
netWeightKg: number;
harvestCount: number;
harvestsWithoutNetWeight: number;
}
const GRAMS_PER_KG = 1000;
function roundToGrams(kg: number): number {
return Math.round(kg * GRAMS_PER_KG) / GRAMS_PER_KG;
}
function emptyYear(year: number): HarvestYear {
return {
year,
netWeightKg: 0,
harvestCount: 0,
harvestsWithoutNetWeight: 0,
};
}
/**
* Fasst Ernten je Jahr zusammen, älteste Jahre zuerst. Mit `onlyYear` nur
* dieses Jahr, und zwar auch dann, wenn es keine Ernte hat (Nullzeile).
*/
export function summarizeHarvestYears(
harvests: readonly HarvestWeight[],
onlyYear?: number,
): HarvestYear[] {
const byYear = new Map<number, HarvestYear>();
if (onlyYear !== undefined) {
byYear.set(onlyYear, emptyYear(onlyYear));
}
for (const harvest of harvests) {
const year = harvest.date.getUTCFullYear();
if (onlyYear !== undefined && year !== onlyYear) continue;
const current = byYear.get(year) ?? emptyYear(year);
byYear.set(year, {
year,
netWeightKg: current.netWeightKg + (harvest.netWeight ?? 0),
harvestCount: current.harvestCount + 1,
harvestsWithoutNetWeight:
current.harvestsWithoutNetWeight + (harvest.netWeight === null ? 1 : 0),
});
}
return [...byYear.values()]
.sort((a, b) => a.year - b.year)
.map((entry) => ({
...entry,
netWeightKg: roundToGrams(entry.netWeightKg),
}));
}
@@ -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'] })
+765
View File
@@ -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);
});
});
});