feat(apicore-sync): authenticate with an ApiCore API key
APICORE_API_KEY replaces the service-account login: the key is sent as the bearer token directly, needs no password and no MFA exemption, may only read the shop feed and can be revoked in ApiCore at any time. A malformed key is rejected with a clear message before any request. Email/password stays as fallback for ApiCore versions without keys. The system status treats the key or the legacy login as configured. README, .env.example and the k8s secret template describe the key. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
5642f0c4af
commit
55be1fe881
7 files changed
+101
-16
No files matched your search
@@ -40,6 +40,29 @@ describe("apicore-client", () => {
|
||||
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([]));
|
||||
|
||||
@@ -77,20 +77,47 @@ export interface ApiCoreSession {
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
function getConfig(): { baseUrl: string; email: string; password: string } {
|
||||
const baseUrl = process.env.APICORE_API_URL;
|
||||
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 {
|
||||
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 || !email || !password) {
|
||||
throw new ApiCoreConfigError(
|
||||
"ApiCore-Synchronisierung ist nicht konfiguriert. Bitte APICORE_API_URL, APICORE_SYNC_EMAIL und APICORE_SYNC_PASSWORD setzen (siehe .env.example)."
|
||||
);
|
||||
if (baseUrl && apiKey) {
|
||||
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."
|
||||
);
|
||||
}
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
return { baseUrl: baseUrl.replace(/\/$/, ""), email, password };
|
||||
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 { baseUrl, email, password } = getConfig();
|
||||
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 {
|
||||
|
||||
@@ -50,6 +50,23 @@ describe("readIntegrationStatus", () => {
|
||||
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/);
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
expect(apicore).toMatchObject({ configured: false, missing: ["APICORE_API_KEY"] });
|
||||
});
|
||||
|
||||
it("marks shop-critical integrations as required", () => {
|
||||
const required = readIntegrationStatus({}).filter((s) => s.required).map((s) => s.key);
|
||||
expect(required).toEqual(["stripe", "stripeWebhook", "mail"]);
|
||||
|
||||
@@ -28,7 +28,8 @@ 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"] },
|
||||
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_SYNC_EMAIL", "APICORE_SYNC_PASSWORD"] },
|
||||
// Zugang per APICORE_API_KEY; E-Mail/Passwort zaehlt als Rueckfall, siehe apicoreMissing().
|
||||
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_API_KEY"] },
|
||||
];
|
||||
|
||||
function stripeMode(key: string): "live" | "test" | null {
|
||||
@@ -45,9 +46,18 @@ 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 = variables.filter((name) => !env[name]?.trim());
|
||||
const missing =
|
||||
key === "apicore" ? apicoreMissing(env) : 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;
|
||||
|
||||
Reference in new issue
Block a user