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

@@ -1,13 +1,66 @@
import { describe, expect, it } from "vitest";
import { hasCombinedShopScopes } from "./scopes";
import {
API_KEY_SCOPES,
apiKeyScopeLabel,
hasReadAndWriteScopes,
separationAdvice,
} from "./scopes";
describe("hasCombinedShopScopes", () => {
describe("hasReadAndWriteScopes", () => {
it("is true for a key that may read and book", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
});
it("treats the ERP inventory scope as reading", () => {
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:write"] })).toBe(true);
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:read"] })).toBe(false);
});
it("is false for single-scope keys", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read"] })).toBe(false);
expect(hasCombinedShopScopes({ scopes: ["shop-sync:write"] })).toBe(false);
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read"] })).toBe(false);
expect(hasReadAndWriteScopes({ scopes: ["shop-sync:write"] })).toBe(false);
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read"] })).toBe(false);
});
});
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", () => {
it("match the backend allowlist", () => {
expect(API_KEY_SCOPES).toEqual(["shop-sync:read", "shop-sync:write", "erp-inventory:read"]);
});
it("label known scopes and pass unknown ones through", () => {
expect(apiKeyScopeLabel("erp-inventory:read")).toBe("Inventar-Spiegel lesen (ERPNext)");
expect(apiKeyScopeLabel("future:scope")).toBe("future:scope");
});
});
+45 -4
View File
@@ -1,16 +1,57 @@
import type { ApiKey } from "./types";
/** 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 type ApiKeyScope = "shop-sync:read" | "shop-sync:write";
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)",
"shop-sync:write": "Verkäufe und Rückbuchungen buchen (Wabenhain)",
"erp-inventory:read": "Inventar-Spiegel lesen (ERPNext)",
};
/** Buchende Rechte - wie API_KEY_WRITE_SCOPES im Backend; alle anderen lesen nur. */
const WRITE_SCOPES: readonly string[] = ["shop-sync:write"];
/** Label für die Anzeige; unbekannte Rechte (neueres Backend) unverändert. */
export function apiKeyScopeLabel(scope: string): string {
return (API_KEY_SCOPE_LABELS as Record<string, string>)[scope] ?? scope;
}
/**
* Keys issued before reading and booking were split may still carry both;
* the server no longer creates such keys, the UI nudges towards two keys.
* The ERP inventory scope counts as reading.
*/
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, "scopes">): boolean {
return apiKey.scopes.includes("shop-sync:read") && apiKey.scopes.includes("shop-sync:write");
export function hasReadAndWriteScopes(apiKey: { scopes: readonly string[] }): boolean {
const isWrite = (scope: string) => WRITE_SCOPES.includes(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.`;
}