Workstream 7: Wehr-Bereich — Fuhrpark & Benutzer (Phase 4)

Implementiert den auf die eigene brigadeId beschränkten Wehr-Bereich:
Profil (inkl. Inline-Geocoding via geocodeAddress), Fuhrpark (Fahrzeug per
Vorlage oder frei, typisierter Merkmal-Editor), Geräte (Kategorie, Werte,
Zuordnung Fahrzeug/„im Gerätehaus") und Benutzerkonten (wehr_admin/wehr_read).

- Schema importiert (nicht neu definiert); ASCII-Property wehrfuehrer.
- Default-deny dreifach: Layout-Guard requireWehrAdmin() + jede Server Action
  beginnt mit requireWehrAdmin(); fremde Entities -> notFound() (404).
- Validierung an der Grenze (Zod): buildMerkmalValuesSchema validiert Werte
  typgerecht gegen die serverseitig aufgelösten Definitionen; Rolle auf
  wehr_admin|wehr_read beschränkt (platform_admin abgelehnt).
- upsertMerkmalValues delete-then-insert mit typisierter Drizzle-Tx (kein any);
  boolean false/num 0 gelten als gesetzt.
- argon2id-Einmalpasswort beim Benutzeranlegen; Selbst-Deaktivierung verhindert.
- Audit vollständig: brigade.profile_update, vehicle.create/update/delete/status,
  equipment.create/update/delete/status, user.create/deactivate.
- Vorgabewerte aus drei typisierten Spalten (vorgabewert_num/_text/_bool).
- i18n via zentraler de.ts; loading/empty/error-konforme Listen.

Tests: 22 neue Unit-Tests (vehicle/equipment/brigade-user-Validierung,
upsertMerkmalValues) grün; Playwright-Specs verwaltung-fuhrpark + -scoping
geschrieben (deferred: kein Server/DB in der Sandbox).

Verifikation offline: tsc --noEmit clean, eslint clean, vitest 147 passed,
next build exit 0 (alle /verwaltung/*-Routen), drizzle-kit check ohne Drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-06-09 11:06:17 +02:00
parent 628d35bfcd
commit 5cda09c411
39 changed files with 3201 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import { test, expect } from "@playwright/test";
/**
* Happy-Path des Wehr-Bereichs: Fuhrpark, Geräte, Profil, Benutzer
* (Workstream 7).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet einen
* angemeldeten `wehr_admin` (Fixture aus dem Test-Workstream) und befüllte
* Taxonomie (Seed/Admin).
*/
test.describe("Verwaltung: Fuhrpark & Benutzer (Happy-Path)", () => {
test.skip(
!process.env.E2E_WEHR_ADMIN_STATE,
"benötigt wehr_admin-Fixture (Test-Workstream)",
);
test.use({
storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] },
});
test("Fahrzeug per Vorlage anlegen befüllt typisierte Merkmale", async ({
page,
}) => {
await page.goto("/verwaltung/fahrzeuge/neu");
await page.getByLabel("Fahrzeug-Vorlage").selectOption({ label: /HLF 2/ });
// Vorlagen-Merkmale werden nachgeladen (Löschwassertank, Feuerlöschpumpe …).
await expect(page.getByText("Löschwassertank")).toBeVisible();
await page.getByLabel("Name").fill("HLF 2 Musterdorf");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page).toHaveURL(/\/verwaltung\/fahrzeuge$/);
await expect(page.getByText("HLF 2 Musterdorf")).toBeVisible();
});
test("Gerät 'im Gerätehaus' anlegen (keine Fahrzeug-Zuordnung)", async ({
page,
}) => {
await page.goto("/verwaltung/geraete/neu");
await page.getByLabel("Name").fill("Tragkraftspritze 1");
await page.getByLabel("Kategorie").selectOption({ index: 1 });
// Zuordnung bleibt auf 'im Gerätehaus'.
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page).toHaveURL(/\/verwaltung\/geraete$/);
await expect(page.getByText("im Gerätehaus").first()).toBeVisible();
});
test("Profil speichern zeigt Bestätigung", async ({ page }) => {
await page.goto("/verwaltung/profil");
await page.getByLabel("Straße").fill("Hauptstraße 1");
await page.getByLabel("PLZ").fill("3100");
await page.getByLabel("Ort").fill("St. Pölten");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText(/gespeichert|geokodiert|geokodiert werden/)).toBeVisible();
});
test("Benutzer anlegen zeigt Einmal-Passwort", async ({ page }) => {
await page.goto("/verwaltung/benutzer");
await page.getByLabel("Name").fill("Neue Person");
await page.getByLabel("E-Mail").fill("neu@ff-musterdorf.at");
await page.getByLabel("Rolle").selectOption("wehr_read");
await page.getByRole("button", { name: "Benutzer anlegen" }).click();
await expect(page.getByText(/Einmal-Passwort/)).toBeVisible();
});
});

View File

@@ -0,0 +1,67 @@
import { test, expect } from "@playwright/test";
/**
* Scoping & Gating des Wehr-Bereichs (Workstream 7, Querschnittsstandard 13,
* default-deny dreifach).
*
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Erwartet:
* - anonym: jede /verwaltung/*-Seite -> Redirect auf /login.
* - wehr_read: jede /verwaltung/*-Seite -> 403 (forbidden()).
* - wehr_admin: erreichbar; fremdes Fahrzeug/Gerät -> 404 (notFound).
*
* Negativ-Probe: Entfernen von `requireWehrAdmin()` aus (app)/verwaltung/
* layout.tsx ODER aus einer Server-Action muss diese Suite rot machen.
*/
const VERWALTUNG_PAGES = [
"/verwaltung/profil",
"/verwaltung/fahrzeuge",
"/verwaltung/fahrzeuge/neu",
"/verwaltung/geraete",
"/verwaltung/geraete/neu",
"/verwaltung/benutzer",
];
test.describe("Verwaltung: anonym -> Redirect auf /login", () => {
test.use({ storageState: { cookies: [], origins: [] } });
for (const path of VERWALTUNG_PAGES) {
test(`anonymer Aufruf von ${path} leitet auf /login um`, async ({
page,
}) => {
await page.goto(path);
await expect(page).toHaveURL(/\/login/);
});
}
});
test.describe("Verwaltung: wehr_read -> 403", () => {
test.skip(
!process.env.E2E_WEHR_READ_STATE,
"benötigt wehr_read-Fixture (Test-Workstream)",
);
test.use({
storageState: process.env.E2E_WEHR_READ_STATE ?? { cookies: [], origins: [] },
});
for (const path of VERWALTUNG_PAGES) {
test(`wehr_read-Aufruf von ${path} -> 403`, async ({ page }) => {
const res = await page.goto(path);
expect(res?.status()).toBe(403);
});
}
});
test.describe("Verwaltung: fremde Wehr -> 404 (Scoping)", () => {
test.skip(
!process.env.E2E_WEHR_ADMIN_STATE || !process.env.E2E_FOREIGN_VEHICLE_ID,
"benötigt wehr_admin-Fixture + fremde Fahrzeug-ID (Test-Workstream)",
);
test.use({
storageState: process.env.E2E_WEHR_ADMIN_STATE ?? { cookies: [], origins: [] },
});
test("Aufruf eines fremden Fahrzeugs liefert 404", async ({ page }) => {
const res = await page.goto(
`/verwaltung/fahrzeuge/${process.env.E2E_FOREIGN_VEHICLE_ID}`,
);
expect(res?.status()).toBe(404);
});
});