refactor(apicore-sync): API key and shop feed only, no password login

The sync now talks to ApiCore exclusively through the secured shop
interface: GET /shop-sync with an API key as bearer token. Removed:

- the email/password service-account login (and its MFA handling),
- the fallback to ApiCore's general read endpoints (/harvests,
  /products, /extractions, /bottling-batches, /apiaries).

A missing interface (404) or a rejected key (401/403) now stops the run
with a specific message instead of falling back. APICORE_SYNC_EMAIL and
APICORE_SYNC_PASSWORD are gone from .env.example, README and the k8s
secret template; the system status needs APICORE_API_KEY.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 06:05:11 +02:00
1 parent 55be1fe881
commit 3bc0693e4d
10 files changed
+220 -520

No files matched your search

+6 -20
View File
@@ -91,29 +91,15 @@ NEXT_PUBLIC_TURNSTILE_SITE_KEY=
SMTP_USER=
SMTP_FROM="Wabenhain <[email protected]>"
# ApiCore-Synchronisierung (optional). Zwei Knoepfe nutzen sie: admin/ernten
# -> "ApiCore-Synchronisierung" (Ernten + Chargen) und admin/produkte ->
# "ApiCore-Synchronisierung" (Produktkatalog). Ohne diese drei Werte zeigen
# beide einen Konfigurationsfehler statt eines Ergebnisses. ApiCore ist ein
# separates Projekt (NestJS-Backend) mit reinem E-Mail/Passwort-Login (kein
# API-Key-Mechanismus) — hier IDEALERWEISE die Zugangsdaten eines
# dedizierten, MFA-freien Service-Kontos eintragen, nicht die eines echten
# Admin-Zugangs. Benoetigte Berechtigung (in ApiCore unter Benutzer & Rollen
# eine eigene Rolle anlegen, die NUR diese eine Berechtigung hat):
# shop-sync:read freigegebene Shop-Daten (GET /shop-sync) — liefert nur
# Ernte-Fakten, Artikelstamm und Abfuellungen, keine Notizen
# Nur fuer aeltere ApiCore-Staende ohne /shop-sync (Rueckfall) noetig:
# harvests:read, products:read, extractions:read, bottling:read, apiaries:read
# ApiCore-Synchronisierung (optional). Der Sync-Knopf im Admin-Dashboard holt
# Ernten, Chargen und Produkte aus ApiCores Shop-Schnittstelle (GET
# /shop-sync). Zugang ausschliesslich per API-Schluessel: in ApiCore unter
# Verwaltung -> Automatisierung -> API-Schluessel anlegen (Berechtigung
# "Shop-Daten lesen"). Kein Benutzerkonto, kein Passwort, jederzeit
# widerrufbar. Ohne beide Werte meldet der Knopf einen Konfigurationsfehler.
# Details siehe src/lib/apicore-client.ts.
APICORE_API_URL=
# Empfohlen: API-Schluessel aus ApiCore → Verwaltung → Automatisierung →
# API-Schluessel (Berechtigung "Shop-Daten lesen"). Kein Passwort, keine MFA,
# jederzeit widerrufbar. Ist er gesetzt, werden die beiden Werte darunter
# nicht gebraucht.
APICORE_API_KEY=
# Nur fuer aeltere ApiCore-Staende ohne API-Schluessel:
APICORE_SYNC_EMAIL=
APICORE_SYNC_PASSWORD=
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
+1 -1
View File
@@ -337,7 +337,7 @@ Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine C
Wiederholte Läufe sind gefahrlos: Ernten werden über den stabilen Schlüssel `apicore-<ApiCore-ID>` als `Harvest.slug` wiedererkannt, Chargen über die ApiCore-Losnummer als `Batch.code`, Produkte über `Product.apicoreBottlingId`.
**Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Für ältere ApiCore-Stände ohne API-Schlüssel funktioniert weiter `APICORE_SYNC_EMAIL`/`APICORE_SYNC_PASSWORD` eines eigenen, MFA-freien Service-Kontos. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
**Einrichtung:** `APICORE_API_URL` und `APICORE_API_KEY` in `.env` setzen (siehe `.env.example`). Den Schlüssel in ApiCore unter Verwaltung → Automatisierung → API-Schlüssel anlegen; er darf nur die freigegebenen Shop-Daten lesen (`GET /shop-sync`, Berechtigung `shop-sync:read`), hat kein Passwort und lässt sich dort jederzeit widerrufen. Einen Zugang per Benutzer und Passwort gibt es nicht mehr. Fehlt eine Berechtigung, meldet der Sync das konkret als HTTP 403 statt still nichts zu tun. Zuständigkeit: Imkerei-Fakten (Datum, Losnummer, MHD, Abfüllmenge) kommen aus ApiCore, alles Verkaufsbezogene (Preise, Texte, Bilder, Sichtbarkeit, Shop-Bestand) wird nur hier gepflegt. Details und die Zuordnung Feld für Feld: `src/lib/apicore-client.ts`, `src/lib/apicore-mapping.ts` und `src/app/admin/actions/apicore-sync.ts`.
## Vollständiger Docker-Betrieb
+5 -12
View File
@@ -102,19 +102,12 @@ stringData:
# ist dagegen oeffentlich und steht dort.
GOOGLE_PLACES_API_KEY: ""
# Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte ->
# "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen
# Konfigurationsfehler, statt still nichts zu tun.
#
# Empfohlen: API-Schluessel aus ApiCore (Verwaltung -> Automatisierung ->
# API-Schluessel, Berechtigung "Shop-Daten lesen"). Kein Passwort, keine
# MFA, jederzeit widerrufbar. Die Adresse APICORE_API_URL ist nicht geheim
# und steht in 01-configmap.yaml.
# API-Schluessel fuer den ApiCore-Sync (Admin-Dashboard -> "Alles
# synchronisieren"). In ApiCore unter Verwaltung -> Automatisierung ->
# API-Schluessel anlegen (Berechtigung "Shop-Daten lesen"); kein Passwort,
# jederzeit widerrufbar. Leer = der Sync-Knopf meldet einen
# Konfigurationsfehler. APICORE_API_URL steht in 01-configmap.yaml.
APICORE_API_KEY: ""
# Nur fuer aeltere ApiCore-Staende ohne API-Schluessel: eigenes,
# MFA-freies Service-Konto mit Leserechten statt eines Admin-Zugangs.
APICORE_SYNC_EMAIL: ""
APICORE_SYNC_PASSWORD: ""
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
+4 -4
View File
@@ -5,13 +5,13 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import {
openApiCoreSession,
getApiCoreConnection,
fetchApiCoreShopData,
isSellableStatus,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreShopData,
type ApiCoreHarvestWithApiaryName,
type ApiCoreHarvest,
type ApiCoreProduct,
type ApiCoreBottlingBatchWithHarvestId,
} from "@/lib/apicore-client";
@@ -128,7 +128,7 @@ function describeError(error: unknown): string {
// Ernten
// ---------------------------------------------------------------------------
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<HarvestSyncCounts> {
async function syncHarvests(sources: ApiCoreHarvest[]): Promise<HarvestSyncCounts> {
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
for (const source of sources) {
@@ -480,7 +480,7 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
let source: ApiCoreShopData;
try {
source = await fetchApiCoreShopData(await openApiCoreSession());
source = await fetchApiCoreShopData(getApiCoreConnection());
} catch (error: unknown) {
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
}
+86 -111
View File
@@ -1,14 +1,14 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, test, expect, vi, afterEach } from "vitest";
import {
openApiCoreSession,
fetchApiCoreHarvests,
fetchApiCoreBottlingBatches,
getApiCoreConnection,
fetchApiCoreShopData,
ApiCoreConfigError,
ApiCoreRequestError,
type ApiCoreSession,
type ApiCoreConnection,
} from "./apicore-client";
const session: ApiCoreSession = { baseUrl: "http://apicore.test/api/v1", token: "token" };
const API_KEY = `ack_abcd1234_${"A".repeat(43)}`;
const connection: ApiCoreConnection = { baseUrl: "http://apicore.test/api/v1", apiKey: API_KEY };
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -17,92 +17,6 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
describe("apicore-client", () => {
beforeEach(() => {
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
vi.stubEnv("APICORE_SYNC_EMAIL", "[email protected]");
vi.stubEnv("APICORE_SYNC_PASSWORD", "secret");
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => {
// Arrange
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new DOMException("The operation timed out.", "TimeoutError"))
);
// Act & Assert
await expect(openApiCoreSession()).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
});
test("nutzt einen API-Schluessel direkt als Bearer-Token, ohne Passwort-Login", async () => {
// Arrange
const key = `ack_abcd1234_${"A".repeat(43)}`;
vi.stubEnv("APICORE_API_KEY", key);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
// Act
const opened = await openApiCoreSession();
// Assert
expect(opened).toEqual({ baseUrl: "http://apicore.test/api/v1", token: key });
expect(fetchMock).not.toHaveBeenCalled();
});
test("weist einen falsch kopierten API-Schluessel mit klarer Meldung ab", async () => {
// Arrange
vi.stubEnv("APICORE_API_KEY", "ack_zu-kurz");
// Act & Assert
await expect(openApiCoreSession()).rejects.toThrow(/Format eines ApiCore-API-Schlüssels/);
});
test("setzt an jeder Anfrage ein Zeitlimit", async () => {
// Arrange
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
// Act
await fetchApiCoreHarvests(session);
// Assert
expect(fetchMock).toHaveBeenCalled();
for (const call of fetchMock.mock.calls) {
expect((call[1] as RequestInit).signal).toBeInstanceOf(AbortSignal);
}
});
test("wertet einen fehlgeschlagenen Abruf nie als leere Datenquelle", async () => {
// Arrange: ApiCore antwortet mit 500 — eine leere Liste daraus zu machen,
// hiesse fuer den Sync "es gibt nichts", und darauf darf nichts aufbauen.
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
// Act & Assert
await expect(fetchApiCoreHarvests(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
test("verwirft eine Antwort mit unerwarteter Struktur statt sie teilweise zu uebernehmen", async () => {
// Arrange: Schleuderungen ok, Chargen ohne Pflichtfeld lotNumber.
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) =>
url.endsWith("/extractions")
? jsonResponse([])
: jsonResponse([{ id: "b1", date: "2026-08-01", bestBeforeDate: "2028-08-01", jarCount: 10 }])
)
);
// Act & Assert
await expect(fetchApiCoreBottlingBatches(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
describe("fetchApiCoreShopData", () => {
const feed = {
contractVersion: 1,
generatedAt: "2026-09-27T10:00:00.000Z",
@@ -135,36 +49,97 @@ describe("apicore-client", () => {
],
};
test("holt alles aus der Shop-Schnittstelle in einem Abruf", async () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("getApiCoreConnection", () => {
test("liefert Adresse und API-Schluessel", () => {
// Arrange
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1/");
vi.stubEnv("APICORE_API_KEY", API_KEY);
// Act & Assert
expect(getApiCoreConnection()).toEqual(connection);
});
test("verlangt einen API-Schluessel — Benutzer und Passwort reichen nicht mehr", () => {
// Arrange: nur die alte Konfiguration.
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
vi.stubEnv("APICORE_API_KEY", "");
vi.stubEnv("APICORE_SYNC_EMAIL", "[email protected]");
vi.stubEnv("APICORE_SYNC_PASSWORD", "secret");
// Act & Assert
expect(() => getApiCoreConnection()).toThrow(ApiCoreConfigError);
});
test("weist einen falsch kopierten API-Schluessel mit klarer Meldung ab", () => {
// Arrange
vi.stubEnv("APICORE_API_URL", "http://apicore.test/api/v1");
vi.stubEnv("APICORE_API_KEY", "ack_zu-kurz");
// Act & Assert
expect(() => getApiCoreConnection()).toThrow(/Format eines ApiCore-API-Schlüssels/);
});
});
describe("fetchApiCoreShopData", () => {
test("holt alles in einem Abruf, mit dem Schluessel als Bearer-Token und Zeitlimit", async () => {
// Arrange
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(feed));
vi.stubGlobal("fetch", fetchMock);
// Act
const data = await fetchApiCoreShopData(session);
const data = await fetchApiCoreShopData(connection);
// Assert
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("http://apicore.test/api/v1/shop-sync");
expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese", notes: null });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://apicore.test/api/v1/shop-sync");
expect(init.headers).toEqual({ Authorization: `Bearer ${API_KEY}` });
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(data.harvests[0]).toMatchObject({ id: "h1", apiaryName: "Streuobstwiese" });
expect(data.bottlings[0]).toMatchObject({ id: "b1", harvestSourceId: "h1", jarSize: 500 });
expect(data.products[0].id).toBe("p1");
});
test("faellt bei einem aelteren ApiCore ohne Shop-Schnittstelle auf die Einzel-Endpunkte zurueck", async () => {
test("meldet eine ausbleibende Antwort als Zeitueberschreitung statt zu haengen", async () => {
// Arrange
const fetchMock = vi.fn(async (url: string) =>
url.endsWith("/shop-sync") ? jsonResponse({ message: "Not Found" }, 404) : jsonResponse([])
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new DOMException("The operation timed out.", "TimeoutError"))
);
// Act & Assert
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/keine Antwort innerhalb von 20 Sekunden/);
});
test("benennt einen widerrufenen oder abgelaufenen Schluessel", async () => {
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Unauthorized" }, 401)));
// Act & Assert
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/API-Schlüssel abgelehnt/);
});
test("hat keinen Rueckfall auf andere Endpunkte, wenn die Schnittstelle fehlt", async () => {
// Arrange: aelteres ApiCore ohne /shop-sync.
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: "Not Found" }, 404));
vi.stubGlobal("fetch", fetchMock);
// Act
const data = await fetchApiCoreShopData(session);
// Act & Assert
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Shop-Schnittstelle nicht an/);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
// Assert
const paths = fetchMock.mock.calls.map((call) => String(call[0]).replace(session.baseUrl, ""));
expect(paths).toEqual(expect.arrayContaining(["/harvests", "/products", "/extractions", "/bottling-batches"]));
expect(data).toEqual({ harvests: [], products: [], bottlings: [] });
test("wertet einen fehlgeschlagenen Abruf nie als leere Datenquelle", async () => {
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "boom" }, 500)));
// Act & Assert
await expect(fetchApiCoreShopData(connection)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
test("bricht bei einer unbekannten Vertragsversion ab, statt Daten falsch zu deuten", async () => {
@@ -172,15 +147,15 @@ describe("apicore-client", () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ ...feed, contractVersion: 2 })));
// Act & Assert
await expect(fetchApiCoreShopData(session)).rejects.toThrow(/Schnittstellenversion 2/);
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/Schnittstellenversion 2/);
});
test("meldet fehlende Berechtigung, statt eine leere Datenquelle anzunehmen", async () => {
// Arrange
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ message: "Forbidden" }, 403)));
test("verwirft eine Antwort mit unerwarteter Struktur als Ganzes", async () => {
// Arrange: Abfuellung ohne Pflichtfeld lotNumber.
const broken = { ...feed, bottlings: [{ ...feed.bottlings[0], lotNumber: undefined }] };
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(broken)));
// Act & Assert
await expect(fetchApiCoreShopData(session)).rejects.toBeInstanceOf(ApiCoreRequestError);
});
await expect(fetchApiCoreShopData(connection)).rejects.toThrow(/unerwartete Antwortstruktur/);
});
});
+105 -326
View File
@@ -1,49 +1,28 @@
import { z } from "zod";
/**
* HTTP-Client fuer die ApiCore-Backend-API (separates Projekt, lokal unter
* .../ApiCore/ApiCore-Backend — ein NestJS-Server mit eigener Postgres-DB),
* die die Rohdaten fuer den Admin-Sync bereitstellt
* (admin/actions/apicore-sync.ts): Ernten, Produkte und Chargen.
* HTTP-Client fuer ApiCore (separates Projekt, NestJS-Backend mit eigener
* Datenbank). Wabenhain liest dort ausschliesslich die dafuer abgesicherte
* Shop-Schnittstelle GET /shop-sync: eine Feld-Allowlist aus Ernte-Fakten,
* Artikelstamm und Abfuellungen, ohne interne Notizen.
*
* Auth-Modell von ApiCore: E-Mail/Passwort-Login -> kurzlebiges JWT
* (POST /auth/login), Endpunkte pruefen zusaetzlich granulare Berechtigungen
* ("harvests:read" fuer GET /harvests). Es gibt dort AKTUELL KEINEN
* API-Key-/Service-Account-Mechanismus fuer maschinelle Zugriffe — nur den
* interaktiven Login-Flow, optional mit einem zweiten MFA-Schritt
* (POST /auth/mfa/verify), siehe ApiCore-Backend/src/auth/. Fuer diesen
* automatisierten Sync wird dringend ein eigenes, MFA-freies Service-Konto
* mit genau EINER Berechtigung empfohlen statt eines echten Admin-Zugangs:
* "shop-sync:read" fuer ApiCores Shop-Schnittstelle (fetchApiCoreShopData,
* ganz unten). Nur aeltere ApiCore-Staende ohne diese Schnittstelle brauchen
* noch "harvests:read", "products:read", "extractions:read", "bottling:read"
* und optional "apiaries:read".
* Zugang NUR per API-Schluessel (in ApiCore unter Verwaltung →
* Automatisierung → API-Schluessel angelegt, Berechtigung "shop-sync:read").
* Der Schluessel wird direkt als Bearer-Token gesendet: kein Benutzerkonto,
* kein Passwort, kein Login, in ApiCore jederzeit widerrufbar. Einen
* Rueckfall auf Benutzer/Passwort oder allgemeine Lese-Endpunkte gibt es
* bewusst nicht mehr.
*
* Benoetigte Umgebungsvariablen (siehe .env.example):
* APICORE_API_URL z.B. "http://localhost:3001/api" (globaler Prefix
* "api" laut ApiCore-Backend/src/app.config.ts)
* APICORE_SYNC_EMAIL E-Mail des Service-Kontos
* APICORE_SYNC_PASSWORD Passwort des Service-Kontos
* APICORE_API_URL z.B. "https://apicore.example.org/api/v1"
* APICORE_API_KEY der Schluessel, Format ack_<8>_<43>
*
* Ohne diese drei Variablen wirft openApiCoreSession() sofort einen
* ApiCoreConfigError — der Aufrufer (apicore-sync.ts) zeigt dessen Message
* dem Admin an, statt die Aktion stillschweigend nichts tun zu lassen.
* Fehlt eines davon, wirft getApiCoreConnection() einen ApiCoreConfigError —
* der Aufrufer (apicore-sync.ts) zeigt dessen Meldung dem Admin an.
*
* Die Anmeldung ist bewusst von den einzelnen Abfragen getrennt
* (openApiCoreSession -> fetchApiCore*(session)): ein Sync-Lauf zieht Ernten,
* Produkte UND Chargen und kaeme sonst auf drei getrennte Passwort-Logins
* gegen dasselbe Konto — bei ApiCores Anmelde-Bremse ein unnoetiges Risiko,
* den eigenen Service-Zugang auszusperren.
*
* Fuer Wabenhains "Charge" (prisma/schema.prisma: model Batch) ist NICHT
* ApiCores "HoneyBatch" (roher Rohhonig direkt nach der Schleuderung, vor
* dem Abfuellen) das fachlich richtige Gegenstueck, sondern "BottlingBatch"
* (Schleuderung -> HoneyBatch -> Abfuellung -> BottlingBatch) — dessen
* Felder passen fast 1:1: lotNumber/code, date/fillDate,
* bestBeforeDate/bestBeforeDate, jarCount/glassCount. Die zugehoerige Ernte
* ergibt sich erst ueber zwei Stufen (BottlingBatch.honeyBatch.extractionId
* -> Extraction.harvests[].harvestId), daher werden zusaetzlich alle
* Schleuderungen geladen (GET /extractions) und clientseitig verknuepft.
* Fuer Wabenhains "Charge" (model Batch) ist ApiCores Abfuellung
* (BottlingBatch) das Gegenstueck, nicht die Rohhonig-Charge: lotNumber/code,
* date/fillDate, bestBeforeDate, jarCount/glassCount.
*/
export class ApiCoreConfigError extends Error {}
@@ -51,11 +30,13 @@ export class ApiCoreRequestError extends Error {}
/**
* Obergrenze je Anfrage. Ohne sie haengt die Admin-Aktion bei einem ApiCore,
* das die Verbindung annimmt, aber nie antwortet, unbegrenzt — der Knopf
* dreht sich, und es gibt weder Ergebnis noch Fehlermeldung.
* das die Verbindung annimmt, aber nie antwortet, unbegrenzt.
*/
const REQUEST_TIMEOUT_MS = 20_000;
// ack_<8 Zeichen>_<43 Zeichen> — Format der ApiCore-API-Schluessel.
const API_KEY_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
function describeFetchFailure(error: unknown): string {
// Checked by name, not instanceof Error: AbortSignal.timeout() rejects with
// a DOMException, which is not an Error subclass in every runtime.
@@ -66,36 +47,20 @@ function describeFetchFailure(error: unknown): string {
return error instanceof Error ? error.message : "Unbekannter Fehler";
}
const loginResponseSchema = z.union([
z.object({ accessToken: z.string().min(1) }),
z.object({ mfaRequired: z.literal(true), mfaToken: z.string() }),
]);
/** Angemeldete Verbindung zu ApiCore, siehe Modul-Kommentar oben. */
export interface ApiCoreSession {
/** Adresse und Schluessel fuer ApiCore. */
export interface ApiCoreConnection {
readonly baseUrl: string;
readonly token: string;
readonly apiKey: string;
}
type ApiCoreCredentials =
| { baseUrl: string; apiKey: string }
| { baseUrl: string; email: string; password: string };
// ack_<8 Zeichen>_<43 Zeichen> — Format der ApiCore-API-Schluessel.
const API_KEY_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/;
/**
* Bevorzugt einen ApiCore-API-Schluessel (APICORE_API_KEY): kein Passwort,
* keine MFA-Frage, nur die Berechtigung "shop-sync:read", in ApiCore
* jederzeit widerrufbar. E-Mail/Passwort eines Service-Kontos bleibt als
* Rueckfall fuer aeltere ApiCore-Staende ohne API-Schluessel.
*/
function getConfig(): ApiCoreCredentials {
export function getApiCoreConnection(): ApiCoreConnection {
const baseUrl = process.env.APICORE_API_URL?.trim().replace(/\/$/, "");
const apiKey = process.env.APICORE_API_KEY?.trim();
const email = process.env.APICORE_SYNC_EMAIL;
const password = process.env.APICORE_SYNC_PASSWORD;
if (baseUrl && apiKey) {
if (!baseUrl || !apiKey) {
throw new ApiCoreConfigError(
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)."
);
}
if (!API_KEY_PATTERN.test(apiKey)) {
throw new ApiCoreConfigError(
"APICORE_API_KEY hat nicht das Format eines ApiCore-API-Schlüssels (ack_…). Bitte den Schlüssel vollständig aus ApiCore → Verwaltung → Automatisierung → API-Schlüssel übernehmen."
@@ -103,156 +68,43 @@ function getConfig(): ApiCoreCredentials {
}
return { baseUrl, apiKey };
}
if (baseUrl && email && password) {
return { baseUrl, email, password };
}
throw new ApiCoreConfigError(
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL und APICORE_API_KEY setzen (siehe .env.example)."
);
}
export async function openApiCoreSession(): Promise<ApiCoreSession> {
const config = getConfig();
// Ein API-Schluessel wird direkt als Bearer-Token gesendet — kein Login.
if ("apiKey" in config) {
return { baseUrl: config.baseUrl, token: config.apiKey };
}
const { baseUrl, email, password } = config;
let res: Response;
try {
res = await fetch(`${baseUrl}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
}
const parsed = loginResponseSchema.safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError("ApiCore-Login: unerwartete Antwortstruktur.");
}
if ("mfaRequired" in parsed.data) {
throw new ApiCoreConfigError(
"Das konfigurierte ApiCore-Sync-Konto hat MFA aktiviert. Fuer den automatisierten Sync wird ein MFA-freies Service-Konto benoetigt (siehe Kommentar in apicore-client.ts)."
);
}
return { baseUrl, token: parsed.data.accessToken };
}
// ---------------------------------------------------------------------------
// Datenvertrag der Shop-Schnittstelle
// ---------------------------------------------------------------------------
/**
* Eine GET-Abfrage inklusive Schema-Pruefung. Die drei Fehlerfaelle (nicht
* erreichbar / HTTP-Fehler / unerwartete Struktur) werden bewusst getrennt
* gemeldet: sie haben voellig verschiedene Ursachen — Netzwerk bzw. falsche
* URL, eine fehlende Berechtigung des Service-Kontos, und eine geaenderte
* ApiCore-Version.
* Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore
* erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne
* Versionswechsel dazu und werden hier ignoriert.
*/
async function fetchFromApiCore(session: ApiCoreSession, path: string): Promise<Response> {
try {
return await fetch(`${session.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${session.token}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
}
async function getJson<T>(
session: ApiCoreSession,
path: string,
schema: z.ZodType<T>,
label: string
): Promise<T> {
return parseResponse(await fetchFromApiCore(session, path), schema, label);
}
async function parseResponse<T>(res: Response, schema: z.ZodType<T>, label: string): Promise<T> {
if (!res.ok) {
throw new ApiCoreRequestError(
res.status === 403
? `ApiCore: ${label} konnten nicht geladen werden (HTTP 403) — dem Sync-Konto fehlt vermutlich die noetige Leseberechtigung, siehe .env.example.`
: `ApiCore: ${label} konnten nicht geladen werden (HTTP ${res.status}).`
);
}
const parsed = schema.safeParse(await res.json());
if (!parsed.success) {
throw new ApiCoreRequestError(`ApiCore: unerwartete Antwortstruktur fuer ${label}.`);
}
return parsed.data;
}
// ---------------------------------------------------------------------------
// Ernten
// ---------------------------------------------------------------------------
const apiarySchema = z.object({ id: z.string(), name: z.string() });
export const SUPPORTED_SHOP_SYNC_CONTRACT = 1;
const harvestSchema = z.object({
id: z.string(),
apiaryId: z.string(),
apiaryName: z.string(),
date: z.string(),
honeyType: z.string().nullable().optional(),
honeySuperCount: z.number().nullable().optional(),
grossWeight: z.number().nullable().optional(),
netWeight: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
honeyType: z.string().nullable(),
honeySuperCount: z.number().nullable(),
grossWeight: z.number().nullable(),
netWeight: z.number().nullable(),
});
export type ApiCoreHarvest = z.infer<typeof harvestSchema>;
export interface ApiCoreHarvestWithApiaryName extends ApiCoreHarvest {
apiaryName: string | null;
}
/** Best effort: Standortnamen sind eine Komfort-Anreicherung, kein kritischer Pfad. */
async function fetchApiaryNames(session: ApiCoreSession): Promise<Map<string, string>> {
try {
const apiaries = await getJson(session, "/apiaries", z.array(apiarySchema), "Standorte");
return new Map(apiaries.map((a) => [a.id, a.name]));
} catch {
return new Map();
}
}
export async function fetchApiCoreHarvests(
session: ApiCoreSession
): Promise<ApiCoreHarvestWithApiaryName[]> {
const harvests = await getJson(session, "/harvests", z.array(harvestSchema), "Ernten");
const apiaryNames = await fetchApiaryNames(session);
return harvests.map((harvest) => ({
...harvest,
apiaryName: apiaryNames.get(harvest.apiaryId) ?? null,
}));
}
// ---------------------------------------------------------------------------
// Produkte
// ---------------------------------------------------------------------------
/**
* ApiCores Product (ApiCore-Backend/prisma/schema.prisma). Zwei bewusste
* Abweichungen vom dortigen Prisma-Modell:
* ApiCores Artikelstamm. Zwei bewusste Abweichungen vom dortigen Modell:
*
* - `status` wird als freier String gelesen, nicht als z.enum([...]). ApiCores
* ProductStatus kennt heute nur ACTIVE und DISCONTINUED; kaeme dort ein
* dritter Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort verwerfen
* und der Sync mit "unerwartete Antwortstruktur" abbrechen, statt nur den
* einen unbekannten Artikel zu ignorieren. Siehe isSellableStatus().
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore KEINE Einheit
* — weder im Prisma-Modell noch an den Formularfeldern ("Gebindegroesse",
* "Gewicht", jeweils ohne Zusatz). Als Einheit wird GRAMM angenommen, weil
* ApiCore dieselbe Groesse an der Abfuellung (BottlingBatch.jarSize) mit
* Werten wie 500 fuehrt und sie in seiner eigenen Oberflaeche als "g"
* ausgibt (ApiCore-Frontend .../herkunft/[lot]/page.tsx). Die Annahme ist
* damit begruendet, aber von ApiCore nicht garantiert — deshalb ist das
* uebernommene Gewicht am importierten Entwurf im Admin sichtbar und
* aenderbar, bevor das Produkt ueberhaupt verkauft werden kann.
* - `status` wird als freier String gelesen, nicht als Enum: kaeme in ApiCore
* ein weiterer Wert hinzu, wuerde ein Enum-Schema die GESAMTE Antwort
* verwerfen, statt nur den einen unbekannten Artikel zu ignorieren. Siehe
* isSellableStatus().
* - Mengenangaben (`packagingSize`, `weight`) tragen in ApiCore keine Einheit.
* Angenommen wird GRAMM, weil ApiCore dieselbe Groesse an der Abfuellung
* (jarSize) mit Werten wie 500 fuehrt und in der eigenen Oberflaeche als
* "g" ausgibt. Das uebernommene Gewicht ist am importierten Entwurf im Admin
* sichtbar und aenderbar, bevor das Produkt verkauft werden kann.
*/
const productSchema = z.object({
id: z.string(),
@@ -268,165 +120,92 @@ const productSchema = z.object({
export type ApiCoreProduct = z.infer<typeof productSchema>;
/**
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere (heute DISCONTINUED,
* morgen vielleicht ein weiterer Wert) wird bewusst als "nicht verkaufsfaehig"
* behandelt: ein unbekannter Status darf nicht dazu fuehren, dass ein Artikel
* ungeprueft als Shop-Entwurf auftaucht.
* Nur ACTIVE gilt als verkaufsfaehig. Alles andere wird bewusst als "nicht
* verkaufsfaehig" behandelt: ein unbekannter Status darf nicht dazu fuehren,
* dass ein Artikel ungeprueft als Shop-Entwurf auftaucht.
*/
export function isSellableStatus(status: string): boolean {
return status === "ACTIVE";
}
export async function fetchApiCoreProducts(session: ApiCoreSession): Promise<ApiCoreProduct[]> {
return getJson(session, "/products", z.array(productSchema), "Produkte");
}
// ---------------------------------------------------------------------------
// Chargen (BottlingBatch, siehe Modul-Kommentar oben)
// ---------------------------------------------------------------------------
const extractionSchema = z.object({
id: z.string(),
// ApiCores GET /extractions liefert je Schleuderung die verknuepften
// Ernten als ExtractionHarvest-Zeilen — pro Zeile reicht harvestId, die
// uebrigen (verschachtelten Volk-/Standort-)Felder werden nicht gebraucht.
harvests: z.array(z.object({ harvestId: z.string() })),
});
const bottlingBatchSchema = z.object({
const bottlingSchema = z.object({
id: z.string(),
lotNumber: z.string(),
date: z.string(),
bestBeforeDate: z.string(),
// Glasgroesse in Gramm (siehe Einheiten-Hinweis an productSchema).
jarSize: z.number().nullable(),
jarCount: z.number(),
// Glasgroesse dieser Abfuellung, in Gramm (siehe Einheiten-Hinweis an
// productSchema oben). Bewusst optional gelesen, obwohl ApiCore das Feld
// heute immer fuellt: faellt es einmal weg, soll der Sync auf die Angabe am
// Artikel zurueckfallen (deriveJarWeightGrams) statt die gesamte Antwort zu
// verwerfen.
jarSize: z.number().nullable().optional(),
// Optional: eine Abfuellung muss in ApiCore keinem Produkt zugeordnet sein.
// Ist sie es, verknuepft der Sync die daraus entstandene Wabenhain-Charge
// mit dem passenden synchronisierten Produkt (siehe apicore-sync.ts).
productId: z.string().nullable().optional(),
honeyBatch: z.object({
id: z.string(),
extractionId: z.string(),
}),
// Eine Abfuellung muss in ApiCore keinem Artikel zugeordnet sein.
productId: z.string().nullable(),
harvestIds: z.array(z.string()),
});
export type ApiCoreBottlingBatch = z.infer<typeof bottlingBatchSchema>;
/**
* Eine Abfuellung, wie der Sync sie braucht — unabhaengig davon, ob sie aus
* der Shop-Schnittstelle (fetchApiCoreShopData) oder den aelteren
* Einzel-Endpunkten stammt.
*/
/** Eine Abfuellung, wie der Sync sie braucht. */
export interface ApiCoreBottlingBatchWithHarvestId {
id: string;
lotNumber: string;
date: string;
bestBeforeDate: string;
jarCount: number;
jarSize?: number | null;
productId?: string | null;
jarSize: number | null;
productId: string | null;
/**
* ApiCore-ID der ERSTEN mit der Schleuderung verknuepften Ernte, oder null
* wenn die Schleuderung (noch) keiner Ernte zugeordnet ist. Eine
* Schleuderung kann fachlich mehrere Ernten buendeln (ExtractionHarvest
* ist many-to-many) — Wabenhains Batch.harvestId ist dagegen ein
* einzelner Pflicht-Fremdschluessel, daher bewusst nur die erste
* verknuepfte Ernte. Admin kann das beim Review in Wabenhain jederzeit
* auf die tatsaechlich passende Ernte korrigieren.
* ApiCore-ID der ersten (aeltesten) Ernte hinter der Abfuellung, oder null.
* Eine Schleuderung kann mehrere Ernten buendeln, Wabenhains Batch.harvestId
* ist dagegen ein einzelner Fremdschluessel — der Admin kann die Zuordnung
* in Wabenhain jederzeit korrigieren.
*/
harvestSourceId: string | null;
}
export async function fetchApiCoreBottlingBatches(
session: ApiCoreSession
): Promise<ApiCoreBottlingBatchWithHarvestId[]> {
const [extractions, batches] = await Promise.all([
getJson(session, "/extractions", z.array(extractionSchema), "Schleuderungen"),
getJson(session, "/bottling-batches", z.array(bottlingBatchSchema), "Chargen"),
]);
const firstHarvestByExtraction = new Map<string, string | null>(
extractions.map((extraction) => [extraction.id, extraction.harvests[0]?.harvestId ?? null])
);
return batches.map((batch) => ({
...batch,
harvestSourceId: firstHarvestByExtraction.get(batch.honeyBatch.extractionId) ?? null,
}));
}
// ---------------------------------------------------------------------------
// Shop-Schnittstelle (ApiCore GET /shop-sync)
// ---------------------------------------------------------------------------
/**
* Version des Datenvertrags, die dieser Wabenhain-Stand versteht. ApiCore
* erhoeht sie nur bei inkompatiblen Aenderungen; neue Felder kommen ohne
* Versionswechsel dazu und werden hier ignoriert.
*/
export const SUPPORTED_SHOP_SYNC_CONTRACT = 1;
const shopFeedSchema = z.object({
contractVersion: z.literal(SUPPORTED_SHOP_SYNC_CONTRACT),
harvests: z.array(
z.object({
id: z.string(),
apiaryId: z.string(),
apiaryName: z.string(),
date: z.string(),
honeyType: z.string().nullable(),
honeySuperCount: z.number().nullable(),
grossWeight: z.number().nullable(),
netWeight: z.number().nullable(),
})
),
harvests: z.array(harvestSchema),
products: z.array(productSchema),
bottlings: z.array(
z.object({
id: z.string(),
lotNumber: z.string(),
date: z.string(),
bestBeforeDate: z.string(),
jarSize: z.number().nullable(),
jarCount: z.number(),
productId: z.string().nullable(),
harvestIds: z.array(z.string()),
})
),
bottlings: z.array(bottlingSchema),
});
export interface ApiCoreShopData {
harvests: ApiCoreHarvestWithApiaryName[];
harvests: ApiCoreHarvest[];
products: ApiCoreProduct[];
bottlings: ApiCoreBottlingBatchWithHarvestId[];
}
/**
* Alles, was der Sync braucht, in EINEM konsistenten Abruf — aus ApiCores
* eigener Shop-Schnittstelle. Die liefert nur freigegebene Felder (keine
* Notizen) und braucht nur die Berechtigung "shop-sync:read".
*
* Kennt ApiCore die Schnittstelle noch nicht (HTTP 404, aelterer Stand),
* faellt der Sync auf die bisherigen Einzel-Endpunkte zurueck. So koennen
* beide Systeme unabhaengig voneinander aktualisiert werden.
*/
export async function fetchApiCoreShopData(session: ApiCoreSession): Promise<ApiCoreShopData> {
const res = await fetchFromApiCore(session, "/shop-sync");
if (res.status === 404) {
const [harvests, products, bottlings] = await Promise.all([
fetchApiCoreHarvests(session),
fetchApiCoreProducts(session),
fetchApiCoreBottlingBatches(session),
]);
return { harvests, products, bottlings };
function httpErrorMessage(status: number): string {
if (status === 401) {
return "ApiCore hat den API-Schlüssel abgelehnt (HTTP 401) — widerrufen, abgelaufen oder falsch kopiert. Bitte in ApiCore einen neuen Schlüssel anlegen.";
}
if (status === 403) {
return "Dem API-Schlüssel fehlt die Berechtigung „Shop-Daten lesen“ (HTTP 403).";
}
if (status === 404) {
return "ApiCore bietet die Shop-Schnittstelle nicht an (HTTP 404) — bitte ApiCore aktualisieren oder APICORE_API_URL prüfen.";
}
return `ApiCore: Shop-Daten konnten nicht geladen werden (HTTP ${status}).`;
}
const body = await parseResponse(res, z.unknown(), "Shop-Daten");
/**
* Alles, was der Sync braucht, in EINEM konsistenten Abruf. Jeder Fehler
* (nicht erreichbar, abgelehnt, unbekannte Version, unerwartete Struktur)
* bricht ab — ein fehlgeschlagener Abruf wird nie als leere Datenquelle
* behandelt.
*/
export async function fetchApiCoreShopData(connection: ApiCoreConnection): Promise<ApiCoreShopData> {
let res: Response;
try {
res = await fetch(`${connection.baseUrl}/shop-sync`, {
headers: { Authorization: `Bearer ${connection.apiKey}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error: unknown) {
throw new ApiCoreRequestError(`ApiCore unter ${connection.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
}
if (!res.ok) {
throw new ApiCoreRequestError(httpErrorMessage(res.status));
}
const body: unknown = await res.json();
const version = z.object({ contractVersion: z.number() }).safeParse(body);
if (version.success && version.data.contractVersion !== SUPPORTED_SHOP_SYNC_CONTRACT) {
throw new ApiCoreRequestError(
@@ -435,11 +214,11 @@ export async function fetchApiCoreShopData(session: ApiCoreSession): Promise<Api
}
const parsed = shopFeedSchema.safeParse(body);
if (!parsed.success) {
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur fuer Shop-Daten.");
throw new ApiCoreRequestError("ApiCore: unerwartete Antwortstruktur für Shop-Daten.");
}
return {
harvests: parsed.data.harvests.map((harvest) => ({ ...harvest, notes: null })),
harvests: parsed.data.harvests,
products: parsed.data.products,
bottlings: parsed.data.bottlings.map(({ harvestIds, ...bottling }) => ({
...bottling,
+2 -16
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvestWithApiaryName } from "./apicore-client";
import { isSellableStatus, type ApiCoreProduct, type ApiCoreHarvest } from "./apicore-client";
import {
slugForApiCoreSource,
buildHarvestDescription,
@@ -26,9 +26,7 @@ function makeProduct(overrides: Partial<ApiCoreProduct> = {}): ApiCoreProduct {
};
}
function makeHarvest(
overrides: Partial<ApiCoreHarvestWithApiaryName> = {}
): ApiCoreHarvestWithApiaryName {
function makeHarvest(overrides: Partial<ApiCoreHarvest> = {}): ApiCoreHarvest {
return {
id: "cm-harvest-1",
apiaryId: "cm-apiary-1",
@@ -37,7 +35,6 @@ function makeHarvest(
honeySuperCount: null,
grossWeight: null,
netWeight: null,
notes: null,
apiaryName: "Streuobstwiese",
...overrides,
};
@@ -79,17 +76,6 @@ describe("buildHarvestDescription", () => {
);
});
test("uebernimmt interne ApiCore-Notizen nie in den Shoptext", () => {
// Arrange: Betriebsnotiz, die nicht oeffentlich werden darf.
const source = makeHarvest({ netWeight: 41, notes: "Volk 7 nach Varroa-Behandlung schwach" });
// Act
const description = buildHarvestDescription(source);
// Assert
expect(description).not.toContain("Varroa");
expect(description).not.toContain("Volk 7");
});
test("fordert eine Beschreibung an, wenn ApiCore gar nichts liefert", () => {
// Arrange
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ApiCoreHarvestWithApiaryName, ApiCoreProduct } from "./apicore-client";
import type { ApiCoreHarvest, ApiCoreProduct } from "./apicore-client";
/**
* Reine Feld-Abbildung ApiCore -> Wabenhain. Bewusst getrennt von
@@ -31,7 +31,7 @@ export function slugForApiCoreSource(sourceId: string): string {
* und die Beschreibung wird mit dem Veroeffentlichen oeffentlich. Ein Klick
* auf "Veroeffentlichen" ohne Durchlesen duerfte sie sonst in den Shop tragen.
*/
export function buildHarvestDescription(source: ApiCoreHarvestWithApiaryName): string {
export function buildHarvestDescription(source: ApiCoreHarvest): string {
const parts: string[] = [];
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
+8 -17
View File
@@ -20,8 +20,7 @@ const FULL = {
DHL_GKP_PASSWORD: "w",
DHL_ACCOUNT_NUMBER: "12345678901234",
APICORE_API_URL: "https://api.example.org/api",
APICORE_SYNC_EMAIL: "[email protected]",
APICORE_SYNC_PASSWORD: "pw",
APICORE_API_KEY: "ack_secret",
};
describe("readIntegrationStatus", () => {
@@ -47,23 +46,15 @@ describe("readIntegrationStatus", () => {
});
it("never returns a configured value", () => {
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/);
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|ack_secret/);
});
it("accepts an ApiCore API key instead of the legacy email/password", () => {
const { APICORE_SYNC_EMAIL, APICORE_SYNC_PASSWORD, ...withoutPassword } = FULL;
void APICORE_SYNC_EMAIL;
void APICORE_SYNC_PASSWORD;
const apicore = readIntegrationStatus({ ...withoutPassword, APICORE_API_KEY: "ack_x" }).find(
(s) => s.key === "apicore"
);
expect(apicore).toMatchObject({ configured: true, missing: [] });
});
it("asks for the API key when neither key nor legacy login is set", () => {
const apicore = readIntegrationStatus({ APICORE_API_URL: "https://api.example.org/api" }).find(
(s) => s.key === "apicore"
);
it("needs the ApiCore API key - there is no password login any more", () => {
const apicore = readIntegrationStatus({
APICORE_API_URL: "https://api.example.org/api",
APICORE_SYNC_EMAIL: "[email protected]",
APICORE_SYNC_PASSWORD: "pw",
}).find((s) => s.key === "apicore");
expect(apicore).toMatchObject({ configured: false, missing: ["APICORE_API_KEY"] });
});
+1 -11
View File
@@ -28,7 +28,6 @@ const INTEGRATIONS: { key: IntegrationKey; required: boolean; variables: string[
{ key: "turnstile", required: false, variables: ["TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"] },
{ key: "googleReviews", required: false, variables: ["GOOGLE_PLACES_API_KEY", "GOOGLE_PLACE_ID"] },
{ key: "dhl", required: false, variables: ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] },
// Zugang per APICORE_API_KEY; E-Mail/Passwort zaehlt als Rueckfall, siehe apicoreMissing().
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_API_KEY"] },
];
@@ -46,18 +45,9 @@ function stripeWarning(env: Env): IntegrationWarning | undefined {
return undefined;
}
/** Ein API-Schluessel ODER (Rueckfall) E-Mail + Passwort des Service-Kontos. */
function apicoreMissing(env: Env): string[] {
const missing = env.APICORE_API_URL?.trim() ? [] : ["APICORE_API_URL"];
const hasKey = Boolean(env.APICORE_API_KEY?.trim());
const hasPassword = Boolean(env.APICORE_SYNC_EMAIL?.trim() && env.APICORE_SYNC_PASSWORD?.trim());
return hasKey || hasPassword ? missing : [...missing, "APICORE_API_KEY"];
}
export function readIntegrationStatus(env: Env = process.env): IntegrationStatus[] {
return INTEGRATIONS.map(({ key, required, variables }) => {
const missing =
key === "apicore" ? apicoreMissing(env) : variables.filter((name) => !env[name]?.trim());
const missing = variables.filter((name) => !env[name]?.trim());
const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing };
const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined;
return warning ? { ...status, warning } : status;