Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45ca0ea745 | ||
|
|
0482fd490e |
No files matched your search
@@ -37,8 +37,11 @@ import { requireAdmin } from "./_shared";
|
||||
* ein Mensch freigibt.
|
||||
*
|
||||
* - Ernten: werden als unveroeffentlichte Entwuerfe angelegt (isPublished
|
||||
* false) bzw. als solche aktualisiert. Bereits veroeffentlichte, frueher
|
||||
* synchronisierte Ernten bleiben unberuehrt.
|
||||
* false) bzw. als solche aktualisiert — aber nur, solange niemand den
|
||||
* Entwurf im Admin bearbeitet hat (erkennbar am Audit-Log-Eintrag der
|
||||
* Bearbeitung). Sonst wuerde jeder weitere Lauf die Korrekturen des Admins
|
||||
* (Erntesaison statt Honigsorte, Standort, Verkaufstext) wieder
|
||||
* zuruecksetzen. Bereits veroeffentlichte Ernten bleiben ohnehin unberuehrt.
|
||||
* - Produkte: werden als deaktivierte Entwuerfe angelegt (isActive false,
|
||||
* Preis 0) und nur so lange aktualisiert, wie sie deaktiviert sind. Ein
|
||||
* bereits aktives Produkt wird nie angefasst — sonst wuerde ein Sync Preis,
|
||||
@@ -68,6 +71,23 @@ interface SyncCounts {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
interface HarvestSyncCounts extends SyncCounts {
|
||||
/** Entwuerfe, die ein Mensch bearbeitet hat und die deshalb unveraendert blieben. */
|
||||
protectedDrafts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hat jemand diese Ernte im Admin angefasst (bearbeitet, veroeffentlicht,
|
||||
* zurueckgezogen)? Der Sync selbst protokolliert ohne entityId und zaehlt
|
||||
* damit nicht mit.
|
||||
*/
|
||||
async function wasEditedByAdmin(harvestId: string): Promise<boolean> {
|
||||
const entries = await prisma.auditLog.count({
|
||||
where: { entityType: "Harvest", entityId: harvestId, action: { not: "harvest.create" } },
|
||||
});
|
||||
return entries > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produkte kennen bewusst kein "updated" — sie werden nur neu angelegt,
|
||||
* siehe syncProducts().
|
||||
@@ -95,8 +115,8 @@ function describeError(error: unknown): string {
|
||||
// Ernten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<SyncCounts> {
|
||||
const counts: SyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0 };
|
||||
async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<HarvestSyncCounts> {
|
||||
const counts: HarvestSyncCounts = { created: 0, updated: 0, skipped: 0, failed: 0, protectedDrafts: 0 };
|
||||
|
||||
for (const source of sources) {
|
||||
const slug = slugForApiCoreSource(source.id);
|
||||
@@ -114,6 +134,10 @@ async function syncHarvests(sources: ApiCoreHarvestWithApiaryName[]): Promise<Sy
|
||||
counts.skipped++;
|
||||
continue;
|
||||
}
|
||||
if (existing && (await wasEditedByAdmin(existing.id))) {
|
||||
counts.protectedDrafts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = {
|
||||
year: date.getFullYear(),
|
||||
@@ -419,7 +443,9 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||
await logAdminAction(admin, {
|
||||
action: "harvest.sync_apicore",
|
||||
entityType: "Harvest",
|
||||
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
|
||||
detail:
|
||||
`${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ` +
|
||||
`${harvests.protectedDrafts} bearbeitete Entwürfe geschützt, ${harvests.failed} fehlgeschlagen`,
|
||||
});
|
||||
|
||||
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
||||
@@ -458,6 +484,7 @@ export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||
redirect(
|
||||
`${returnTo}?syncDone=1` +
|
||||
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
||||
`&hProtected=${harvests.protectedDrafts}` +
|
||||
`&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { syncApiCoreAction } from "@/app/admin/actions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { Button } from "@/components/ui";
|
||||
@@ -38,6 +39,7 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
||||
if (!done && syncError === undefined) return null;
|
||||
|
||||
const pending = count(searchParams.pPending);
|
||||
const protectedDrafts = count(searchParams.hProtected);
|
||||
const discontinued = count(searchParams.pDiscontinued);
|
||||
|
||||
return (
|
||||
@@ -63,6 +65,11 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
||||
) : null}
|
||||
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
||||
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
||||
{protectedDrafts > 0 ? (
|
||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||
{t.protectedDrafts(protectedDrafts)}
|
||||
</p>
|
||||
) : null}
|
||||
{pending > 0 ? (
|
||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||
{t.pending(pending)}
|
||||
@@ -88,12 +95,31 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
||||
* daraus keine offene Weiterleitung wird.
|
||||
*/
|
||||
export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
|
||||
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale).adminApiCoreSync;
|
||||
// Jeder vollstaendige Lauf schreibt zuletzt den Produkt-Eintrag — der ist
|
||||
// damit der Zeitpunkt des letzten Laufs, der bis zum Ende kam.
|
||||
const lastRun = await prisma.auditLog.findFirst({
|
||||
where: { action: "product.sync_apicore" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-lg text-heading mb-4">{t.heading}</h2>
|
||||
<p className="text-sm text-ink/65 mb-4">{t.intro}</p>
|
||||
<p className="text-sm text-ink/65 mb-4">
|
||||
{lastRun
|
||||
? t.lastRun(
|
||||
lastRun.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "Europe/Berlin",
|
||||
})
|
||||
)
|
||||
: t.neverRun}
|
||||
</p>
|
||||
<form action={syncApiCoreAction}>
|
||||
<input type="hidden" name="returnTo" value={returnTo} />
|
||||
<Button type="submit" className="px-5 py-2.5">
|
||||
|
||||
@@ -1156,7 +1156,7 @@ export const de: Dictionary = {
|
||||
adminApiCoreSync: {
|
||||
heading: "ApiCore-Synchronisierung",
|
||||
intro:
|
||||
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene Einträge werden nie überschrieben.",
|
||||
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene und im Admin bearbeitete Einträge werden nie überschrieben; interne ApiCore-Notizen werden nicht übernommen.",
|
||||
button: "Alles synchronisieren",
|
||||
resultHarvests: (created, updated, skipped, failed) =>
|
||||
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||
@@ -1168,6 +1168,10 @@ export const de: Dictionary = {
|
||||
`${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge — meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`,
|
||||
discontinued: (count) =>
|
||||
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
|
||||
protectedDrafts: (count) =>
|
||||
`${count} Ernte-Entw${count === 1 ? "urf wurde" : "ürfe wurden"} im Admin bereits bearbeitet und deshalb nicht überschrieben.`,
|
||||
lastRun: (when) => `Letzter vollständiger Lauf: ${when}`,
|
||||
neverRun: "Noch kein vollständiger Lauf.",
|
||||
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
|
||||
},
|
||||
|
||||
@@ -1131,7 +1131,7 @@ export const en: Dictionary = {
|
||||
adminApiCoreSync: {
|
||||
heading: "ApiCore sync",
|
||||
intro:
|
||||
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released are never overwritten.",
|
||||
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released or edited in the admin are never overwritten; internal ApiCore notes are not copied.",
|
||||
button: "Sync everything",
|
||||
resultHarvests: (created, updated, skipped, failed) =>
|
||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||
@@ -1143,6 +1143,10 @@ export const en: Dictionary = {
|
||||
`${count} bottling run${count === 1 ? "" : "s"} without a matching batch — usually a sign that the corresponding harvest is still missing in ApiCore.`,
|
||||
discontinued: (count) =>
|
||||
`${count} bottling run${count === 1 ? "" : "s"} belong to an article ApiCore no longer carries, so no new vintage was created. Existing ones stay on sale.`,
|
||||
protectedDrafts: (count) =>
|
||||
`${count} harvest draft${count === 1 ? " was" : "s were"} already edited in the admin and therefore not overwritten.`,
|
||||
lastRun: (when) => `Last complete run: ${when}`,
|
||||
neverRun: "No complete run yet.",
|
||||
error: "Sync with ApiCore failed.",
|
||||
batchLoadError: "Batches could not be loaded — without them no products are created either",
|
||||
},
|
||||
|
||||
@@ -733,6 +733,9 @@ export interface Dictionary {
|
||||
resultProducts: (created: number, skipped: number, failed: number) => string;
|
||||
pending: (count: number) => string;
|
||||
discontinued: (count: number) => string;
|
||||
protectedDrafts: (count: number) => string;
|
||||
lastRun: (when: string) => string;
|
||||
neverRun: string;
|
||||
error: string;
|
||||
batchLoadError: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
openApiCoreSession,
|
||||
fetchApiCoreHarvests,
|
||||
fetchApiCoreBottlingBatches,
|
||||
ApiCoreRequestError,
|
||||
type ApiCoreSession,
|
||||
} from "./apicore-client";
|
||||
|
||||
const session: ApiCoreSession = { baseUrl: "http://apicore.test/api/v1", token: "token" };
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
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("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);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,23 @@ import { z } from "zod";
|
||||
export class ApiCoreConfigError extends Error {}
|
||||
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.
|
||||
*/
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
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.
|
||||
const name = typeof error === "object" && error !== null && "name" in error ? error.name : undefined;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
return `keine Antwort innerhalb von ${REQUEST_TIMEOUT_MS / 1000} Sekunden`;
|
||||
}
|
||||
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() }),
|
||||
@@ -79,10 +96,10 @@ export async function openApiCoreSession(): Promise<ApiCoreSession> {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${message}`);
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiCoreRequestError(`ApiCore-Login fehlgeschlagen (HTTP ${res.status}).`);
|
||||
@@ -116,10 +133,10 @@ async function getJson<T>(
|
||||
try {
|
||||
res = await fetch(`${session.baseUrl}${path}`, {
|
||||
headers: { Authorization: `Bearer ${session.token}` },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${message}`);
|
||||
throw new ApiCoreRequestError(`ApiCore unter ${session.baseUrl} nicht erreichbar: ${describeFetchFailure(error)}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new ApiCoreRequestError(
|
||||
|
||||
@@ -64,19 +64,31 @@ describe("slugForApiCoreSource", () => {
|
||||
});
|
||||
|
||||
describe("buildHarvestDescription", () => {
|
||||
test("fasst die vorhandenen Mengenangaben und Notizen zusammen", () => {
|
||||
test("fasst die vorhandenen Mengenangaben zusammen", () => {
|
||||
// Arrange
|
||||
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41, notes: " Sehr hell. " });
|
||||
const source = makeHarvest({ honeySuperCount: 12, grossWeight: 48.5, netWeight: 41 });
|
||||
|
||||
// Act
|
||||
const description = buildHarvestDescription(source);
|
||||
|
||||
// Assert
|
||||
expect(description).toBe(
|
||||
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg · Sehr hell."
|
||||
"Automatisch aus ApiCore importiert. 12 Honigräume · Bruttogewicht 48.5 kg · Nettogewicht 41 kg"
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
const source = makeHarvest();
|
||||
|
||||
@@ -24,14 +24,18 @@ export function slugForApiCoreSource(sourceId: string): string {
|
||||
/**
|
||||
* Wabenhains Harvest.description ist ein Pflichtfeld, ApiCore liefert dafuer
|
||||
* kein Aequivalent — baut stattdessen eine sinnvolle Vorbefuellung aus den
|
||||
* vorhandenen Mengen-/Notizfeldern, die der Admin beim Review verfeinert.
|
||||
* vorhandenen Mengenfeldern, die der Admin beim Review verfeinert.
|
||||
*
|
||||
* ApiCores `notes` werden bewusst NICHT uebernommen: das sind interne
|
||||
* Betriebsnotizen des Imkers (Beobachtungen, Probleme, Behandlungshinweise),
|
||||
* 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 {
|
||||
const parts: string[] = [];
|
||||
if (source.honeySuperCount != null) parts.push(`${source.honeySuperCount} Honigräume`);
|
||||
if (source.grossWeight != null) parts.push(`Bruttogewicht ${source.grossWeight} kg`);
|
||||
if (source.netWeight != null) parts.push(`Nettogewicht ${source.netWeight} kg`);
|
||||
if (source.notes?.trim()) parts.push(source.notes.trim());
|
||||
const detail = parts.length > 0 ? parts.join(" · ") : "Bitte Beschreibung ergänzen.";
|
||||
return `Automatisch aus ApiCore importiert. ${detail}`;
|
||||
}
|
||||
|
||||
@@ -70,8 +70,14 @@ describe("Session-Token", () => {
|
||||
role: "KUNDE",
|
||||
sessionVersion: 0,
|
||||
});
|
||||
// Letztes Zeichen der Signatur veraendern.
|
||||
const tampered = token.slice(0, -1) + (token.endsWith("A") ? "B" : "A");
|
||||
// Ein Zeichen MITTEN in der Signatur veraendern. Das letzte Zeichen taugt
|
||||
// nicht: bei 32 Signaturbytes traegt es in Base64url teils nur Fuellbits,
|
||||
// die der Decoder ignoriert — der Test schlug dann zufaellig fehl, obwohl
|
||||
// die Signatur korrekt geprueft wurde.
|
||||
const signatureStart = token.lastIndexOf(".") + 1;
|
||||
const position = signatureStart + 10;
|
||||
const replacement = token[position] === "A" ? "B" : "A";
|
||||
const tampered = token.slice(0, position) + replacement + token.slice(position + 1);
|
||||
|
||||
await expect(verifySessionToken(tampered)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user