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:
1 parent
53fab102f2
commit
25d95b0473
19 files changed
+426
-57
No files matched your search
@@ -69,15 +69,29 @@ describe("ApiKeysTab", () => {
|
||||
expect(hints).toHaveLength(1);
|
||||
expect(hints[0]).toHaveTextContent(HINT);
|
||||
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([
|
||||
makeKey("mixed", ["erp-inventory:read", "shop-sync:write"]),
|
||||
]);
|
||||
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 () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
API_KEY_SCOPE_LABELS,
|
||||
API_KEY_SCOPES,
|
||||
apiKeyScopeLabel,
|
||||
hasReadAndWriteScopes,
|
||||
separationAdvice,
|
||||
} from "@/lib/api-keys/scopes";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -259,6 +259,7 @@ export function ApiKeysTab() {
|
||||
<ul className="flex flex-col gap-2">
|
||||
{keys.map((apiKey) => {
|
||||
const state = keyState(apiKey);
|
||||
const advice = state.active ? separationAdvice(apiKey.scopes) : null;
|
||||
return (
|
||||
<li
|
||||
key={apiKey.id}
|
||||
@@ -278,11 +279,9 @@ export function ApiKeysTab() {
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
{state.active && hasReadAndWriteScopes(apiKey) ? (
|
||||
{advice ? (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte
|
||||
Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in
|
||||
Wabenhain eintragen, dann diesen widerrufen.
|
||||
{advice}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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", () => {
|
||||
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", () => {
|
||||
it("match the backend allowlist", () => {
|
||||
expect(API_KEY_SCOPES).toEqual(["shop-sync:read", "shop-sync:write", "erp-inventory:read"]);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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;
|
||||
|
||||
@@ -24,7 +22,36 @@ export function apiKeyScopeLabel(scope: string): string {
|
||||
* the server no longer creates such keys, the UI nudges towards two keys.
|
||||
* 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);
|
||||
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.`;
|
||||
}
|
||||
Reference in new issue
Block a user