feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit
Bereiche mit Farbe, Arbeitsfenster und Tageskapazität – die erste schreibende Funktion und damit der Beleg, dass die Kette trägt: zod prüft jede Eingabe, die Rolle wird in der Seite und noch einmal in der Server Action geprüft, jede Änderung hinterlässt einen Audit-Eintrag. Die Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name nach Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt. Das gilt auch in verschachtelten Objekten und Listen. Dazu die Protokollseite für die Verwaltung und die Startseite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc013fd6a1
commit
e9a2b4b463
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ENTFERNT, auditDetails, detailsSaeubern } from './audit'
|
||||
|
||||
describe('auditDetails', () => {
|
||||
it('laesst fachliche Felder unangetastet', () => {
|
||||
expect(auditDetails({ name: 'Gewerbe', farbe: '#2563eb', reihenfolge: 2 })).toEqual({
|
||||
name: 'Gewerbe',
|
||||
farbe: '#2563eb',
|
||||
reihenfolge: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('gibt fuer nichts auch nichts zurueck', () => {
|
||||
expect(auditDetails(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('entfernt Passwoerter und Tokens', () => {
|
||||
expect(
|
||||
auditDetails({
|
||||
email: 'a@b.de',
|
||||
passwort: 'geheim123',
|
||||
passwortHash: '$2b$12$...',
|
||||
access_token: 'ey...',
|
||||
}),
|
||||
).toEqual({
|
||||
email: 'a@b.de',
|
||||
passwort: ENTFERNT,
|
||||
passwortHash: ENTFERNT,
|
||||
access_token: ENTFERNT,
|
||||
})
|
||||
})
|
||||
|
||||
it('entfernt Termin-Betreffs und Mailinhalte', () => {
|
||||
expect(
|
||||
auditDetails({ betreff: 'Personalgespräch', body: 'Text', dauer: 30 }),
|
||||
).toEqual({ betreff: ENTFERNT, body: ENTFERNT, dauer: 30 })
|
||||
})
|
||||
|
||||
it('achtet nicht auf Gross- und Kleinschreibung', () => {
|
||||
expect(auditDetails({ ClientSecret: 'x', REFRESH_TOKEN: 'y' })).toEqual({
|
||||
ClientSecret: ENTFERNT,
|
||||
REFRESH_TOKEN: ENTFERNT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('detailsSaeubern', () => {
|
||||
it('greift auch in verschachtelten Objekten', () => {
|
||||
expect(detailsSaeubern({ konto: { anbieter: 'GRAPH', id_token: 'ey...' } })).toEqual({
|
||||
konto: { anbieter: 'GRAPH', id_token: ENTFERNT },
|
||||
})
|
||||
})
|
||||
|
||||
it('greift in Listen', () => {
|
||||
expect(detailsSaeubern([{ name: 'a', token: 'x' }])).toEqual([
|
||||
{ name: 'a', token: ENTFERNT },
|
||||
])
|
||||
})
|
||||
|
||||
it('schreibt Datumsangaben als ISO-Zeichenkette', () => {
|
||||
expect(detailsSaeubern({ am: new Date('2026-09-10T12:00:00Z') })).toEqual({
|
||||
am: '2026-09-10T12:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('schneidet zu tiefe Verschachtelung ab, statt endlos abzusteigen', () => {
|
||||
expect(detailsSaeubern({ a: { b: { c: { d: { e: { f: 1 } } } } } })).toEqual({
|
||||
a: { b: { c: { d: { e: ENTFERNT } } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('kommt mit einfachen Werten und null zurecht', () => {
|
||||
expect(detailsSaeubern('text')).toBe('text')
|
||||
expect(detailsSaeubern(42)).toBe(42)
|
||||
expect(detailsSaeubern(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Aufbereitung der Audit-Details. Reine Logik, damit sie ohne Datenbank
|
||||
* geprueft werden kann.
|
||||
*
|
||||
* In die Details gehoeren nur fachliche Felder – niemals Tokens, Passwoerter,
|
||||
* Mailinhalte oder Termin-Betreffs. Was hier landet, steht spaeter im
|
||||
* Klartext in der Datenbank und in jeder Sicherung.
|
||||
*/
|
||||
|
||||
/** Feldnamen, die auch versehentlich nie in einem Audit-Eintrag landen duerfen. */
|
||||
const VERBOTEN = [
|
||||
'passwort',
|
||||
'password',
|
||||
'token',
|
||||
'secret',
|
||||
'geheim',
|
||||
'clientsecret',
|
||||
'betreff',
|
||||
'subject',
|
||||
'inhalt',
|
||||
'body',
|
||||
'nachricht',
|
||||
]
|
||||
|
||||
export const ENTFERNT = '[entfernt]'
|
||||
|
||||
function istVerdaechtig(schluessel: string): boolean {
|
||||
const klein = schluessel.toLowerCase()
|
||||
return VERBOTEN.some((v) => klein.includes(v))
|
||||
}
|
||||
|
||||
/**
|
||||
* Zweiter Riegel gegen Unachtsamkeit: Felder mit verdaechtigem Namen werden
|
||||
* ersetzt, statt gespeichert zu werden. Wirkt auch in verschachtelten
|
||||
* Objekten und in Listen.
|
||||
*/
|
||||
export function detailsSaeubern(wert: unknown, tiefe = 0): unknown {
|
||||
// Tief verschachtelte Strukturen gehoeren nicht in ein Audit-Detail; ab
|
||||
// einer gewissen Tiefe wird abgeschnitten statt endlos abgestiegen.
|
||||
if (tiefe > 4) return ENTFERNT
|
||||
|
||||
if (Array.isArray(wert)) {
|
||||
return wert.map((eintrag) => detailsSaeubern(eintrag, tiefe + 1))
|
||||
}
|
||||
|
||||
if (wert !== null && typeof wert === 'object') {
|
||||
if (wert instanceof Date) return wert.toISOString()
|
||||
const sauber: Record<string, unknown> = {}
|
||||
for (const [schluessel, inhalt] of Object.entries(wert)) {
|
||||
sauber[schluessel] = istVerdaechtig(schluessel)
|
||||
? ENTFERNT
|
||||
: detailsSaeubern(inhalt, tiefe + 1)
|
||||
}
|
||||
return sauber
|
||||
}
|
||||
|
||||
return wert
|
||||
}
|
||||
|
||||
/** Bequemer Einstieg fuer den Normalfall: ein flaches Objekt. */
|
||||
export function auditDetails(
|
||||
details: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!details) return undefined
|
||||
return detailsSaeubern(details) as Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
KAPAZITAET_FELDER,
|
||||
bereichSchema,
|
||||
farbeSchema,
|
||||
kapazitaetAmTag,
|
||||
wochenKapazitaet,
|
||||
} from './bereich'
|
||||
|
||||
const gueltig = {
|
||||
name: 'Gewerbe',
|
||||
farbe: '#2563EB',
|
||||
reihenfolge: 1,
|
||||
aktiv: true,
|
||||
arbeitsfensterVon: '08:00',
|
||||
arbeitsfensterBis: '17:00',
|
||||
kapazitaetMo: 240,
|
||||
kapazitaetDi: 240,
|
||||
kapazitaetMi: 240,
|
||||
kapazitaetDo: 240,
|
||||
kapazitaetFr: 120,
|
||||
kapazitaetSa: 0,
|
||||
kapazitaetSo: 0,
|
||||
}
|
||||
|
||||
describe('farbeSchema', () => {
|
||||
it('nimmt sechsstellige und dreistellige Hex-Farben', () => {
|
||||
expect(farbeSchema.parse('#2563eb')).toBe('#2563eb')
|
||||
expect(farbeSchema.parse('#abc')).toBe('#abc')
|
||||
})
|
||||
|
||||
it('macht Grossbuchstaben klein, damit Vergleiche stimmen', () => {
|
||||
expect(farbeSchema.parse('#2563EB')).toBe('#2563eb')
|
||||
})
|
||||
|
||||
it('weist Farben ohne Raute und mit falscher Laenge ab', () => {
|
||||
expect(farbeSchema.safeParse('2563eb').success).toBe(false)
|
||||
expect(farbeSchema.safeParse('#12345').success).toBe(false)
|
||||
expect(farbeSchema.safeParse('blau').success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bereichSchema', () => {
|
||||
it('nimmt einen vollstaendigen Bereich an und rechnet Uhrzeiten in Minuten um', () => {
|
||||
const ergebnis = bereichSchema.parse(gueltig)
|
||||
expect(ergebnis.arbeitsfensterVon).toBe(480)
|
||||
expect(ergebnis.arbeitsfensterBis).toBe(1020)
|
||||
expect(ergebnis.farbe).toBe('#2563eb')
|
||||
})
|
||||
|
||||
it('verlangt einen Namen mit mindestens zwei Zeichen', () => {
|
||||
const ergebnis = bereichSchema.safeParse({ ...gueltig, name: 'A' })
|
||||
expect(ergebnis.success).toBe(false)
|
||||
})
|
||||
|
||||
it('weist ein Arbeitsfenster ab, das vor seinem Beginn endet', () => {
|
||||
const ergebnis = bereichSchema.safeParse({
|
||||
...gueltig,
|
||||
arbeitsfensterVon: '17:00',
|
||||
arbeitsfensterBis: '08:00',
|
||||
})
|
||||
expect(ergebnis.success).toBe(false)
|
||||
if (!ergebnis.success) {
|
||||
expect(ergebnis.error.issues[0]?.message).toMatch(/nach seinem Beginn enden/)
|
||||
}
|
||||
})
|
||||
|
||||
it('weist ein Arbeitsfenster ohne Dauer ab', () => {
|
||||
const ergebnis = bereichSchema.safeParse({
|
||||
...gueltig,
|
||||
arbeitsfensterVon: '08:00',
|
||||
arbeitsfensterBis: '08:00',
|
||||
})
|
||||
expect(ergebnis.success).toBe(false)
|
||||
})
|
||||
|
||||
it('weist eine Kapazitaet ab, die laenger ist als das Arbeitsfenster', () => {
|
||||
// Fenster 08:00–17:00 sind 540 Minuten.
|
||||
const ergebnis = bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 600 })
|
||||
expect(ergebnis.success).toBe(false)
|
||||
if (!ergebnis.success) {
|
||||
expect(ergebnis.error.issues[0]?.message).toMatch(/Arbeitsfenster/)
|
||||
}
|
||||
})
|
||||
|
||||
it('laesst eine Kapazitaet genau in Fensterlaenge zu', () => {
|
||||
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 540 }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('weist negative und uebergrosse Kapazitaeten ab', () => {
|
||||
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: -1 }).success).toBe(false)
|
||||
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 1441 }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('weist eine unsinnige Uhrzeit ab', () => {
|
||||
expect(
|
||||
bereichSchema.safeParse({ ...gueltig, arbeitsfensterVon: '25:00' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wochenKapazitaet', () => {
|
||||
it('summiert alle sieben Tage', () => {
|
||||
const bereich = bereichSchema.parse(gueltig)
|
||||
expect(wochenKapazitaet(bereich)).toBe(1080)
|
||||
})
|
||||
|
||||
it('ist null, wenn nichts eingeplant ist', () => {
|
||||
const leer = Object.fromEntries(KAPAZITAET_FELDER.map((f) => [f, 0])) as Record<
|
||||
(typeof KAPAZITAET_FELDER)[number],
|
||||
number
|
||||
>
|
||||
expect(wochenKapazitaet(leer)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kapazitaetAmTag', () => {
|
||||
it('trifft den richtigen Wochentag, Montag ist null', () => {
|
||||
const bereich = bereichSchema.parse(gueltig)
|
||||
expect(kapazitaetAmTag(bereich, 0)).toBe(240)
|
||||
expect(kapazitaetAmTag(bereich, 4)).toBe(120)
|
||||
expect(kapazitaetAmTag(bereich, 6)).toBe(0)
|
||||
})
|
||||
|
||||
it('gibt fuer einen Index ausserhalb der Woche null zurueck', () => {
|
||||
const bereich = bereichSchema.parse(gueltig)
|
||||
expect(kapazitaetAmTag(bereich, 7)).toBe(0)
|
||||
expect(kapazitaetAmTag(bereich, -1)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { z } from 'zod'
|
||||
import { ausUhrzeit } from '@/lib/zeit'
|
||||
|
||||
/**
|
||||
* Regeln fuer Bereiche. Reine Logik, damit Formular und Server Action
|
||||
* dieselbe Antwort geben.
|
||||
*/
|
||||
|
||||
export const WOCHENTAGE = [
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
'Sonntag',
|
||||
] as const
|
||||
|
||||
export const KAPAZITAET_FELDER = [
|
||||
'kapazitaetMo',
|
||||
'kapazitaetDi',
|
||||
'kapazitaetMi',
|
||||
'kapazitaetDo',
|
||||
'kapazitaetFr',
|
||||
'kapazitaetSa',
|
||||
'kapazitaetSo',
|
||||
] as const
|
||||
|
||||
export type KapazitaetFeld = (typeof KAPAZITAET_FELDER)[number]
|
||||
|
||||
/** Hex-Farbe mit Raute, drei- oder sechsstellig. */
|
||||
export const farbeSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Bitte eine Farbe wie #2563eb angeben.')
|
||||
.transform((f) => f.toLowerCase())
|
||||
|
||||
const uhrzeitSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 08:00 angeben.')
|
||||
.transform((w) => ausUhrzeit(w) as number)
|
||||
|
||||
/** Eine Tageskapazitaet: 0 bis 24 Stunden, in Minuten. */
|
||||
const kapazitaetSchema = z.coerce
|
||||
.number()
|
||||
.int('Bitte ganze Minuten angeben.')
|
||||
.min(0, 'Nicht weniger als 0 Minuten.')
|
||||
.max(1440, 'Nicht mehr als 1440 Minuten, also 24 Stunden.')
|
||||
|
||||
export const bereichSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Mindestens zwei Zeichen.')
|
||||
.max(40, 'Höchstens 40 Zeichen.'),
|
||||
farbe: farbeSchema,
|
||||
reihenfolge: z.coerce.number().int().min(0).max(999).default(0),
|
||||
aktiv: z.coerce.boolean().default(true),
|
||||
arbeitsfensterVon: uhrzeitSchema,
|
||||
arbeitsfensterBis: uhrzeitSchema,
|
||||
kapazitaetMo: kapazitaetSchema,
|
||||
kapazitaetDi: kapazitaetSchema,
|
||||
kapazitaetMi: kapazitaetSchema,
|
||||
kapazitaetDo: kapazitaetSchema,
|
||||
kapazitaetFr: kapazitaetSchema,
|
||||
kapazitaetSa: kapazitaetSchema,
|
||||
kapazitaetSo: kapazitaetSchema,
|
||||
})
|
||||
.refine((b) => b.arbeitsfensterVon < b.arbeitsfensterBis, {
|
||||
path: ['arbeitsfensterBis'],
|
||||
message: 'Das Arbeitsfenster muss nach seinem Beginn enden.',
|
||||
})
|
||||
.refine(
|
||||
(b) =>
|
||||
KAPAZITAET_FELDER.every(
|
||||
(feld) => b[feld] <= b.arbeitsfensterBis - b.arbeitsfensterVon,
|
||||
),
|
||||
{
|
||||
path: ['kapazitaetMo'],
|
||||
message: 'Keine Tageskapazität darf länger sein als das Arbeitsfenster.',
|
||||
},
|
||||
)
|
||||
|
||||
export type BereichEingabe = z.infer<typeof bereichSchema>
|
||||
|
||||
/** Summe der Wochenkapazitaet in Minuten. */
|
||||
export function wochenKapazitaet(bereich: Record<KapazitaetFeld, number>): number {
|
||||
return KAPAZITAET_FELDER.reduce((summe, feld) => summe + bereich[feld], 0)
|
||||
}
|
||||
|
||||
/** Kapazitaet des Wochentags, 0 = Montag. */
|
||||
export function kapazitaetAmTag(
|
||||
bereich: Record<KapazitaetFeld, number>,
|
||||
wochentagIndex: number,
|
||||
): number {
|
||||
const feld = KAPAZITAET_FELDER[wochentagIndex]
|
||||
return feld ? bereich[feld] : 0
|
||||
}
|
||||
Reference in New Issue
Block a user