fix(erp-export): strict seit format, DB clock for settle window, movement index, one scope per key

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 23:24:05 +02:00
1 parent 53fab102f2
commit 25d95b0473
19 files changed
+426 -57

No files matched your search

+2 -2
View File
@@ -103,7 +103,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`). 11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt: Darum laufen alle Seiteneffekte nach dem Commit (`AutomationService.evaluate*`, `CalendarSyncService.enqueue`/`cancel`, `WebhooksService.dispatch`) best-effort über `runAfterCommit` (`src/common/after-commit.ts`) – sie loggen auf Error-Level und werfen nie. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Dauerhaft zugestellte Seiteneffekte bringt erst das Ereignis-Rückgrat (Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`).
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. 12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein Schlüssel mit einem Lese- und einem Buchungsrecht wird beim Anlegen mit 400 abgelehnt; bereits ausgestellte kombinierte Schlüssel bleiben gültig, die Oberfläche markiert sie und empfiehlt zwei getrennte Schlüssel (neue anlegen, in Wabenhain eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche legt je Schlüssel genau ein Recht an und bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Fehlversuche mit ungültigen Schlüsseln: Annahme 24. 14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Fehlversuche mit ungültigen Schlüsseln: Annahme 24.
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. 16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
@@ -223,7 +223,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt | | Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) | | Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten | | Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Lesen und Buchen sind getrennte Schlüssel: `ApiKeysService.create` lehnt einen Schlüssel mit Lese- und Buchungsrecht (z. B. beiden Shop-Rechten) mit 400 ab; bereits ausgestellte kombinierte Schlüssel bleiben gültig und werden in der Oberfläche mit Hinweis markiert. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 24); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix | | Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: `ApiKeysService.create` lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 24); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body | | Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
| Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies | | Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf | | Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf |
@@ -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([productionBatchId])
@@index([rawMaterialLotId]) @@index([rawMaterialLotId])
@@index([shopRequestId]) @@index([shopRequestId])
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
@@index([createdAt, id])
} }
enum ShopStockRequestKind { enum ShopStockRequestKind {
@@ -322,6 +322,28 @@ describe('ApiKeysService.create', () => {
expect(prisma.apiKey.create).not.toHaveBeenCalled(); 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'])( it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
'still creates a key with only %s', 'still creates a key with only %s',
async (scope) => { async (scope) => {
@@ -104,14 +104,19 @@ export class ApiKeysService {
dto: CreateApiKeyDto, dto: CreateApiKeyDto,
actingUserId: string, actingUserId: string,
): Promise<CreatedApiKey> { ): Promise<CreatedApiKey> {
// Reading and booking are separate keys: a leaked read key cannot book, // One scope per new key: a leaked read key cannot book, a leaked booking
// a leaked booking key cannot read. Existing combined keys stay valid. // key cannot read, and each connected system gets its own key.
// erp-inventory:read is a read scope too. // Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
if (combinesReadAndWrite(dto.scopes)) { if (combinesReadAndWrite(dto.scopes)) {
throw new BadRequestException( throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen', '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. // Nobody hands a machine more than they may do themselves.
const callerPermissions = const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId); await this.permissionResolver.getPermissions(actingUserId);
@@ -50,21 +50,46 @@ describe('ERP-Abfrageparameter', () => {
}, },
); );
it('nimmt einen ISO-Zeitpunkt für seit an', async () => { it.each([
expect( '2026-01-01',
await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01T00:00:00Z' }), '2026-01-01T00:00Z',
).toEqual([]); '2026-01-01T00:00:00Z',
expect( '2026-01-01T00:00:00.123Z',
await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01' }), '2026-01-01T01:00:00+01:00',
).toEqual([]); '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'])( it.each([
'lehnt seit=%s ab', 'gestern',
async (seit) => { '2026-13-01',
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual(['seit']); '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'])( it.each(['1899', '2101', '2026.5', 'dieses'])(
'lehnt jahr=%s ab', 'lehnt jahr=%s ab',
@@ -2,14 +2,20 @@ import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
IsInt, IsInt,
IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
Matches, Matches,
Max, Max,
MaxLength, MaxLength,
Min, Min,
ValidateBy,
} from 'class-validator'; } from 'class-validator';
import {
ERP_MAX_YEAR,
ERP_MIN_YEAR,
parseSeit,
SEIT_PATTERN,
} from '../erp-time';
export const DEFAULT_PAGE_SIZE = 100; export const DEFAULT_PAGE_SIZE = 100;
export const MAX_PAGE_SIZE = 500; export const MAX_PAGE_SIZE = 500;
@@ -45,12 +51,24 @@ export class ErpPageQueryDto {
export class ErpMovementsQueryDto extends ErpPageQueryDto { export class ErpMovementsQueryDto extends ErpPageQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 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".`,
'Nur Bewegungen ab diesem Zeitpunkt (ISO 8601, einschließlich; UTC mit "Z" empfohlen). Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".',
example: '2026-01-01T00:00:00Z', example: '2026-01-01T00:00:00Z',
}) })
@IsOptional() @IsOptional()
@IsISO8601({ strict: true }) @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; seit?: string;
} }
+3 -10
View File
@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { isPlausibleErpDate } from './erp-time';
/** /**
* Blätter-Cursor des ERP-Exports: die Position hinter dem letzten gelieferten * Blätter-Cursor des ERP-Exports: die Position hinter dem letzten gelieferten
@@ -23,11 +24,6 @@ type CursorOf<K extends ErpCursorKind> = Extract<ErpCursor, { k: K }>;
// etwas anderes bedeuten könnten. // etwas anderes bedeuten könnten.
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
// Zeitstempel eines Bewegungs-Cursors: nur plausible Jahre. JavaScript
// kennt Jahre bis +275760, die Datenbankabfrage nicht - das wäre ein 500.
const MIN_CURSOR_YEAR = 2000;
const MAX_CURSOR_YEAR = 2100;
const FIELDS: Record<ErpCursorKind, readonly string[]> = { const FIELDS: Record<ErpCursorKind, readonly string[]> = {
produkte: ['id', 'k'], produkte: ['id', 'k'],
chargen: ['id', 'k', 't'], chargen: ['id', 'k', 't'],
@@ -59,12 +55,9 @@ function parse(raw: string): Record<string, unknown> {
function isCanonicalTimestamp(value: unknown): value is string { function isCanonicalTimestamp(value: unknown): value is string {
if (typeof value !== 'string') return false; if (typeof value !== 'string') return false;
// Nur die eigene Form (toISOString) und nur plausible Jahre (erp-time.ts).
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.toISOString() !== value) { return isPlausibleErpDate(date) && date.toISOString() === value;
return false;
}
const year = date.getUTCFullYear();
return year >= MIN_CURSOR_YEAR && year <= MAX_CURSOR_YEAR;
} }
/** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */ /** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */
@@ -16,6 +16,9 @@ type FindManyArgs = {
}; };
const NOW = new Date('2026-10-12T02:00:00.000Z'); 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> = {}) { function bottlingRow(id: string, overrides: Record<string, unknown> = {}) {
return { return {
@@ -69,6 +72,7 @@ describe('ErpExportService', () => {
productionBatch: { findMany: jest.Mock }; productionBatch: { findMany: jest.Mock };
batchMovement: { findMany: jest.Mock }; batchMovement: { findMany: jest.Mock };
harvest: { findMany: jest.Mock }; harvest: { findMany: jest.Mock };
$queryRaw: jest.Mock;
}; };
let stock: { let stock: {
remainingByBottling: jest.Mock; remainingByBottling: jest.Mock;
@@ -84,6 +88,9 @@ describe('ErpExportService', () => {
productionBatch: { findMany: jest.fn().mockResolvedValue([]) }, productionBatch: { findMany: jest.fn().mockResolvedValue([]) },
batchMovement: { findMany: jest.fn().mockResolvedValue([]) }, batchMovement: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) }, harvest: { findMany: jest.fn().mockResolvedValue([]) },
$queryRaw: jest
.fn()
.mockResolvedValue([{ settledUntil: DB_SETTLED_UNTIL }]),
}; };
stock = { stock = {
remainingByBottling: jest.fn().mockResolvedValue(new Map()), remainingByBottling: jest.fn().mockResolvedValue(new Map()),
@@ -339,7 +346,48 @@ describe('ErpExportService', () => {
}); });
describe('movements', () => { describe('movements', () => {
const settledUntil = new Date(NOW.getTime() - MOVEMENT_SETTLE_MS); 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 () => { it('liefert nur Bewegungen von Abfüllungen und Herstellungschargen, die älter als die Wartezeit sind', async () => {
await service.movements({}); await service.movements({});
@@ -17,6 +17,7 @@ import {
ErpPageQueryDto, ErpPageQueryDto,
} from './dto/erp-query.dto'; } from './dto/erp-query.dto';
import { summarizeHarvestYears, type HarvestYear } from './harvest-years'; import { summarizeHarvestYears, type HarvestYear } from './harvest-years';
import { parseSeit } from './erp-time';
/** /**
* Erhöht nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in * Erhöht nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in
@@ -30,7 +31,9 @@ export const ERP_INVENTORY_CONTRACT_VERSION = 1;
* ihren Zeitstempel beim Einfügen, sichtbar wird sie aber erst beim Commit * ihren Zeitstempel beim Einfügen, sichtbar wird sie aber erst beim Commit
* ihrer Transaktion. Ohne Wartezeit könnte ein Abruf eine spätere Buchung * ihrer Transaktion. Ohne Wartezeit könnte ein Abruf eine spätere Buchung
* schon sehen und den Cursor über eine frühere, noch offene hinwegschieben - * schon sehen und den Cursor über eine frühere, noch offene hinwegschieben -
* die frühere ginge dem Spiegel dann für immer verloren. * 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 const MOVEMENT_SETTLE_MS = 5 * 60 * 1000;
@@ -317,7 +320,8 @@ export class ErpExportService {
} }
const limit = query.limit ?? DEFAULT_PAGE_SIZE; const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'bewegungen') : null; const after = query.nach ? decodeCursor(query.nach, 'bewegungen') : null;
const settledUntil = new Date(Date.now() - MOVEMENT_SETTLE_MS); const since = query.seit !== undefined ? seitDate(query.seit) : undefined;
const settledUntil = await this.settledUntil();
const afterCursor: Prisma.BatchMovementWhereInput[] = after const afterCursor: Prisma.BatchMovementWhereInput[] = after
? [ ? [
@@ -340,7 +344,7 @@ export class ErpExportService {
{ {
createdAt: { createdAt: {
lte: settledUntil, lte: settledUntil,
...(query.seit !== undefined ? { gte: new Date(query.seit) } : {}), ...(since !== undefined ? { gte: since } : {}),
}, },
}, },
...afterCursor, ...afterCursor,
@@ -386,6 +390,14 @@ export class ErpExportService {
}; };
} }
/** 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>( private envelope<T>(
page: Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'>, page: Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'>,
): ErpPage<T> { ): ErpPage<T> {
@@ -421,3 +433,14 @@ export class ErpExportService {
}); });
} }
} }
/** "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;
}
@@ -103,6 +103,14 @@ describe('API keys (e2e)', () => {
expect(JSON.stringify(response.body)).toContain('getrennte Schlüssel'); 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 () => { it('lets a key read exactly what its scopes allow', async () => {
const { token } = ( const { token } = (
await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] }) await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] })
+31 -5
View File
@@ -474,10 +474,13 @@ describe('ERP-Inventar-Export (e2e)', () => {
}); });
describe('Bewegungen', () => { describe('Bewegungen', () => {
async function myMovements(): Promise<{ items: Item[]; cursor: string }> { async function myMovements(
seit = at(0).toISOString(),
limit = 2,
): Promise<{ items: Item[]; cursor: string }> {
const mine = new Set(Object.values(movementIds)); const mine = new Set(Object.values(movementIds));
const found: Item[] = []; const found: Item[] = [];
let query = `bewegungen?limit=2&seit=${at(0).toISOString()}`; let query = `bewegungen?limit=${limit}&seit=${encodeURIComponent(seit)}`;
for (let page = 0; page < 40; page++) { for (let page = 0; page < 40; page++) {
const body = (await get(query, erpKey).expect(200)) const body = (await get(query, erpKey).expect(200))
.body as PageBody<Item>; .body as PageBody<Item>;
@@ -489,7 +492,7 @@ describe('ERP-Inventar-Export (e2e)', () => {
if (!body.hasMore || passedMine) { if (!body.hasMore || passedMine) {
return { items: found, cursor: body.nextCursor! }; return { items: found, cursor: body.nextCursor! };
} }
query = `bewegungen?limit=2&nach=${body.nextCursor}`; query = `bewegungen?limit=${limit}&nach=${body.nextCursor}`;
} }
throw new Error('zu viele Seiten'); throw new Error('zu viele Seiten');
} }
@@ -545,6 +548,22 @@ describe('ERP-Inventar-Export (e2e)', () => {
expect(raw).not.toContain('userId'); 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 () => { it('meldet, bis wann die Liste vollständig ist', async () => {
const before = Date.now(); const before = Date.now();
const body = (await get('bewegungen?limit=1', erpKey).expect(200)) const body = (await get('bewegungen?limit=1', erpKey).expect(200))
@@ -574,6 +593,13 @@ describe('ERP-Inventar-Export (e2e)', () => {
['produkte?seite=2'], ['produkte?seite=2'],
['chargen?nach=bm90LWpzb24'], ['chargen?nach=bm90LWpzb24'],
['bewegungen?seit=gestern'], ['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=1800'],
['ernten?jahr=dieses'], ['ernten?jahr=dieses'],
])('lehnt %s ab (400)', async (path) => { ])('lehnt %s ab (400)', async (path) => {
@@ -594,9 +620,9 @@ describe('ERP-Inventar-Export (e2e)', () => {
await get(`bewegungen?nach=${cursor}`, erpKey).expect(400); await get(`bewegungen?nach=${cursor}`, erpKey).expect(400);
}); });
it('verträgt ein fernes seit (leere Seite statt Fehler)', async () => { it('verträgt ein fernes seit innerhalb der Jahresgrenzen (leere Seite statt Fehler)', async () => {
const body = ( const body = (
await get('bewegungen?seit=9999-12-31T00:00:00Z', erpKey).expect(200) await get('bewegungen?seit=2100-12-31T00:00:00Z', erpKey).expect(200)
).body as PageBody<Item>; ).body as PageBody<Item>;
expect(body.items).toEqual([]); expect(body.items).toEqual([]);
}); });
@@ -69,15 +69,29 @@ describe("ApiKeysTab", () => {
expect(hints).toHaveLength(1); expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent(HINT); expect(hints[0]).toHaveTextContent(HINT);
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/); expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
expect(hints[0]).toHaveTextContent(/in Wabenhain eintragen/);
}); });
it("also marks a key that reads the ERP mirror and books", async () => { it("also marks a key that reads the ERP mirror and books, naming both systems", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([ vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("mixed", ["erp-inventory:read", "shop-sync:write"]), makeKey("mixed", ["erp-inventory:read", "shop-sync:write"]),
]); ]);
renderTab(); renderTab();
expect(await screen.findByRole("status")).toHaveTextContent(HINT); const hint = await screen.findByRole("status");
expect(hint).toHaveTextContent(HINT);
expect(hint).toHaveTextContent(/in ERPNext bzw. Wabenhain eintragen/);
});
it("marks an older key with several read scopes", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("reads", ["shop-sync:read", "erp-inventory:read"]),
]);
renderTab();
const hint = await screen.findByRole("status");
expect(hint).toHaveTextContent(/mehrere Rechte/);
expect(hint).not.toHaveTextContent(HINT);
}); });
it("shows no hint when every key has one scope", async () => { it("shows no hint when every key has one scope", async () => {
@@ -10,7 +10,7 @@ import {
API_KEY_SCOPE_LABELS, API_KEY_SCOPE_LABELS,
API_KEY_SCOPES, API_KEY_SCOPES,
apiKeyScopeLabel, apiKeyScopeLabel,
hasReadAndWriteScopes, separationAdvice,
} from "@/lib/api-keys/scopes"; } from "@/lib/api-keys/scopes";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -259,6 +259,7 @@ export function ApiKeysTab() {
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{keys.map((apiKey) => { {keys.map((apiKey) => {
const state = keyState(apiKey); const state = keyState(apiKey);
const advice = state.active ? separationAdvice(apiKey.scopes) : null;
return ( return (
<li <li
key={apiKey.id} key={apiKey.id}
@@ -278,11 +279,9 @@ export function ApiKeysTab() {
</Badge> </Badge>
))} ))}
</span> </span>
{state.active && hasReadAndWriteScopes(apiKey) ? ( {advice ? (
<p role="status" className="text-xs text-muted-foreground"> <p role="status" className="text-xs text-muted-foreground">
Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte {advice}
Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in
Wabenhain eintragen, dann diesen widerrufen.
</p> </p>
) : null} ) : null}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { API_KEY_SCOPES, apiKeyScopeLabel, hasReadAndWriteScopes } from "./scopes"; import {
API_KEY_SCOPES,
apiKeyScopeLabel,
hasReadAndWriteScopes,
separationAdvice,
} from "./scopes";
describe("hasReadAndWriteScopes", () => { describe("hasReadAndWriteScopes", () => {
it("is true for a key that may read and book", () => { it("is true for a key that may read and book", () => {
@@ -18,6 +23,37 @@ describe("hasReadAndWriteScopes", () => {
}); });
}); });
describe("separationAdvice", () => {
it("is null for a key with one scope", () => {
expect(separationAdvice(["shop-sync:read"])).toBeNull();
expect(separationAdvice(["erp-inventory:read"])).toBeNull();
});
it("names Wabenhain for a shop key that reads and books", () => {
const advice = separationAdvice(["shop-sync:read", "shop-sync:write"]);
expect(advice).toMatch(/darf lesen und buchen/);
expect(advice).toMatch(/zwei getrennte Schlüssel/);
expect(advice).toMatch(/in Wabenhain eintragen/);
});
it("names each consuming system, not only Wabenhain", () => {
const advice = separationAdvice(["erp-inventory:read", "shop-sync:write"]);
expect(advice).toMatch(/darf lesen und buchen/);
expect(advice).toMatch(/in ERPNext bzw. Wabenhain eintragen/);
});
it("advises separate keys for several read scopes too", () => {
const advice = separationAdvice(["shop-sync:read", "erp-inventory:read"]);
expect(advice).toMatch(/mehrere Rechte/);
expect(advice).toMatch(/je Recht ein eigener Schlüssel/);
expect(advice).toMatch(/in Wabenhain bzw. ERPNext eintragen/);
});
it("falls back to a neutral wording for unknown scopes", () => {
expect(separationAdvice(["future:a", "future:b"])).toMatch(/im angebundenen System eintragen/);
});
});
describe("API key scopes", () => { describe("API key scopes", () => {
it("match the backend allowlist", () => { it("match the backend allowlist", () => {
expect(API_KEY_SCOPES).toEqual(["shop-sync:read", "shop-sync:write", "erp-inventory:read"]); expect(API_KEY_SCOPES).toEqual(["shop-sync:read", "shop-sync:write", "erp-inventory:read"]);
+30 -3
View File
@@ -1,5 +1,3 @@
import type { ApiKey } from "./types";
/** Berechtigungen, die ein API-Schlüssel tragen kann - wie API_KEY_SCOPES im Backend. */ /** Berechtigungen, die ein API-Schlüssel tragen kann - wie API_KEY_SCOPES im Backend. */
export const API_KEY_SCOPES = ["shop-sync:read", "shop-sync:write", "erp-inventory:read"] as const; export const API_KEY_SCOPES = ["shop-sync:read", "shop-sync:write", "erp-inventory:read"] as const;
@@ -24,7 +22,36 @@ export function apiKeyScopeLabel(scope: string): string {
* the server no longer creates such keys, the UI nudges towards two keys. * the server no longer creates such keys, the UI nudges towards two keys.
* The ERP inventory scope counts as reading. * The ERP inventory scope counts as reading.
*/ */
export function hasReadAndWriteScopes(apiKey: Pick<ApiKey, "scopes">): boolean { export function hasReadAndWriteScopes(apiKey: { scopes: readonly string[] }): boolean {
const isWrite = (scope: string) => WRITE_SCOPES.includes(scope); const isWrite = (scope: string) => WRITE_SCOPES.includes(scope);
return apiKey.scopes.some(isWrite) && apiKey.scopes.some((scope) => !isWrite(scope)); return apiKey.scopes.some(isWrite) && apiKey.scopes.some((scope) => !isWrite(scope));
} }
/** Welches System einen Schlüssel mit diesem Recht benutzt. */
const CONSUMING_SYSTEM: Record<ApiKeyScope, string> = {
"shop-sync:read": "Wabenhain",
"shop-sync:write": "Wabenhain",
"erp-inventory:read": "ERPNext",
};
function consumingSystems(scopes: readonly string[]): string[] {
const systems = scopes
.map((scope) => (CONSUMING_SYSTEM as Record<string, string>)[scope])
.filter((system): system is string => system !== undefined);
return [...new Set(systems)];
}
/**
* Hinweis für einen älteren Schlüssel mit mehreren Rechten (der Server legt
* nur noch Schlüssel mit genau einem Recht an), oder null. Nennt das System,
* in dem die neuen Schlüssel einzutragen sind.
*/
export function separationAdvice(scopes: readonly string[]): string | null {
if (new Set(scopes).size < 2) return null;
const systems = consumingSystems(scopes);
const where = systems.length > 0 ? `in ${systems.join(" bzw. ")}` : "im angebundenen System";
const why = hasReadAndWriteScopes({ scopes })
? "Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte Schlüssel: einer zum Lesen, einer zum Buchen."
: "Dieser Schlüssel trägt mehrere Rechte. Sicherer ist je Recht ein eigener Schlüssel.";
return `${why} Neue Schlüssel anlegen, ${where} eintragen, dann diesen widerrufen.`;
}
+5 -5
View File
@@ -18,14 +18,14 @@ Der ERPNext-Importer wird genau nach diesem Dokument gebaut. Änderungen am Vert
| Absender | Optional nur von bestimmten IP-Adressen/Netzen (Allowlist am Schlüssel). | | Absender | Optional nur von bestimmten IP-Adressen/Netzen (Allowlist am Schlüssel). |
| Fehlversuche | Nach 20 ungültigen Schlüsseln (unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten bekommt diese IP-Adresse (bei IPv6 das ganze /64-Netz) für weitere ungültige Schlüssel 15 Minuten lang `429`; die Zählung gilt für alle ApiCore-Instanzen gemeinsam. Ein gültiger Schlüssel wird davon nie abgewiesen, auch nicht von einer gesperrten Adresse – sonst könnte jeder, der dieselbe Adresse nutzt oder vortäuscht, ERPNext oder den Shop aussperren. Nicht wie ein Schlüssel aufgebaute Tokens (`ack_…` im falschen Format) bekommen sofort `401` und zählen nicht. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. | | Fehlversuche | Nach 20 ungültigen Schlüsseln (unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten bekommt diese IP-Adresse (bei IPv6 das ganze /64-Netz) für weitere ungültige Schlüssel 15 Minuten lang `429`; die Zählung gilt für alle ApiCore-Instanzen gemeinsam. Ein gültiger Schlüssel wird davon nie abgewiesen, auch nicht von einer gesperrten Adresse – sonst könnte jeder, der dieselbe Adresse nutzt oder vortäuscht, ERPNext oder den Shop aussperren. Nicht wie ein Schlüssel aufgebaute Tokens (`ack_…` im falschen Format) bekommen sofort `401` und zählen nicht. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. |
| Protokoll | Abgelehnte Schlüssel werden mit Grund, Präfix (`ack_abcd1234`) und IP protokolliert, nie mit dem Schlüssel. Query-Strings landen nicht im Log. | | Protokoll | Abgelehnte Schlüssel werden mit Grund, Präfix (`ack_abcd1234`) und IP protokolliert, nie mit dem Schlüssel. Query-Strings landen nicht im Log. |
| Trennung | Eigener Schlüssel nur für ERPNext. Der Wabenhain-Schlüssel (`shop-sync:*`) kommt an diese Endpunkte nicht heran und umgekehrt. | | Trennung | Eigener Schlüssel nur für ERPNext. Ein neuer Schlüssel trägt genau ein Recht; einen Schlüssel mit mehreren Rechten lehnt ApiCore beim Anlegen mit `400` ab (früher ausgestellte bleiben gültig). Der Wabenhain-Schlüssel (`shop-sync:*`) kommt an diese Endpunkte nicht heran und umgekehrt. |
Benutzer mit dem Recht `erp-inventory:read` (z. B. die Rolle Administrator) können die Endpunkte mit ihrem Login ebenfalls lesen, etwa zum Prüfen. Benutzer mit dem Recht `erp-inventory:read` (z. B. die Rolle Administrator) können die Endpunkte mit ihrem Login ebenfalls lesen, etwa zum Prüfen.
### Schlüssel anlegen (Inhaber) ### Schlüssel anlegen (Inhaber)
1. ApiCore → Verwaltung → Automatisierung → API-Schlüssel. 1. ApiCore → Verwaltung → Automatisierung → API-Schlüssel.
2. Name: `ERPNext`. Unter „Berechtigung“ **„ERP-Inventar lesen“** wählen (vorausgewählt ist „Shop lesen“). Ein Schlüssel trägt genau ein Recht; einen Schlüssel mit Lese- und Buchungsrecht lehnt ApiCore ab. 2. Name: `ERPNext`. Unter „Berechtigung“ **„ERP-Inventar lesen“** wählen (vorausgewählt ist „Shop lesen“). Ein Schlüssel trägt genau ein Recht; einen Schlüssel mit mehreren Rechten lehnt ApiCore ab.
3. IP-Adressen: die Adresse, von der ERPNext ApiCore erreicht (siehe [Rollout](#11-rollout)). Unbekannt? Leer lassen, einmal abrufen, dann am Schlüssel „Adressen ändern“ und die zuletzt benutzte Adresse übernehmen. 3. IP-Adressen: die Adresse, von der ERPNext ApiCore erreicht (siehe [Rollout](#11-rollout)). Unbekannt? Leer lassen, einmal abrufen, dann am Schlüssel „Adressen ändern“ und die zuletzt benutzte Adresse übernehmen.
4. Schlüssel kopieren und **nur** als Kubernetes-Secret für den ERPNext-Abgleich hinterlegen (nicht in `config.json`, nicht ins Repo, nicht in Tickets). 4. Schlüssel kopieren und **nur** als Kubernetes-Secret für den ERPNext-Abgleich hinterlegen (nicht in `config.json`, nicht ins Repo, nicht in Tickets).
@@ -106,7 +106,7 @@ Hinweise für den Abgleich:
- `remainingQuantity` ist der Sollwert für den Bestandsabgleich (Stock Reconciliation) im Lager „Imkerei (ApiCore)“. Er ist **live**: er enthält alle Bewegungen bis zum Abruf. `/bewegungen` liefert dagegen nur Bewegungen, die älter als 5 Minuten sind. Eine Buchung der letzten Minuten steckt also schon im Rest, ihre Bewegung kommt erst im nächsten Lauf. Der Importer darf deshalb den Rest nicht aus den gelieferten Bewegungen nachrechnen und mit `remainingQuantity` vergleichen; Sollwert ist immer `remainingQuantity`, die Bewegungen dienen nur der Einordnung (Vertrauensschrank, Eigenbedarf). - `remainingQuantity` ist der Sollwert für den Bestandsabgleich (Stock Reconciliation) im Lager „Imkerei (ApiCore)“. Er ist **live**: er enthält alle Bewegungen bis zum Abruf. `/bewegungen` liefert dagegen nur Bewegungen, die älter als 5 Minuten sind. Eine Buchung der letzten Minuten steckt also schon im Rest, ihre Bewegung kommt erst im nächsten Lauf. Der Importer darf deshalb den Rest nicht aus den gelieferten Bewegungen nachrechnen und mit `remainingQuantity` vergleichen; Sollwert ist immer `remainingQuantity`, die Bewegungen dienen nur der Einordnung (Vertrauensschrank, Eigenbedarf).
- Wird `initialQuantity` in ApiCore korrigiert, ändert sich der Rest sofort mit. - Wird `initialQuantity` in ApiCore korrigiert, ändert sich der Rest sofort mit.
- Eine irrtümlich angelegte Herstellungscharge ohne Bewegungen kann in ApiCore gelöscht werden; sie fehlt dann in der Liste und ist im Spiegel auf 0 zu setzen. Abfüllungen und Chargen mit Bewegungen werden nie gelöscht. - Eine irrtümlich angelegte Abfüllung oder Herstellungscharge ohne Bewegungen kann in ApiCore gelöscht werden; sie fehlt dann in der Liste und ist im Spiegel auf 0 zu setzen. Abfüllungen und Chargen mit Bewegungen werden nie gelöscht.
## 5. `GET /bewegungen` – Bestandsbewegungen ## 5. `GET /bewegungen` – Bestandsbewegungen
@@ -114,7 +114,7 @@ Jede Bestandsänderung einer Abfüllung oder Herstellungscharge, nie gelöscht (
| Parameter | Typ | Bedeutung | | Parameter | Typ | Bedeutung |
|---|---|---| |---|---|---|
| `seit` | ISO-Zeitpunkt oder -Datum | Nur Bewegungen ab diesem Zeitpunkt (einschließlich). Für den ersten Lauf, z. B. `seit=2026-01-01T00:00:00Z`. Nicht zusammen mit `nach`. | | `seit` | `JJJJ-MM-TT` (Mitternacht UTC) oder `JJJJ-MM-TTThh:mm[:ss[.sss]]` mit `Z` oder `±hh:mm`; Jahr 2000–2100 | Nur Bewegungen ab diesem Zeitpunkt (einschließlich). Für den ersten Lauf, z. B. `seit=2026-01-01T00:00:00Z`. Nicht zusammen mit `nach`. Andere ISO-8601-Formen (Wochen, Ordinaltage, ohne Striche, Uhrzeit ohne Zone) → `400`; `+` im Offset URL-kodieren (`%2B`). |
| `nach` | Cursor | Weiterlesen hinter der zuletzt gelieferten Bewegung | | `nach` | Cursor | Weiterlesen hinter der zuletzt gelieferten Bewegung |
| `limit` | 1–500 | wie oben | | `limit` | 1–500 | wie oben |
@@ -180,7 +180,7 @@ Fehlerantworten sind JSON mit `statusCode` und `message` (bei Validierungsfehler
| Status | Wann | Verhalten des Importers | | Status | Wann | Verhalten des Importers |
|---|---|---| |---|---|---|
| `400` | `limit` außerhalb 1–500 oder keine Ganzzahl; `nach` kein gültiger Cursor oder von einem anderen Endpunkt; `seit` kein ISO-Zeitpunkt; `seit` und `nach` zusammen; `jahr` außerhalb 1900–2100; unbekannter Parameter | Programmfehler – Schritt abbrechen, im Cockpit melden | | `400` | `limit` außerhalb 1–500 oder keine Ganzzahl; `nach` kein gültiger Cursor oder von einem anderen Endpunkt; `seit` nicht in einer der Formen aus Abschnitt 5 oder außerhalb 2000–2100; `seit` und `nach` zusammen; `jahr` außerhalb 1900–2100; unbekannter Parameter | Programmfehler – Schritt abbrechen, im Cockpit melden |
| `401` | Schlüssel fehlt, ist fehlerhaft, unbekannt, widerrufen oder abgelaufen | Schritt abbrechen; Hinweis „ApiCore-Schlüssel prüfen“ | | `401` | Schlüssel fehlt, ist fehlerhaft, unbekannt, widerrufen oder abgelaufen | Schritt abbrechen; Hinweis „ApiCore-Schlüssel prüfen“ |
| `403` | Schlüssel ohne `erp-inventory:read`, oder IP-Adresse nicht in der Allowlist des Schlüssels | Schritt abbrechen; Hinweis „Recht bzw. Adressen am Schlüssel prüfen“ | | `403` | Schlüssel ohne `erp-inventory:read`, oder IP-Adresse nicht in der Allowlist des Schlüssels | Schritt abbrechen; Hinweis „Recht bzw. Adressen am Schlüssel prüfen“ |
| `429` | ungültiger Schlüssel von einer Adresse mit zu vielen Fehlversuchen, oder allgemeines Anfragelimit überschritten | Schritt abbrechen, im nächsten Lauf erneut | | `429` | ungültiger Schlüssel von einer Adresse mit zu vielen Fehlversuchen, oder allgemeines Anfragelimit überschritten | Schritt abbrechen, im nächsten Lauf erneut |