RET-06: aufbewahrungsfristen-konfigurationsoberfläche
- web/retention-admin: eigenstaendige Next.js/React/TypeScript-App (kein Backend-Annex), auf web/shl (SHL-01) aufbauend - classes: Aufbewahrungsklasse anlegen/aendern/deaktivieren (AC1) - preview: Vorschauliste 30 Tage, nutzt denselben Endpunkt wie der periodische Job (AC2) - lib/api.ts: ForbiddenError bei 403, getrennt behandelt, keine eigene Autorisierungslogik im Frontend (RBAC-Entscheidung liegt bei RET-08/RBAC-06) - expliziter 403-Nachweis (nicht nur der 200-Fall): lib/api.test.ts, ClassesPage/PreviewPage zeigen 'Zugriff verweigert' statt leerer Seite - lokal getestet: tsc clean, next build clean, vitest 3/3 gruen Pruefungen siehe archive/docs/RET-06-PRUEFPROTOKOLL.md
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ForbiddenError, fetchClassRules, configureClassRule } from "./api";
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_RETENTION_API_URL: "http://backend.test" };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Akzeptanzkriterium 3 ("nur berechtigten Rollen zugaenglich"): der
|
||||
// Negativfall MUSS explizit gepruft werden, nicht nur der Erfolgsfall -
|
||||
// Frontend wirft ForbiddenError, wenn RET-06-API (real gegen RBAC-06,
|
||||
// siehe RET-08) mit 403 antwortet.
|
||||
describe("api client - 403-Nachweis (keine berechtigte Rolle)", () => {
|
||||
it("fetchClassRules wirft ForbiddenError bei 403-Antwort", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 403 }))
|
||||
);
|
||||
|
||||
await expect(fetchClassRules()).rejects.toBeInstanceOf(ForbiddenError);
|
||||
});
|
||||
|
||||
it("configureClassRule wirft ForbiddenError bei 403-Antwort", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 403 }))
|
||||
);
|
||||
|
||||
await expect(configureClassRule("klasse-x", "1 year")).rejects.toBeInstanceOf(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("api client - erlaubte Rolle", () => {
|
||||
it("fetchClassRules liefert die Liste bei 200-Antwort", async () => {
|
||||
const rules = [{ RetentionClass: "klasse-a", Duration: "5 years", Active: true }];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(rules), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchClassRules()).resolves.toEqual(rules);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// Duenner Client fuer RET-06-API (archive/internal/retentionapi) — enthaelt
|
||||
// keine eigene Fristen-/RBAC-Logik, nur Datenabruf und -weitergabe. Der
|
||||
// RBAC-Check selbst passiert serverseitig ueber RET-08 (echter RBAC-06-Aufruf),
|
||||
// dieser Client meldet nur, ob der Zugriff erlaubt war (403) oder nicht.
|
||||
|
||||
export class ForbiddenError extends Error {}
|
||||
export class ApiError extends Error {}
|
||||
|
||||
function apiBase(): string {
|
||||
const base = process.env.NEXT_PUBLIC_RETENTION_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("NEXT_PUBLIC_RETENTION_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// userRole wird bis zu einer zentralen Session-/Identitaets-Loesung im
|
||||
// Frontend als Umgebungsvariable/Query mitgegeben und als X-User-Role-Header
|
||||
// an RET-06-API durchgereicht, das ihn seinerseits gegen RBAC-06 prueft
|
||||
// (RET-08) - das Frontend trifft selbst KEINE Autorisierungsentscheidung.
|
||||
function userRoleHeader(): Record<string, string> {
|
||||
const role = process.env.NEXT_PUBLIC_USER_ROLE;
|
||||
return role ? { "X-User-Role": role } : {};
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${apiBase()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...userRoleHeader(),
|
||||
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (res.status === 403) {
|
||||
throw new ForbiddenError("Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new ApiError(body.error ?? `Anfrage fehlgeschlagen (${res.status})`);
|
||||
}
|
||||
if (res.status === 200 && res.headers.get("content-length") === "0") {
|
||||
return undefined as T;
|
||||
}
|
||||
return res.json().catch(() => undefined as T);
|
||||
}
|
||||
|
||||
export interface ClassRule {
|
||||
RetentionClass: string;
|
||||
Duration: string;
|
||||
Active: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiringObject {
|
||||
RetentionObjectID: string;
|
||||
ObjectType: string;
|
||||
ObjectReference: string;
|
||||
RetentionClass: string;
|
||||
DueDate: string;
|
||||
}
|
||||
|
||||
export function fetchClassRules(): Promise<ClassRule[]> {
|
||||
return req("/retention-classes");
|
||||
}
|
||||
|
||||
export function configureClassRule(retentionClass: string, duration: string): Promise<void> {
|
||||
return req("/retention-classes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ retention_class: retentionClass, duration }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivateClassRule(retentionClass: string): Promise<void> {
|
||||
return req(`/retention-classes/${encodeURIComponent(retentionClass)}/deactivate`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchExpiringPreview(): Promise<ExpiringObject[]> {
|
||||
return req("/retention-classes/preview");
|
||||
}
|
||||
Reference in New Issue
Block a user