Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9c5b7482 | ||
|
|
344a33805a | ||
|
|
b4d4ee8d3c | ||
|
|
6fd7831784 | ||
|
|
5813e6209c | ||
|
|
790ab20651 | ||
|
|
ae59e745c0 | ||
|
|
8e5d06aafb |
538
CLAUDE.md
538
CLAUDE.md
@@ -1,102 +1,242 @@
|
|||||||
# Deklarix
|
# Deklarix
|
||||||
|
|
||||||
> Prüfung und Nachweis für Werbe-Content. Pre-Publish-Kennzeichnungsprüfung
|
> KI-Antragsprüfung für Unternehmen: Mitarbeitende beschreiben ein
|
||||||
> und revisionssichere Archivierung für Creator, Agenturen, Marken und
|
> KI-Vorhaben in eigenen Worten, das System leitet Datenklasse,
|
||||||
> Kanzleien. `deklarix.de` / `deklarix.com`
|
> KI-VO-Einstufung und Anforderungsprofil ab, gleicht sie gegen einen
|
||||||
|
> gepflegten Werkzeugkatalog ab und erzeugt einen begründeten
|
||||||
|
> Entscheidungsvorschlag. Ein Mensch entscheidet, das System bereitet
|
||||||
|
> nur vor.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Was das Projekt ist
|
## Produktwechsel (2026-08-28)
|
||||||
|
|
||||||
Drei Funktionen, mehr nicht:
|
Deklarix war ursprünglich eine Pre-Publish-Kennzeichnungsprüfung für
|
||||||
|
Werbe-Content (UWG/MStV) für Creator, Agenturen, Marken und Kanzleien.
|
||||||
1. **Pre-Publish-Prüfung** — Caption, Standbild und Vertragslage rein,
|
Dieser Scope wurde **komplett verworfen** und durch das oben
|
||||||
Risikobewertung mit konkreten Korrekturvorschlägen raus.
|
beschriebene Produkt ersetzt — kein Feature-Zusatz, ein Produktwechsel.
|
||||||
2. **Beweissicherung** — jeder veröffentlichte Beitrag wird mit Hash und
|
Wiederverwendet wurden nur produktunabhängige Bausteine: Auth/Session/
|
||||||
Zeitstempel archiviert: Screenshot, Caption, sichtbare Kennzeichnung,
|
Account-Mandantentrennung, das Prinzip "Regelwerk als versionierte
|
||||||
verknüpfter Vertrag, Briefing.
|
YAML-Dateien, kein ML für die Bewertung", die apt/systemd/Gitea-
|
||||||
3. **Verantwortungsmatrix** — wer hat was vorgegeben, wer hat freigegeben,
|
Deployment-Pipeline und das Design-System. Alles Werberecht-Spezifische
|
||||||
wer schuldet welche Prüfung (Creator / Agentur / Marke).
|
(Kennzeichnungsregeln, Beweisarchiv für Social-Media-Posts, Instagram/
|
||||||
|
TikTok-OAuth, Insights-Erinnerung) wurde entfernt, nicht nur ersetzt —
|
||||||
**Wer nutzt es, wer zahlt:** Creator nutzen es täglich, zahlen wenig
|
siehe Migration `0008_pivot_ki_antragspruefung` für den genauen Schnitt.
|
||||||
(Reichweite/Referenz). Agenturen und Marken zahlen richtig — sie haften
|
|
||||||
über § 8 Abs. 2 UWG mit und brauchen Freigabe-Workflow plus
|
|
||||||
Nachweisarchiv. Kanzleien lizenzieren Deklarix als Whitelabel für ihre
|
|
||||||
Mandanten (zahlen für Software, nicht für Mandantenzugang); das
|
|
||||||
Kanzlei-Verzeichnis im Produkt bleibt kostenlos.
|
|
||||||
|
|
||||||
**Rollen von Anfang an.** Eine Submission gehört nicht einem Nutzer,
|
|
||||||
sondern hat Beteiligte mit Rollen (`creator`, `agentur`, `marke`,
|
|
||||||
`kanzlei`). Alle vier existieren im Datenmodell ab dem ersten Commit.
|
|
||||||
|
|
||||||
**Kein Demo-Shortcut.** Es wird direkt produktionsreif entwickelt —
|
|
||||||
keine reduzierte Zwischenstufe mit manuell eingetragenen Daten oder ohne
|
|
||||||
Login als Selbstzweck.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kernprinzip: Fakten trennen von Urteil
|
## Grundregel
|
||||||
|
|
||||||
**Stufe 1 bestimmt Fakten (deterministisch, kein LLM). Das Regelwerk in
|
**Das System entscheidet nicht. Es bereitet vor.** Der/die
|
||||||
Go fällt das Urteil.**
|
Verantwortliche bestätigt, ändert oder überschreibt einen
|
||||||
|
Bewertungsvorschlag — mit Begründungsfeld bei Abweichung. Jede
|
||||||
|
Entscheidung wird als Registereintrag protokolliert. Die Bewertung wird
|
||||||
|
**nie** ohne ihre vollständige Herleitung angezeigt (welche Antwort
|
||||||
|
welche Anforderung ausgelöst hat) — ohne das ist ein Vorschlag im Audit
|
||||||
|
wertlos.
|
||||||
|
|
||||||
- **Stufe 1 — Extraktion (`internal/extract`, regelbasiert):** Ob eine
|
Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
|
||||||
Gegenleistung vorlag, kann aus dem Text nicht zuverlässig erraten
|
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
|
||||||
werden — ein verschwiegener bezahlter Post sieht textlich identisch
|
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
|
||||||
aus wie ein echter organischer Post. Deshalb gibt der/die Einreichende
|
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
|
||||||
die Gegenleistung selbst an (Formularfeld). Was sich objektiv aus der
|
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
|
||||||
Caption bestimmen lässt, wird per Zeichenketten-Logik erkannt: ob ein
|
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
|
||||||
Kennzeichnungswort vorkommt, welcher Wortlaut, und ob es vor der
|
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
|
||||||
plattformspezifischen "mehr anzeigen"-Kürzung steht. Kein externer
|
|
||||||
Dienst, keine API-Kosten, kein Sprachmodell — daher auch keine
|
|
||||||
Interpretation kreativer/impliziter Formulierungen; was nicht über ein
|
|
||||||
bekanntes Kennzeichnungswort läuft, wird nicht erkannt.
|
|
||||||
- **Stufe 2 — Bewertung (Go, deterministisch):** Regelwerk als
|
|
||||||
versionierte YAML-Dateien (`rules/`). Jedes Finding referenziert
|
|
||||||
Regel-ID und Version (z. B. `WK-004 v3`). Regeln sind
|
|
||||||
plattformunabhängig — UWG/MStV gelten überall gleich, nur
|
|
||||||
Darstellungsdetails unterscheiden sich; die gehören in
|
|
||||||
Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform.
|
|
||||||
|
|
||||||
**Absolute Regel:** Fundstellen stehen ausschließlich in den kuratierten
|
|
||||||
YAML-Regeln, nie in Freitext oder Extraktionslogik erfunden. Eine
|
|
||||||
halluzinierte Fundstelle ist in diesem Markt ein Totalschaden.
|
|
||||||
|
|
||||||
**Historie:** Ursprünglich war Stufe 1 als Claude-API-Aufruf geplant
|
|
||||||
(Kennzeichnung *und* Gegenleistung vom Modell erraten). Das wurde
|
|
||||||
bewusst verworfen — der Nutzer will keine laufende Anthropic-Abhängigkeit
|
|
||||||
für Deklarix selbst (getrennt von Claude Code, mit dem an Deklarix
|
|
||||||
programmiert wird). Die Idee "LLM sieht Kontext-Hinweise wie
|
|
||||||
Rabattcodes/Affiliate-Links, die reines Keyword-Matching übersieht"
|
|
||||||
bleibt ein späterer Ausbaupunkt, kein aktueller Blocker.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack
|
## Fünf Ebenen, fünf Rollen
|
||||||
|
|
||||||
Bewusst langweilig, ein Binary, lokal lauffähig:
|
Die Oberfläche gliedert sich in fünf Ebenen mit steigender Sensibilität.
|
||||||
|
Vier Mandanten-Rollen sind eine Voreinstellung, kein fest verdrahtetes
|
||||||
|
Modell (spätere Ausbaustufe: konfigurierbare Rollen) — `betreiber` ist
|
||||||
|
technisch getrennt vom Mandantenbereich, keine Voreinstellung.
|
||||||
|
|
||||||
| Bereich | Wahl |
|
| Ebene | Zugang | Rolle(n) | Zweck |
|
||||||
|---|---|
|
|---|---|---|---|
|
||||||
| Sprache | Go 1.26, Standard-Library wo möglich |
|
| 1 — Öffentlich | ohne Konto | — | Anmeldung, Passwort zurücksetzen, Einladung annehmen, Antrag über Mandantenlink |
|
||||||
| Web | `net/http` + `html/template` + htmx, kein Frontend-Build, kein React |
|
| 2 — Mitarbeiter | Konto nötig | `mitarbeiter` | Antrag stellen, eigene Anträge verfolgen, Rückfragen beantworten, freigegebene Werkzeuge einsehen. Bewusst schlank — dauert der Antrag zu lange, wird er umgangen |
|
||||||
| Datenbank | PostgreSQL über `pgx`, Migrationen über `golang-migrate` |
|
| 3 — Fachebene | Konto nötig | `verantwortlicher` (Entscheidungsrecht), `pruefer` (identische Sicht, kein Entscheidungsrecht) | Posteingang offener Anträge, Bewertungsvorschlag mit Herleitung, entscheiden, Register, Berichte/Exporte, Audit-Log lesend |
|
||||||
| PDF | serverseitig erzeugt |
|
| 4 — Mandanten-Verwaltung | Konto nötig | `admin` | Nutzer/Rollen, Abteilungen, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen, Rechnungsdaten — bezogen auf GENAU EINEN Mandanten |
|
||||||
| Zeitstempel | RFC-3161 von einer öffentlichen TSA |
|
| 5 — Plattform | Konto nötig, technisch getrennt | `betreiber` | Werkzeugkatalog, Regelwerk-Versionierung, Mandantenverwaltung/Freischaltung, Nutzungssicht — Netcell-IT-Personal, plattformweit über alle Mandanten |
|
||||||
| Extraktion | regelbasiert, `internal/extract`, keine externe Abhängigkeit |
|
|
||||||
| Design | `design/enterprise.css` (Enterprise Light Theme, enconf-Pattern, Primärfarbe `#1677ff`) |
|
|
||||||
|
|
||||||
Kein Docker für die App selbst, kein React, keine Queue. Neue
|
**Funktionstrennung bleibt bestehen**, auch wenn in kleinen Firmen
|
||||||
Abhängigkeiten nur nach Rückfrage.
|
dieselbe Person Admin und Verantwortlicher ist — das sind zwei Rollen
|
||||||
|
auf demselben Menschen, keine verschmolzene Rolle. `admin` (Ebene 4,
|
||||||
|
Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
|
||||||
|
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
|
||||||
|
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
|
||||||
|
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
|
||||||
|
`admin` hieß.
|
||||||
|
|
||||||
**Warum Postgres statt SQLite:** Das Produkt läuft von Anfang an für
|
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
||||||
hunderte gleichzeitige Nutzer über mehrere Mandanten (Creator, Agentur,
|
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
||||||
Marke, Kanzlei). Ein revisionssicheres Beweisarchiv braucht robuste
|
|
||||||
Concurrent-Writes sowie Point-in-Time-Recovery/Replikation — das ist mit
|
|
||||||
SQLites Single-Writer-Modell nicht sauber zu bauen. Gehostet wird
|
|
||||||
Managed Postgres in der EU (DSGVO).
|
|
||||||
|
|
||||||
**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main`
|
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||||
|
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
||||||
|
`AccountID` gegen den angemeldeten Account, wie bisher) — die
|
||||||
|
Zielvorgabe "Isolation auf Datenbankebene erzwungen" (Postgres Row-
|
||||||
|
Level Security) ist eine bewusst noch nicht getroffene Entscheidung,
|
||||||
|
siehe Offene Punkte unten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragebogen (Ebene 2, "Antrag stellen")
|
||||||
|
|
||||||
|
Adaptiv: Folgefragen erscheinen nur, wenn die Antwort sie auslöst.
|
||||||
|
Alltagssprache, keine Fachbegriffe — die stehen nur in der Ableitung.
|
||||||
|
|
||||||
|
**A — Vorhaben:** `titel`, `beschreibung` (Freitext: was soll die KI
|
||||||
|
tun?), `ergebnis` (Freitext: was soll herauskommen?), `haeufigkeit`
|
||||||
|
(einmalig/gelegentlich/täglich/automatisiert), `abteilung` (aus
|
||||||
|
Stammdaten).
|
||||||
|
|
||||||
|
**B — Daten** (bestimmt die Datenklasse). Mehrfachauswahl Ja/Nein/
|
||||||
|
Unsicher, "Unsicher" zählt wie "Ja": Namen/E-Mail/Personenangaben (B1),
|
||||||
|
Gesundheit/Religion/Herkunft/Gewerkschaft/Sexualleben (B2), Patienten-/
|
||||||
|
Mandanten-/Versichertendaten (B3), Bewerbungs-/Personal-/
|
||||||
|
Leistungsdaten Beschäftigter (B4), von Kunden anvertraute Daten (B5),
|
||||||
|
Konstruktions-/Kalkulations-/Vertragsunterlagen (B6), nur allgemein
|
||||||
|
zugängliche/erfundene Inhalte (B7).
|
||||||
|
|
||||||
|
**C — Verwendung des Ergebnisses** (bestimmt die KI-VO-Einstufung):
|
||||||
|
geht das Ergebnis unverändert nach außen (C1)? beeinflusst es eine
|
||||||
|
Entscheidung über einen Menschen (C2, Folgefrage: Einstellung/
|
||||||
|
Beförderung/Kündigung, Kreditwürdigkeit, Leistungsbewilligung, Zugang
|
||||||
|
zu Bildung, medizinische Beurteilung, keins davon)? erkennt/bewertet es
|
||||||
|
Emotionen, Verhalten oder biometrische Merkmale (C3)? läuft es ohne
|
||||||
|
menschliche Prüfung (C4)? merkt der Empfänger, dass es von einer KI
|
||||||
|
stammt (C5)?
|
||||||
|
|
||||||
|
**D — Werkzeug:** ggf. schon ein Werkzeug im Sinn (optional, Freitext),
|
||||||
|
Firmen- oder privater Zugang, Firmen- oder Privatgerät.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ableitungen (`rules/*.yaml`, `internal/rules`)
|
||||||
|
|
||||||
|
**Datenklasse** (`rules/datenklasse.yaml`) — höchste zutreffende Stufe
|
||||||
|
gewinnt: `oeffentlich` (B7) < `intern` (B6) < `auftragsdaten` (B5) <
|
||||||
|
`personenbezogen` (B1, B4) < `berufsgeheimnis` (B3, § 203 StGB) <
|
||||||
|
`besondere_kategorie` (B2, Art. 9 DSGVO). **Die Rangfolge ist eine
|
||||||
|
Annahme** (Spezifikation nennt keine expliziten Ränge) — siehe
|
||||||
|
`rules/OPEN.md`, vor Produktiveinsatz mit dem/der Datenschutz-
|
||||||
|
beauftragten bestätigen.
|
||||||
|
|
||||||
|
**KI-VO-Einstufung** (`rules/kivo_einstufung.yaml`) — Prüfreihenfolge,
|
||||||
|
erste zutreffende Stufe gewinnt: `verboten` (Art. 5 KI-VO: C3 mit
|
||||||
|
Emotionserkennung am Arbeitsplatz, Social Scoring oder biometrischer
|
||||||
|
Kategorisierung) → `hochrisiko` (C2 mit Beschäftigung/Kreditwürdigkeit/
|
||||||
|
Leistungsbewilligung/Bildung/Gesundheit) → `transparenzpflicht` (C1 ja
|
||||||
|
und C5 nein) → `minimal` (alles übrige). Die genauen Fragebogen-Felder
|
||||||
|
für die drei "verboten"-Varianten sind noch nicht abschließend
|
||||||
|
spezifiziert — siehe `rules/OPEN.md`.
|
||||||
|
|
||||||
|
**Anforderungsprofil** (`rules/anforderungen.yaml`) — aus Datenklasse
|
||||||
|
und/oder Einstufung abgeleitete Menge von Anforderungen an ein
|
||||||
|
Werkzeug: `avv_erforderlich`, `eu_verarbeitung`,
|
||||||
|
`kein_training_auf_eingabe`, `loeschfrist_max_tage`,
|
||||||
|
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
|
||||||
|
|
||||||
|
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
|
||||||
|
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser).
|
||||||
|
**Die Auswertung gegen echte Fragebogen-Antworten ist noch nicht
|
||||||
|
gebaut** — das ist Schritt 3 der Baureihenfolge (siehe unten), bewusst
|
||||||
|
erst, wenn der Fragebogen (Schritt 2) die exakten Fakten-Feldnamen
|
||||||
|
festlegt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Werkzeugkatalog (`werkzeug`, `werkzeug_sperre`)
|
||||||
|
|
||||||
|
Der eigentliche Wert des Produkts. Zentral gepflegt (Ebene 5,
|
||||||
|
`account_id IS NULL`), für alle Mandanten identisch sichtbar; jeder
|
||||||
|
Mandant kann zusätzlich eigene Einträge (`account_id` gesetzt) und
|
||||||
|
Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
|
||||||
|
zentralen Katalog zu verändern.
|
||||||
|
|
||||||
|
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
|
||||||
|
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
|
||||||
|
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
|
||||||
|
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
|
||||||
|
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
|
||||||
|
nicht in der UI umgesetzt, siehe Offene Punkte).
|
||||||
|
|
||||||
|
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||||
|
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||||
|
jeder Bewertung/Entscheidung eingefroren, damit im Audit nachvollziehbar
|
||||||
|
bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bewertungslogik (geplant, Schritt 3+)
|
||||||
|
|
||||||
|
1. **K.-o.-Prüfung.** Einstufung `verboten` → sofort Ablehnung (Art. 5
|
||||||
|
KI-VO), keine Werkzeugsuche.
|
||||||
|
2. **Harte Filter.** Werkzeuge aussortieren, die eine Pflichtanforderung
|
||||||
|
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
|
||||||
|
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
|
||||||
|
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
|
||||||
|
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
|
||||||
|
und gegen `geeignete_zwecke` filtern.
|
||||||
|
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
|
||||||
|
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
|
||||||
|
Prüfung.
|
||||||
|
5. **Auflagen ableiten.** Statt Ablehnung Bedingungen erzeugen
|
||||||
|
(Pseudonymisierung, keine Klarnamen, Vier-Augen-Prüfung,
|
||||||
|
Kennzeichnung, Aufbewahrung begrenzen).
|
||||||
|
6. **Vorschlag erzeugen.** Empfehlung, Alternativen, Auflagen,
|
||||||
|
vollständige Herleitung.
|
||||||
|
|
||||||
|
Jede Bewertung wird als eigene Zeile gespeichert (append-only, wie
|
||||||
|
`finding`/`extraction` im alten Produkt) — eine neue Bewertung ersetzt
|
||||||
|
nicht die alte, sie ergänzt sie, damit Nachvollziehbarkeit auch bei
|
||||||
|
mehrfacher Neubewertung eines Antrags erhalten bleibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entscheidung, Register, Wiedervorlage (geplant, Schritt 5-7)
|
||||||
|
|
||||||
|
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
|
||||||
|
abgelehnt / Rückfrage. Begründungsfeld ist bei Abweichung vom Vorschlag
|
||||||
|
Pflicht. **Snapshot bei Entscheidung:** Regelwerk-Version, Katalog-
|
||||||
|
Version und der vollständige Werkzeugdatensatz werden eingefroren — ein
|
||||||
|
späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf eine
|
||||||
|
Entscheidung beruhte.
|
||||||
|
|
||||||
|
Genehmigungen erhalten ein Ablaufdatum (Vorschlag: 12 Monate, bei
|
||||||
|
`hochrisiko` 6). Ändert sich im Katalog eine Eigenschaft, auf der eine
|
||||||
|
aktive Genehmigung beruht, wird der/die Verantwortliche benachrichtigt
|
||||||
|
(Wiedervorlage).
|
||||||
|
|
||||||
|
Jede Genehmigung erzeugt automatisch einen Registereintrag: Zweck,
|
||||||
|
Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/
|
||||||
|
r, Datum, Gültigkeit. Export als PDF und CSV.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onboarding
|
||||||
|
|
||||||
|
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
|
||||||
|
mit Firmendaten → Testzugang sofort aktiv → Freischaltung des
|
||||||
|
Bezahlbetriebs nach Prüfung durch den Betreiber (letzteres noch nicht
|
||||||
|
gebaut). Beim Anlegen entstehen automatisch: Account, erster Nutzer mit
|
||||||
|
Rolle `admin` (siehe Rollentabelle — vereinfacht v1: nicht zusätzlich
|
||||||
|
separat als `verantwortlicher` angelegt, siehe Offene Punkte),
|
||||||
|
leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
|
||||||
|
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
|
||||||
|
Ebene 5/Betreiber-Pflege gebaut ist).
|
||||||
|
|
||||||
|
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink
|
||||||
|
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV-
|
||||||
|
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne
|
||||||
|
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto
|
||||||
|
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma-
|
||||||
|
Registrierung.
|
||||||
|
|
||||||
|
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
|
||||||
|
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
|
||||||
|
nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,19 +246,15 @@ Managed Postgres in der EU (DSGVO).
|
|||||||
/var/www/deklarix/
|
/var/www/deklarix/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── deklarix/
|
│ └── deklarix/
|
||||||
│ └── main.go # Entry Point, HTTP-Server
|
│ └── main.go # Entry Point: Migration, Regelwerk laden, HTTP-Server
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── extract/ # regelbasierte Fakten-Erkennung (Keywords, Kürzungs-Position)
|
│ ├── auth/ # Passwort-Hashing, Session-Token (produktunabhängig)
|
||||||
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
|
│ ├── rules/ # Lädt/validiert Datenklasse-/Einstufungs-/Anforderungs-YAML
|
||||||
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
|
|
||||||
│ ├── dossier/ # PDF-Erzeugung
|
|
||||||
│ ├── store/ # Postgres, Migrationen
|
│ ├── store/ # Postgres, Migrationen
|
||||||
│ └── web/ # Handler, Templates
|
│ └── web/ # Handler, Templates
|
||||||
├── rules/ # YAML-Regeln, versioniert im Git
|
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
|
||||||
├── testdata/
|
|
||||||
│ └── golden/ # Golden-Tests: Beispielbeiträge + erwartetes Ergebnis
|
|
||||||
├── design/
|
├── design/
|
||||||
│ └── enterprise.css # Design-System (enconf-Basis)
|
│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot)
|
||||||
├── packaging/
|
├── packaging/
|
||||||
│ └── DEBIAN/
|
│ └── DEBIAN/
|
||||||
│ └── control.tmpl # .deb Package-Control-Template
|
│ └── control.tmpl # .deb Package-Control-Template
|
||||||
@@ -135,70 +271,64 @@ Managed Postgres in der EU (DSGVO).
|
|||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
|
- `account` — ein Mandant (ein Unternehmen). Jeder Antrag gehört genau
|
||||||
`session`), mehr braucht der MVP nicht:
|
einem Account.
|
||||||
|
- `app_user` — ein Login innerhalb eines Accounts. Rolle ist eine von
|
||||||
|
`mitarbeiter`, `verantwortlicher`, `pruefer`, `admin` (alle vier
|
||||||
|
Mandanten-bezogen) oder `betreiber` (plattformweit, Ebene 5).
|
||||||
|
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); echte
|
||||||
|
Tabelle statt zustandsloser signierter Tokens, damit Logout eine
|
||||||
|
Sitzung wirklich beendet.
|
||||||
|
- `audit_log` — Protokoll (append-only): jede Antragsänderung, jede
|
||||||
|
Bewertung, jede Entscheidung, jede Katalogänderung mit Zeitstempel
|
||||||
|
und Akteur.
|
||||||
|
- `abteilung` — Stammdaten für Fragebogen-Feld A.abteilung, je Mandant.
|
||||||
|
- `werkzeug` — Katalogeintrag (siehe oben); `account_id` NULL = zentral,
|
||||||
|
gesetzt = mandantenspezifische Ergänzung. NICHT append-only — der
|
||||||
|
Katalog wird gepflegt, eine Entscheidung friert den zu ihrem
|
||||||
|
Zeitpunkt gültigen Datensatz separat ein (geplant).
|
||||||
|
- `werkzeug_sperre` — Sperrung eines (auch zentralen) Katalogeintrags
|
||||||
|
durch einen einzelnen Mandanten.
|
||||||
|
- `antrag` — das Vorhaben aus Fragebogen-Abschnitt A plus die
|
||||||
|
vollständigen Antworten aus B/C/D als JSON (`antworten`, JSONB) — ein
|
||||||
|
starres Spaltenschema könnte den adaptiven Fragebogen nicht abbilden.
|
||||||
|
NICHT append-only (normale Zustandsänderung `entwurf` →
|
||||||
|
`eingereicht` → `entschieden`, wie `submission` es im alten Produkt
|
||||||
|
war).
|
||||||
|
- `bewertung`, `entscheidung`, `registereintrag` — **noch nicht
|
||||||
|
gebaut**, geplant für Schritt 5/6 der Baureihenfolge.
|
||||||
|
|
||||||
- `account` — ein Mandant (Creator, Agentur, Marke oder Kanzlei als
|
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
|
||||||
eigene Organisation); jede Submission gehört genau einem Account.
|
wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
|
||||||
`verified` markiert einen Kanzlei-Account als für das öffentliche,
|
`registereintrag` werden bei ihrer Einführung ebenfalls append-only.
|
||||||
kostenlose Kanzlei-Verzeichnis (`GET /kanzleien`) freigegeben — nur
|
|
||||||
vom Admin-Bereich aus setzbar, nie vom Mandanten selbst
|
|
||||||
- `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort-
|
|
||||||
Hash, Rolle). Rolle ist eine von `creator`, `agentur`, `marke`,
|
|
||||||
`kanzlei` **oder `admin`**. `admin` ist Betreiber-Personal
|
|
||||||
(Netcell-IT), nicht an einen Mandanten-Geschäftszweck gebunden,
|
|
||||||
zuständig für den Admin-Bereich (`/admin/...`: Accounts-Übersicht,
|
|
||||||
Kanzlei-Verzeichnis-Freigabe, Audit-Log). Es gibt **keine**
|
|
||||||
Selbstregistrierung für `admin` über `POST /register` (das Formular
|
|
||||||
bietet die Rolle nicht an) — der erste Admin wird einmalig per SQL
|
|
||||||
angelegt:
|
|
||||||
```sql
|
|
||||||
INSERT INTO account (name) VALUES ('Deklarix Admin') RETURNING id;
|
|
||||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
|
||||||
VALUES ('<account-id>', '<login>', '<bcrypt-hash>', 'admin');
|
|
||||||
```
|
|
||||||
(bcrypt-Hash z. B. über `internal/auth.HashPassword` in einem
|
|
||||||
Wegwerf-`cmd/`-Programm erzeugen, da `internal/` von außerhalb des
|
|
||||||
Moduls nicht importierbar ist)
|
|
||||||
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst
|
|
||||||
eine echte Tabelle statt zustandsloser signierter Tokens, damit
|
|
||||||
Logout eine Sitzung wirklich beendet
|
|
||||||
- `audit_log` — Protokoll der Admin-Aktionen (wer hat wann welchen
|
|
||||||
Account wie verändert); append-only aus demselben Grund wie
|
|
||||||
`finding`/`extraction`/`evidence_package`
|
|
||||||
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
|
|
||||||
- `asset` — Bild oder Datei, Pfad, SHA-256
|
|
||||||
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
|
|
||||||
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
|
|
||||||
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
|
|
||||||
kein Verweis auf die aktuelle Regel-YAML — die kann sich inzwischen
|
|
||||||
geändert haben)
|
|
||||||
- `evidence_package` — Dossier, Hash, Zeitstempel-Token, erzeugt am
|
|
||||||
- `participant` — Beteiligter an einer Submission mit Rolle
|
|
||||||
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
|
|
||||||
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
|
|
||||||
|
|
||||||
**Append-only.** Kein UPDATE auf `finding`, `extraction`,
|
---
|
||||||
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
|
|
||||||
Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man
|
|
||||||
Zeilen ändern kann, ist keines mehr.
|
|
||||||
|
|
||||||
**Beweiskette:** SHA-256 über jedes Asset und über die kanonisierte
|
## Baureihenfolge
|
||||||
JSON-Repräsentation der Metadaten, RFC-3161-Zeitstempel über diesen Hash.
|
|
||||||
Dossier-PDF enthält Beitrag, Caption, Kennzeichnung, Findings mit
|
|
||||||
Fundstellen, Vertrags- und Briefing-Bezug, Verantwortungsmatrix, alle
|
|
||||||
Hashes und das Zeitstempel-Token. Vollständiger Export muss für den
|
|
||||||
Nutzer jederzeit möglich sein — wer kündigt, bekommt sein Archiv.
|
|
||||||
|
|
||||||
**Auth/Mandantentrennung:** `POST /register` legt einen neuen Account
|
1. ~~Datenmodell, Regelwerk als YAML, Katalogstruktur~~ — **erledigt**
|
||||||
plus den ersten Nutzer darin an, `POST /login` meldet einen bestehenden
|
(dieser Stand: `account`/`app_user`/`session`/`audit_log`/
|
||||||
Nutzer an — beides setzt ein `deklarix_session`-Cookie (HttpOnly,
|
`abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres,
|
||||||
SameSite=Strict, Secure sobald über TLS erreicht). `GET /` verlangt eine
|
`internal/rules` lädt und validiert die drei YAML-Regelwerke,
|
||||||
gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`,
|
Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).
|
||||||
`POST /veroeffentlichen` und `GET /dossier/{id}` verlangen sie ebenfalls
|
2. ~~Fragebogen mit adaptiver Logik~~ — **erledigt** (`GET /antraege/neu`
|
||||||
(sonst 401). Ein Beitrag eines fremden Accounts wird wie ein nicht
|
einseitiges Formular, `POST /antraege` legt an und reicht direkt ein,
|
||||||
existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
|
`GET /antraege` eigene Anträge, `GET /antraege/{id}` Detail. Adaptive
|
||||||
sonst würde die Antwort selbst verraten, dass die ID existiert.
|
Folgefragen C2/C3 rein per CSS `:has()` ein-/ausgeblendet, kein
|
||||||
|
JavaScript. `antworten`-JSON nutzt exakt die Fakten-Schlüssel aus
|
||||||
|
`rules/*.yaml` (b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die
|
||||||
|
OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.)
|
||||||
|
3. Ableitungen und harte Filter (Auswertung gegen echte Antworten)
|
||||||
|
4. Ergebnisdarstellung mit Herleitung
|
||||||
|
5. Entscheidung, Snapshot, Audit-Log
|
||||||
|
6. Registereintrag und Export
|
||||||
|
7. Wiedervorlage und Katalog-Benachrichtigung
|
||||||
|
|
||||||
|
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
|
||||||
|
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
|
||||||
|
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit
|
||||||
|
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische
|
||||||
|
Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -319,56 +449,23 @@ zieht dafür aber **kein** eigenes Postgres hoch (kein Docker-Zwang auf
|
|||||||
dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems,
|
dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems,
|
||||||
dort muss `DATABASE_URL` vorkonfiguriert sein.
|
dort muss `DATABASE_URL` vorkonfiguriert sein.
|
||||||
|
|
||||||
**Golden-Test-Suite ist das eigentliche Asset des Projekts, nicht die
|
|
||||||
UI.** 30 bis 50 Beispielbeiträge in `testdata/golden/`, abgeleitet aus
|
|
||||||
veröffentlichten Urteilen und Beanstandungen der Medienanstalten, jeweils
|
|
||||||
mit erwarteten Findings. Jede Regeländerung läuft gegen die komplette
|
|
||||||
Suite. Eine Regeländerung, die ein Golden-Ergebnis kippt, wird nicht
|
|
||||||
gemerged, ohne dass der erwartete Wert bewusst und dokumentiert angepasst
|
|
||||||
wird. Extraktion wird separat getestet: fixierte Eingaben, geprüfte
|
|
||||||
JSON-Ausgabe.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Leitplanken, die dauerhaft gelten
|
## Leitplanken, die dauerhaft gelten
|
||||||
|
|
||||||
- **Keine Rechtsdienstleistung.** Deklarix ist ein Werkzeug, keine
|
- **Keine Rechtsdienstleistung.** Deklarix bereitet eine Einstufung vor,
|
||||||
Rechtsberatung. Keine Formulierung darf eine individuelle rechtliche
|
entscheidet nicht. Keine Formulierung darf eine individuelle
|
||||||
Bewertung suggerieren. Auf jedem Screen mit einem Ergebnis steht ein
|
rechtliche Bewertung suggerieren oder Erfolgsaussichten behaupten.
|
||||||
entsprechender Hinweis. Keine Erfolgsaussichten-Aussagen.
|
- **Keine erfundenen Einstufungen.** Jede Ableitungsregel steht in einer
|
||||||
- **Keine Versicherungssprache.** Nie „Abmahnschutz“, „abgesichert“,
|
Regel-YAML und wurde dort von Hand geprüft; offene fachliche Fragen
|
||||||
„geschützt“, „Kostenübernahme“ — sonst erlaubnispflichtiges
|
stehen in `rules/OPEN.md`, werden nicht geraten.
|
||||||
Versicherungsgeschäft. Immer „Prüfung“, „Hinweis“, „Dokumentation“,
|
- **Werkzeugkatalog-Zusicherungen müssen belegbar sein.** `quelle` und
|
||||||
„Nachweis“.
|
`letzte_pruefung` sind Pflichtfelder, keine Ausnahme.
|
||||||
- **Keine erfundenen Fundstellen.** Jede Norm und jedes Urteil steht in
|
- **Keine Mitarbeiterüberwachung.** Nichts im Produkt darf auswerten,
|
||||||
einer Regel-YAML und wurde dort von Hand geprüft.
|
welche einzelne Person welches Werkzeug wie oft nutzt — das
|
||||||
- **Nur eigene Inhalte.** Kein Scraping fremder Accounts, keine fremden
|
widerspricht dem Vertrauensmodell, auf dem die freiwillige
|
||||||
Posts als Demo-Material. Demo-Material ist synthetisch. Nutzerinhalte
|
Antragstellung beruht.
|
||||||
kommen über eigenen OAuth-Zugang oder Datenexport.
|
- **DSGVO und Hosting in der EU.**
|
||||||
- **DSGVO und Hosting in der EU.** Löschkonzept von Anfang an mitdenken,
|
|
||||||
aber Beweisarchiv und Löschpflicht sauber gegeneinander abgrenzen
|
|
||||||
(offener Punkt, siehe unten).
|
|
||||||
- **Berufsrecht der Anwälte (§ 49b Abs. 3 S. 1 BRAO).** Kein Routing
|
|
||||||
(kein Button, der einen Fall an eine Kanzlei übergibt — Nutzer wählt
|
|
||||||
selbst aus dem Verzeichnis), kein ergebnisabhängiger Preis, keine
|
|
||||||
Sachvorteile an gelistete Kanzleien, Verzeichnis bleibt kostenlos
|
|
||||||
(BGH IX ZR 89/23: entgeltliche Mandatsvermittlung ist nichtig). Vor dem
|
|
||||||
ersten Kanzlei-Vertrag: formlose Auskunft der zuständigen
|
|
||||||
Rechtsanwaltskammer einholen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Domains
|
|
||||||
|
|
||||||
| Domain | Verwendung |
|
|
||||||
|--------|-----------|
|
|
||||||
| deklarix.de | Primär (Marketing-Site) |
|
|
||||||
| app.deklarix.de | Die Anwendung selbst (dieser Go-Server) |
|
|
||||||
| deklarix.com | Redirect auf deklarix.de |
|
|
||||||
| get.deklarix.com | Installer-Endpoint (apt-Einrichtung, siehe Deployment) |
|
|
||||||
|
|
||||||
Kein separates `app.deklarix.com` — der Root-Redirect von `.com` auf
|
|
||||||
`.de` deckt Fehlnavigation ab, keine zweite Produktions-Subdomain nötig.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -392,8 +489,7 @@ git push origin main
|
|||||||
sudo systemctl start deklarix
|
sudo systemctl start deklarix
|
||||||
sudo systemctl status deklarix
|
sudo systemctl status deklarix
|
||||||
|
|
||||||
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
|
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR)
|
||||||
# DOSSIER_DIR, TSA_URL)
|
|
||||||
|
|
||||||
# Logs prüfen
|
# Logs prüfen
|
||||||
journalctl -u deklarix -f
|
journalctl -u deklarix -f
|
||||||
@@ -412,16 +508,11 @@ journalctl -u deklarix -f
|
|||||||
## Arbeitsweise für Claude Code
|
## Arbeitsweise für Claude Code
|
||||||
|
|
||||||
- Kleine Commits, ein Thema pro Commit
|
- Kleine Commits, ein Thema pro Commit
|
||||||
- Erst Test oder Golden-Fall, dann Implementierung
|
- Erst Test, dann Implementierung
|
||||||
- Keine neue Abhängigkeit ohne Rückfrage
|
- Keine neue Abhängigkeit ohne Rückfrage
|
||||||
- Keine Umbenennung von Regel-IDs; Regeln werden versioniert, nicht
|
- Fehler nicht verschlucken, keine stillen Fallbacks
|
||||||
ersetzt
|
- Bei fachlicher Unsicherheit: nicht raten, sondern als offene Frage in
|
||||||
- Fehler nicht verschlucken, keine stillen Fallbacks bei der Extraktion
|
`rules/OPEN.md` notieren
|
||||||
- Bei Unsicherheit über eine Rechtsfrage: nicht raten, sondern als offene
|
|
||||||
Frage in `rules/OPEN.md` notieren
|
|
||||||
- Der Code-RAG dient der Orientierung im Bestand, nicht als Nachweis von
|
|
||||||
Korrektheit. Verifiziert wird durch Compiler, Tests und die
|
|
||||||
Golden-Suite.
|
|
||||||
|
|
||||||
## Vor Änderungen
|
## Vor Änderungen
|
||||||
|
|
||||||
@@ -440,13 +531,28 @@ journalctl -u deklarix -f
|
|||||||
|
|
||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
- Umfang des Regelsatzes für den ersten Pilotkunden festlegen
|
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
||||||
- **TSA für die Zeitstempel:** interimsweise FreeTSA.org (frei, RFC-3161-
|
(siehe `rules/OPEN.md`) — vor Schritt 3 mit dem/der fachlich
|
||||||
konform, siehe `internal/evidence.DefaultTSAURL`) — funktioniert
|
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen.
|
||||||
technisch, ist aber **nicht eIDAS-qualifiziert**. Vor echtem
|
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
||||||
Kundeneinsatz auf einen eIDAS-qualifizierten Zeitstempeldienst (z. B.
|
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
|
||||||
D-Trust, Bundesdruckerei) umstellen, der die gesetzliche
|
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
|
||||||
Vermutungswirkung nach eIDAS Art. 41 hat — dafür ist ein Vertrag/Account
|
(siehe `rules/OPEN.md`, Punkt 3).
|
||||||
nötig, kein anonymer HTTP-Call.
|
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||||
- Löschkonzept versus Beweisarchiv sauber ausformulieren
|
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||||
- Markenanmeldung Deklarix, sobald erster Kunde zahlt
|
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||||
|
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
||||||
|
Anwendungslogik"), aktuell ist Isolation ausschließlich
|
||||||
|
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
|
||||||
|
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
||||||
|
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
||||||
|
entstehen, die sonst nachträglich migriert werden müssten.
|
||||||
|
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die
|
||||||
|
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
|
||||||
|
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
|
||||||
|
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
|
||||||
|
zwei `app_user`-Zeilen für dieselbe Person?
|
||||||
|
- **Zentraler Werkzeugkatalog ist leer** — "Werkzeugkatalog aus der
|
||||||
|
Plattform vorbelegt" beim Onboarding ist nicht automatisiert, es gibt
|
||||||
|
noch keine Ebene-5/Betreiber-UI zur Katalogpflege.
|
||||||
|
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
@@ -29,26 +27,31 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
extractor := extract.NewEngine()
|
|
||||||
|
|
||||||
rulesDir := os.Getenv("RULES_DIR")
|
rulesDir := os.Getenv("RULES_DIR")
|
||||||
if rulesDir == "" {
|
if rulesDir == "" {
|
||||||
rulesDir = "rules"
|
rulesDir = "rules"
|
||||||
}
|
}
|
||||||
ruleSet, err := rules.Load(os.DirFS(rulesDir))
|
rulesFS := os.DirFS(rulesDir)
|
||||||
|
// Beim Start laden und validieren, auch wenn die Auswertungslogik
|
||||||
|
// (Phase 3 der Baureihenfolge) sie noch nicht konsumiert — ein
|
||||||
|
// kaputtes Regelwerk soll den Dienst nicht erst beim ersten Antrag
|
||||||
|
// zum Absturz bringen, sondern gar nicht erst starten lassen.
|
||||||
|
datenklasse, err := rules.LoadDatenklasse(rulesFS, "datenklasse.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load rules from %s: %v", rulesDir, err)
|
log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err)
|
||||||
}
|
}
|
||||||
log.Printf("%d Regeln aus %s geladen", len(ruleSet), rulesDir)
|
einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml")
|
||||||
|
if err != nil {
|
||||||
timestamper := evidence.NewHTTPTimestamper(os.Getenv("TSA_URL"))
|
log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err)
|
||||||
|
|
||||||
dossierDir := os.Getenv("DOSSIER_DIR")
|
|
||||||
if dossierDir == "" {
|
|
||||||
dossierDir = "dossiers"
|
|
||||||
}
|
}
|
||||||
|
anforderungen, err := rules.LoadAnforderungen(rulesFS, "anforderungen.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load anforderungsprofil-regelwerk from %s: %v", rulesDir, err)
|
||||||
|
}
|
||||||
|
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||||
|
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||||
|
|
||||||
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir)
|
server, err := web.NewServer(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web server: %v", err)
|
log.Fatalf("web server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
!00 U+0000 .notdef
|
|
||||||
!01 U+0001 .notdef
|
|
||||||
!02 U+0002 .notdef
|
|
||||||
!03 U+0003 .notdef
|
|
||||||
!04 U+0004 .notdef
|
|
||||||
!05 U+0005 .notdef
|
|
||||||
!06 U+0006 .notdef
|
|
||||||
!07 U+0007 .notdef
|
|
||||||
!08 U+0008 .notdef
|
|
||||||
!09 U+0009 .notdef
|
|
||||||
!0A U+000A .notdef
|
|
||||||
!0B U+000B .notdef
|
|
||||||
!0C U+000C .notdef
|
|
||||||
!0D U+000D .notdef
|
|
||||||
!0E U+000E .notdef
|
|
||||||
!0F U+000F .notdef
|
|
||||||
!10 U+0010 .notdef
|
|
||||||
!11 U+0011 .notdef
|
|
||||||
!12 U+0012 .notdef
|
|
||||||
!13 U+0013 .notdef
|
|
||||||
!14 U+0014 .notdef
|
|
||||||
!15 U+0015 .notdef
|
|
||||||
!16 U+0016 .notdef
|
|
||||||
!17 U+0017 .notdef
|
|
||||||
!18 U+0018 .notdef
|
|
||||||
!19 U+0019 .notdef
|
|
||||||
!1A U+001A .notdef
|
|
||||||
!1B U+001B .notdef
|
|
||||||
!1C U+001C .notdef
|
|
||||||
!1D U+001D .notdef
|
|
||||||
!1E U+001E .notdef
|
|
||||||
!1F U+001F .notdef
|
|
||||||
!20 U+0020 space
|
|
||||||
!21 U+0021 exclam
|
|
||||||
!22 U+0022 quotedbl
|
|
||||||
!23 U+0023 numbersign
|
|
||||||
!24 U+0024 dollar
|
|
||||||
!25 U+0025 percent
|
|
||||||
!26 U+0026 ampersand
|
|
||||||
!27 U+0027 quotesingle
|
|
||||||
!28 U+0028 parenleft
|
|
||||||
!29 U+0029 parenright
|
|
||||||
!2A U+002A asterisk
|
|
||||||
!2B U+002B plus
|
|
||||||
!2C U+002C comma
|
|
||||||
!2D U+002D hyphen
|
|
||||||
!2E U+002E period
|
|
||||||
!2F U+002F slash
|
|
||||||
!30 U+0030 zero
|
|
||||||
!31 U+0031 one
|
|
||||||
!32 U+0032 two
|
|
||||||
!33 U+0033 three
|
|
||||||
!34 U+0034 four
|
|
||||||
!35 U+0035 five
|
|
||||||
!36 U+0036 six
|
|
||||||
!37 U+0037 seven
|
|
||||||
!38 U+0038 eight
|
|
||||||
!39 U+0039 nine
|
|
||||||
!3A U+003A colon
|
|
||||||
!3B U+003B semicolon
|
|
||||||
!3C U+003C less
|
|
||||||
!3D U+003D equal
|
|
||||||
!3E U+003E greater
|
|
||||||
!3F U+003F question
|
|
||||||
!40 U+0040 at
|
|
||||||
!41 U+0041 A
|
|
||||||
!42 U+0042 B
|
|
||||||
!43 U+0043 C
|
|
||||||
!44 U+0044 D
|
|
||||||
!45 U+0045 E
|
|
||||||
!46 U+0046 F
|
|
||||||
!47 U+0047 G
|
|
||||||
!48 U+0048 H
|
|
||||||
!49 U+0049 I
|
|
||||||
!4A U+004A J
|
|
||||||
!4B U+004B K
|
|
||||||
!4C U+004C L
|
|
||||||
!4D U+004D M
|
|
||||||
!4E U+004E N
|
|
||||||
!4F U+004F O
|
|
||||||
!50 U+0050 P
|
|
||||||
!51 U+0051 Q
|
|
||||||
!52 U+0052 R
|
|
||||||
!53 U+0053 S
|
|
||||||
!54 U+0054 T
|
|
||||||
!55 U+0055 U
|
|
||||||
!56 U+0056 V
|
|
||||||
!57 U+0057 W
|
|
||||||
!58 U+0058 X
|
|
||||||
!59 U+0059 Y
|
|
||||||
!5A U+005A Z
|
|
||||||
!5B U+005B bracketleft
|
|
||||||
!5C U+005C backslash
|
|
||||||
!5D U+005D bracketright
|
|
||||||
!5E U+005E asciicircum
|
|
||||||
!5F U+005F underscore
|
|
||||||
!60 U+0060 grave
|
|
||||||
!61 U+0061 a
|
|
||||||
!62 U+0062 b
|
|
||||||
!63 U+0063 c
|
|
||||||
!64 U+0064 d
|
|
||||||
!65 U+0065 e
|
|
||||||
!66 U+0066 f
|
|
||||||
!67 U+0067 g
|
|
||||||
!68 U+0068 h
|
|
||||||
!69 U+0069 i
|
|
||||||
!6A U+006A j
|
|
||||||
!6B U+006B k
|
|
||||||
!6C U+006C l
|
|
||||||
!6D U+006D m
|
|
||||||
!6E U+006E n
|
|
||||||
!6F U+006F o
|
|
||||||
!70 U+0070 p
|
|
||||||
!71 U+0071 q
|
|
||||||
!72 U+0072 r
|
|
||||||
!73 U+0073 s
|
|
||||||
!74 U+0074 t
|
|
||||||
!75 U+0075 u
|
|
||||||
!76 U+0076 v
|
|
||||||
!77 U+0077 w
|
|
||||||
!78 U+0078 x
|
|
||||||
!79 U+0079 y
|
|
||||||
!7A U+007A z
|
|
||||||
!7B U+007B braceleft
|
|
||||||
!7C U+007C bar
|
|
||||||
!7D U+007D braceright
|
|
||||||
!7E U+007E asciitilde
|
|
||||||
!7F U+007F .notdef
|
|
||||||
!80 U+20AC Euro
|
|
||||||
!82 U+201A quotesinglbase
|
|
||||||
!83 U+0192 florin
|
|
||||||
!84 U+201E quotedblbase
|
|
||||||
!85 U+2026 ellipsis
|
|
||||||
!86 U+2020 dagger
|
|
||||||
!87 U+2021 daggerdbl
|
|
||||||
!88 U+02C6 circumflex
|
|
||||||
!89 U+2030 perthousand
|
|
||||||
!8A U+0160 Scaron
|
|
||||||
!8B U+2039 guilsinglleft
|
|
||||||
!8C U+0152 OE
|
|
||||||
!8E U+017D Zcaron
|
|
||||||
!91 U+2018 quoteleft
|
|
||||||
!92 U+2019 quoteright
|
|
||||||
!93 U+201C quotedblleft
|
|
||||||
!94 U+201D quotedblright
|
|
||||||
!95 U+2022 bullet
|
|
||||||
!96 U+2013 endash
|
|
||||||
!97 U+2014 emdash
|
|
||||||
!98 U+02DC tilde
|
|
||||||
!99 U+2122 trademark
|
|
||||||
!9A U+0161 scaron
|
|
||||||
!9B U+203A guilsinglright
|
|
||||||
!9C U+0153 oe
|
|
||||||
!9E U+017E zcaron
|
|
||||||
!9F U+0178 Ydieresis
|
|
||||||
!A0 U+00A0 space
|
|
||||||
!A1 U+00A1 exclamdown
|
|
||||||
!A2 U+00A2 cent
|
|
||||||
!A3 U+00A3 sterling
|
|
||||||
!A4 U+00A4 currency
|
|
||||||
!A5 U+00A5 yen
|
|
||||||
!A6 U+00A6 brokenbar
|
|
||||||
!A7 U+00A7 section
|
|
||||||
!A8 U+00A8 dieresis
|
|
||||||
!A9 U+00A9 copyright
|
|
||||||
!AA U+00AA ordfeminine
|
|
||||||
!AB U+00AB guillemotleft
|
|
||||||
!AC U+00AC logicalnot
|
|
||||||
!AD U+00AD hyphen
|
|
||||||
!AE U+00AE registered
|
|
||||||
!AF U+00AF macron
|
|
||||||
!B0 U+00B0 degree
|
|
||||||
!B1 U+00B1 plusminus
|
|
||||||
!B2 U+00B2 twosuperior
|
|
||||||
!B3 U+00B3 threesuperior
|
|
||||||
!B4 U+00B4 acute
|
|
||||||
!B5 U+00B5 mu
|
|
||||||
!B6 U+00B6 paragraph
|
|
||||||
!B7 U+00B7 periodcentered
|
|
||||||
!B8 U+00B8 cedilla
|
|
||||||
!B9 U+00B9 onesuperior
|
|
||||||
!BA U+00BA ordmasculine
|
|
||||||
!BB U+00BB guillemotright
|
|
||||||
!BC U+00BC onequarter
|
|
||||||
!BD U+00BD onehalf
|
|
||||||
!BE U+00BE threequarters
|
|
||||||
!BF U+00BF questiondown
|
|
||||||
!C0 U+00C0 Agrave
|
|
||||||
!C1 U+00C1 Aacute
|
|
||||||
!C2 U+00C2 Acircumflex
|
|
||||||
!C3 U+00C3 Atilde
|
|
||||||
!C4 U+00C4 Adieresis
|
|
||||||
!C5 U+00C5 Aring
|
|
||||||
!C6 U+00C6 AE
|
|
||||||
!C7 U+00C7 Ccedilla
|
|
||||||
!C8 U+00C8 Egrave
|
|
||||||
!C9 U+00C9 Eacute
|
|
||||||
!CA U+00CA Ecircumflex
|
|
||||||
!CB U+00CB Edieresis
|
|
||||||
!CC U+00CC Igrave
|
|
||||||
!CD U+00CD Iacute
|
|
||||||
!CE U+00CE Icircumflex
|
|
||||||
!CF U+00CF Idieresis
|
|
||||||
!D0 U+00D0 Eth
|
|
||||||
!D1 U+00D1 Ntilde
|
|
||||||
!D2 U+00D2 Ograve
|
|
||||||
!D3 U+00D3 Oacute
|
|
||||||
!D4 U+00D4 Ocircumflex
|
|
||||||
!D5 U+00D5 Otilde
|
|
||||||
!D6 U+00D6 Odieresis
|
|
||||||
!D7 U+00D7 multiply
|
|
||||||
!D8 U+00D8 Oslash
|
|
||||||
!D9 U+00D9 Ugrave
|
|
||||||
!DA U+00DA Uacute
|
|
||||||
!DB U+00DB Ucircumflex
|
|
||||||
!DC U+00DC Udieresis
|
|
||||||
!DD U+00DD Yacute
|
|
||||||
!DE U+00DE Thorn
|
|
||||||
!DF U+00DF germandbls
|
|
||||||
!E0 U+00E0 agrave
|
|
||||||
!E1 U+00E1 aacute
|
|
||||||
!E2 U+00E2 acircumflex
|
|
||||||
!E3 U+00E3 atilde
|
|
||||||
!E4 U+00E4 adieresis
|
|
||||||
!E5 U+00E5 aring
|
|
||||||
!E6 U+00E6 ae
|
|
||||||
!E7 U+00E7 ccedilla
|
|
||||||
!E8 U+00E8 egrave
|
|
||||||
!E9 U+00E9 eacute
|
|
||||||
!EA U+00EA ecircumflex
|
|
||||||
!EB U+00EB edieresis
|
|
||||||
!EC U+00EC igrave
|
|
||||||
!ED U+00ED iacute
|
|
||||||
!EE U+00EE icircumflex
|
|
||||||
!EF U+00EF idieresis
|
|
||||||
!F0 U+00F0 eth
|
|
||||||
!F1 U+00F1 ntilde
|
|
||||||
!F2 U+00F2 ograve
|
|
||||||
!F3 U+00F3 oacute
|
|
||||||
!F4 U+00F4 ocircumflex
|
|
||||||
!F5 U+00F5 otilde
|
|
||||||
!F6 U+00F6 odieresis
|
|
||||||
!F7 U+00F7 divide
|
|
||||||
!F8 U+00F8 oslash
|
|
||||||
!F9 U+00F9 ugrave
|
|
||||||
!FA U+00FA uacute
|
|
||||||
!FB U+00FB ucircumflex
|
|
||||||
!FC U+00FC udieresis
|
|
||||||
!FD U+00FD yacute
|
|
||||||
!FE U+00FE thorn
|
|
||||||
!FF U+00FF ydieresis
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
// Package dossier erzeugt das Nachweis-Dossier (PDF) aus Submission,
|
|
||||||
// Findings, Verantwortungsmatrix und Beweiskette. Die Aufbereitung des
|
|
||||||
// Inhalts (BuildContent) ist von der eigentlichen PDF-Zeichnung
|
|
||||||
// (Render) getrennt, damit die fachliche Logik — was steht wo im
|
|
||||||
// Dossier, in welcher Form — ohne PDF-Parsing testbar ist.
|
|
||||||
package dossier
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Disclaimer steht auf jedem erzeugten Dossier. Siehe CLAUDE.md,
|
|
||||||
// Leitplanken: Deklarix ist ein Werkzeug, keine Rechtsberatung.
|
|
||||||
const Disclaimer = "Dieses Dossier dokumentiert die durchgeführte Prüfung. " +
|
|
||||||
"Es ist keine Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall."
|
|
||||||
|
|
||||||
// Submission sind die Basisdaten des geprüften Beitrags.
|
|
||||||
type Submission struct {
|
|
||||||
Platform string
|
|
||||||
PostType string
|
|
||||||
Caption string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Participant ist ein Beteiligter aus der Verantwortungsmatrix.
|
|
||||||
type Participant struct {
|
|
||||||
Role string
|
|
||||||
Name string
|
|
||||||
Vorgegeben bool
|
|
||||||
Freigegeben bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data ist die Eingabe für Generate/BuildContent — alles, was ein
|
|
||||||
// Dossier für einen Beitrag braucht.
|
|
||||||
type Data struct {
|
|
||||||
Submission Submission
|
|
||||||
Facts rules.Facts
|
|
||||||
Findings []rules.Finding
|
|
||||||
Participants []Participant
|
|
||||||
AssetHash []byte
|
|
||||||
MetadataHash []byte
|
|
||||||
TimestampToken []byte
|
|
||||||
GeneratedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindingRow ist die dossier-taugliche Aufbereitung eines rules.Finding.
|
|
||||||
type FindingRow struct {
|
|
||||||
RuleID string
|
|
||||||
Version int
|
|
||||||
Severity string
|
|
||||||
Title string
|
|
||||||
Fix string
|
|
||||||
Sources []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParticipantRow ist die dossier-taugliche Aufbereitung eines Participant.
|
|
||||||
type ParticipantRow struct {
|
|
||||||
Role string
|
|
||||||
Name string
|
|
||||||
Vorgegeben bool
|
|
||||||
Freigegeben bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content ist die fertig aufbereitete, PDF-unabhängige Darstellung
|
|
||||||
// eines Dossiers.
|
|
||||||
type Content struct {
|
|
||||||
Title string
|
|
||||||
Platform string
|
|
||||||
PostType string
|
|
||||||
Caption string
|
|
||||||
|
|
||||||
SubmittedAt time.Time
|
|
||||||
GeneratedAt time.Time
|
|
||||||
|
|
||||||
DisclosurePresent bool
|
|
||||||
DisclosureWording string
|
|
||||||
DisclosureBeforeCut bool
|
|
||||||
|
|
||||||
Findings []FindingRow
|
|
||||||
Participants []ParticipantRow
|
|
||||||
|
|
||||||
AssetHashHex string
|
|
||||||
MetadataHashHex string
|
|
||||||
TimestampedAt time.Time
|
|
||||||
|
|
||||||
Disclaimer string
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildContent bereitet Data zu Content auf. Fehlt eine Pflichtangabe
|
|
||||||
// (Plattform, Zeitstempel-Token), wird ein Fehler geliefert statt ein
|
|
||||||
// Dossier mit stillschweigend leeren Beweisfeldern zu erzeugen.
|
|
||||||
func BuildContent(data Data) (Content, error) {
|
|
||||||
if data.Submission.Platform == "" {
|
|
||||||
return Content{}, fmt.Errorf("dossier: submission.platform fehlt")
|
|
||||||
}
|
|
||||||
if len(data.TimestampToken) == 0 {
|
|
||||||
return Content{}, fmt.Errorf("dossier: timestamp token fehlt")
|
|
||||||
}
|
|
||||||
// AssetHash ist optional: nicht jede Prüfung hat ein hochgeladenes
|
|
||||||
// Bild/Video (aktuell reine Caption-Prüfungen). Ein erfundener
|
|
||||||
// Platzhalter-Hash für ein nicht existierendes Asset wäre selbst ein
|
|
||||||
// Integritätsproblem — also zeigt das Dossier stattdessen "kein
|
|
||||||
// Asset hinterlegt" an, siehe Render.
|
|
||||||
if len(data.MetadataHash) == 0 {
|
|
||||||
return Content{}, fmt.Errorf("dossier: metadata hash fehlt")
|
|
||||||
}
|
|
||||||
|
|
||||||
timestampedAt, err := evidence.TimestampTime(data.TimestampToken)
|
|
||||||
if err != nil {
|
|
||||||
return Content{}, fmt.Errorf("dossier: timestamp token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
findings := make([]FindingRow, len(data.Findings))
|
|
||||||
for i, f := range data.Findings {
|
|
||||||
findings[i] = FindingRow{
|
|
||||||
RuleID: f.RuleID,
|
|
||||||
Version: f.RuleVersion,
|
|
||||||
Severity: string(f.Severity),
|
|
||||||
Title: f.Title,
|
|
||||||
Fix: f.Fix,
|
|
||||||
Sources: f.Sources,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
participants := make([]ParticipantRow, len(data.Participants))
|
|
||||||
for i, p := range data.Participants {
|
|
||||||
participants[i] = ParticipantRow{
|
|
||||||
Role: p.Role,
|
|
||||||
Name: p.Name,
|
|
||||||
Vorgegeben: p.Vorgegeben,
|
|
||||||
Freigegeben: p.Freigegeben,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Content{
|
|
||||||
Title: "Nachweis-Dossier",
|
|
||||||
Platform: data.Submission.Platform,
|
|
||||||
PostType: data.Submission.PostType,
|
|
||||||
Caption: data.Submission.Caption,
|
|
||||||
SubmittedAt: data.Submission.CreatedAt,
|
|
||||||
GeneratedAt: data.GeneratedAt,
|
|
||||||
DisclosurePresent: data.Facts.DisclosurePresent,
|
|
||||||
DisclosureWording: data.Facts.DisclosureWording,
|
|
||||||
DisclosureBeforeCut: data.Facts.DisclosureBeforeCut,
|
|
||||||
Findings: findings,
|
|
||||||
Participants: participants,
|
|
||||||
AssetHashHex: hex.EncodeToString(data.AssetHash),
|
|
||||||
MetadataHashHex: hex.EncodeToString(data.MetadataHash),
|
|
||||||
TimestampedAt: timestampedAt,
|
|
||||||
Disclaimer: Disclaimer,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
package dossier_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/x509"
|
|
||||||
"crypto/x509/pkix"
|
|
||||||
"encoding/asn1"
|
|
||||||
"math/big"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/digitorus/timestamp"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/dossier"
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeTimestampToken erzeugt einen strukturell gültigen, selbstsignierten
|
|
||||||
// RFC-3161-Token für hash — offline, ohne echte TSA. Damit lassen sich
|
|
||||||
// BuildContent/Render testen, ohne bei jedem Testlauf eine echte
|
|
||||||
// Time-Stamp Authority anzufragen (die Echtheit/Vertrauenswürdigkeit
|
|
||||||
// des Zertifikats spielt für diese Tests keine Rolle, nur dass der Token
|
|
||||||
// strukturell parsbar ist wie ein echter).
|
|
||||||
func fakeTimestampToken(t *testing.T, hash []byte, at time.Time) []byte {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
certTemplate := &x509.Certificate{
|
|
||||||
SerialNumber: big.NewInt(1),
|
|
||||||
Subject: pkix.Name{CommonName: "deklarix-test-tsa"},
|
|
||||||
NotBefore: at.Add(-time.Hour),
|
|
||||||
NotAfter: at.Add(time.Hour),
|
|
||||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
||||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
|
|
||||||
}
|
|
||||||
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create certificate: %v", err)
|
|
||||||
}
|
|
||||||
cert, err := x509.ParseCertificate(certDER)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse certificate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ts := timestamp.Timestamp{
|
|
||||||
HashAlgorithm: crypto.SHA256,
|
|
||||||
HashedMessage: hash,
|
|
||||||
Time: at,
|
|
||||||
SerialNumber: big.NewInt(1),
|
|
||||||
Policy: asn1.ObjectIdentifier{1, 2, 3},
|
|
||||||
}
|
|
||||||
respDER, err := ts.CreateResponse(cert, key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create timestamp response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := timestamp.ParseResponse(respDER)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse fake timestamp response: %v", err)
|
|
||||||
}
|
|
||||||
return parsed.RawToken
|
|
||||||
}
|
|
||||||
|
|
||||||
func validData(t *testing.T) dossier.Data {
|
|
||||||
t.Helper()
|
|
||||||
hash := evidence.HashBytes([]byte("caption+bild"))
|
|
||||||
at := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
return dossier.Data{
|
|
||||||
Submission: dossier.Submission{
|
|
||||||
Platform: "instagram",
|
|
||||||
PostType: "reel",
|
|
||||||
Caption: "Werbung für ein Produkt äöüß",
|
|
||||||
CreatedAt: at,
|
|
||||||
},
|
|
||||||
Facts: rules.Facts{
|
|
||||||
DisclosurePresent: true,
|
|
||||||
DisclosureWording: "Werbung",
|
|
||||||
DisclosureBeforeCut: false,
|
|
||||||
},
|
|
||||||
Findings: []rules.Finding{
|
|
||||||
{RuleID: "WK-004", RuleVersion: 1, Severity: rules.SeverityHigh, Title: "t", Fix: "f", Sources: []string{"§ 5a Abs. 4 UWG"}},
|
|
||||||
},
|
|
||||||
Participants: []dossier.Participant{
|
|
||||||
{Role: "creator", Name: "Max Mustermann", Vorgegeben: false, Freigegeben: true},
|
|
||||||
},
|
|
||||||
AssetHash: hash,
|
|
||||||
MetadataHash: evidence.HashBytes([]byte("metadata")),
|
|
||||||
TimestampToken: fakeTimestampToken(t, hash, at),
|
|
||||||
GeneratedAt: at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildContentRejectsMissingPlatform(t *testing.T) {
|
|
||||||
data := validData(t)
|
|
||||||
data.Submission.Platform = ""
|
|
||||||
if _, err := dossier.BuildContent(data); err == nil {
|
|
||||||
t.Fatal("expected error for missing platform, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildContentRejectsMissingTimestampToken(t *testing.T) {
|
|
||||||
data := validData(t)
|
|
||||||
data.TimestampToken = nil
|
|
||||||
if _, err := dossier.BuildContent(data); err == nil {
|
|
||||||
t.Fatal("expected error for missing timestamp token, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildContentRejectsMissingMetadataHash(t *testing.T) {
|
|
||||||
data := validData(t)
|
|
||||||
data.MetadataHash = nil
|
|
||||||
if _, err := dossier.BuildContent(data); err == nil {
|
|
||||||
t.Fatal("expected error for missing metadata hash, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildContentAllowsMissingAssetHash(t *testing.T) {
|
|
||||||
// Reine Caption-Pruefungen ohne hochgeladenes Bild/Video haben kein
|
|
||||||
// Asset zum Hashen — das ist kein Fehlerfall.
|
|
||||||
data := validData(t)
|
|
||||||
data.AssetHash = nil
|
|
||||||
content, err := dossier.BuildContent(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildContent: %v", err)
|
|
||||||
}
|
|
||||||
if content.AssetHashHex != "" {
|
|
||||||
t.Fatalf("AssetHashHex = %q, want empty when no asset was provided", content.AssetHashHex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildContentSuccess(t *testing.T) {
|
|
||||||
data := validData(t)
|
|
||||||
|
|
||||||
content, err := dossier.BuildContent(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildContent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if content.Platform != "instagram" {
|
|
||||||
t.Errorf("Platform = %q, want instagram", content.Platform)
|
|
||||||
}
|
|
||||||
if len(content.Findings) != 1 || content.Findings[0].RuleID != "WK-004" {
|
|
||||||
t.Errorf("Findings = %+v, want one WK-004 row", content.Findings)
|
|
||||||
}
|
|
||||||
if len(content.Participants) != 1 || content.Participants[0].Name != "Max Mustermann" {
|
|
||||||
t.Errorf("Participants = %+v, want one Max-Mustermann row", content.Participants)
|
|
||||||
}
|
|
||||||
if len(content.AssetHashHex) != 64 {
|
|
||||||
t.Errorf("AssetHashHex length = %d, want 64 (hex of 32-byte SHA-256)", len(content.AssetHashHex))
|
|
||||||
}
|
|
||||||
if !content.TimestampedAt.Equal(data.Submission.CreatedAt) {
|
|
||||||
t.Errorf("TimestampedAt = %v, want %v", content.TimestampedAt, data.Submission.CreatedAt)
|
|
||||||
}
|
|
||||||
if content.Disclaimer == "" {
|
|
||||||
t.Error("expected a non-empty disclaimer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package dossier
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"embed"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-pdf/fpdf"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed assets/cp1252.map
|
|
||||||
var assetsFS embed.FS
|
|
||||||
|
|
||||||
// Generate erzeugt das PDF-Dossier für data und schreibt es nach w.
|
|
||||||
func Generate(w io.Writer, data Data) error {
|
|
||||||
content, err := BuildContent(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return Render(w, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render zeichnet ein bereits aufbereitetes Content als PDF nach w.
|
|
||||||
func Render(w io.Writer, c Content) error {
|
|
||||||
tr, err := unicodeTranslator()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dossier: unicode translator: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf := fpdf.New("P", "mm", "A4", "")
|
|
||||||
pdf.SetTitle(tr(c.Title), false)
|
|
||||||
pdf.AddPage()
|
|
||||||
|
|
||||||
heading := func(text string) {
|
|
||||||
pdf.SetFont("Helvetica", "B", 12)
|
|
||||||
pdf.CellFormat(0, 8, tr(text), "", 1, "L", false, 0, "")
|
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
|
||||||
}
|
|
||||||
line := func(format string, args ...any) {
|
|
||||||
pdf.CellFormat(0, 6, tr(fmt.Sprintf(format, args...)), "", 1, "L", false, 0, "")
|
|
||||||
}
|
|
||||||
paragraph := func(text string) {
|
|
||||||
pdf.MultiCell(0, 5, tr(text), "", "L", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf.SetFont("Helvetica", "B", 16)
|
|
||||||
pdf.CellFormat(0, 10, tr(c.Title), "", 1, "L", false, 0, "")
|
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
|
||||||
line("Erzeugt am %s", c.GeneratedAt.Format("02.01.2006 15:04 MST"))
|
|
||||||
pdf.Ln(4)
|
|
||||||
|
|
||||||
heading("Beitrag")
|
|
||||||
line("Plattform: %s Typ: %s", c.Platform, c.PostType)
|
|
||||||
line("Eingereicht am: %s", c.SubmittedAt.Format("02.01.2006 15:04"))
|
|
||||||
paragraph("Caption: " + c.Caption)
|
|
||||||
pdf.Ln(2)
|
|
||||||
|
|
||||||
heading("Kennzeichnung")
|
|
||||||
line("Vorhanden: %s Wortlaut: %q Vor Kürzung sichtbar: %s",
|
|
||||||
yesNo(c.DisclosurePresent), c.DisclosureWording, yesNo(c.DisclosureBeforeCut))
|
|
||||||
pdf.Ln(2)
|
|
||||||
|
|
||||||
heading("Findings")
|
|
||||||
if len(c.Findings) == 0 {
|
|
||||||
line("Keine Findings.")
|
|
||||||
}
|
|
||||||
for _, f := range c.Findings {
|
|
||||||
pdf.SetFont("Helvetica", "B", 10)
|
|
||||||
line("%s v%d — %s (%s)", f.RuleID, f.Version, f.Title, f.Severity)
|
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
|
||||||
paragraph("Korrektur: " + f.Fix)
|
|
||||||
if len(f.Sources) > 0 {
|
|
||||||
paragraph("Fundstellen: " + strings.Join(f.Sources, "; "))
|
|
||||||
}
|
|
||||||
pdf.Ln(1)
|
|
||||||
}
|
|
||||||
pdf.Ln(2)
|
|
||||||
|
|
||||||
heading("Verantwortungsmatrix")
|
|
||||||
if len(c.Participants) == 0 {
|
|
||||||
line("Keine Beteiligten hinterlegt.")
|
|
||||||
}
|
|
||||||
for _, p := range c.Participants {
|
|
||||||
line("%s: %s (vorgegeben: %s, freigegeben: %s)",
|
|
||||||
p.Role, p.Name, yesNo(p.Vorgegeben), yesNo(p.Freigegeben))
|
|
||||||
}
|
|
||||||
pdf.Ln(2)
|
|
||||||
|
|
||||||
heading("Beweiskette")
|
|
||||||
if c.AssetHashHex != "" {
|
|
||||||
line("Asset-Hash (SHA-256): %s", c.AssetHashHex)
|
|
||||||
} else {
|
|
||||||
line("Asset-Hash: kein Asset hinterlegt (reine Caption-Prüfung)")
|
|
||||||
}
|
|
||||||
line("Metadaten-Hash (SHA-256): %s", c.MetadataHashHex)
|
|
||||||
line("RFC-3161-Zeitstempel: %s", c.TimestampedAt.Format("02.01.2006 15:04:05 MST"))
|
|
||||||
pdf.Ln(4)
|
|
||||||
|
|
||||||
pdf.SetFont("Helvetica", "I", 8)
|
|
||||||
paragraph(c.Disclaimer)
|
|
||||||
|
|
||||||
if err := pdf.Error(); err != nil {
|
|
||||||
return fmt.Errorf("dossier: pdf aufbauen: %w", err)
|
|
||||||
}
|
|
||||||
if err := pdf.Output(w); err != nil {
|
|
||||||
return fmt.Errorf("dossier: pdf schreiben: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func yesNo(b bool) string {
|
|
||||||
if b {
|
|
||||||
return "ja"
|
|
||||||
}
|
|
||||||
return "nein"
|
|
||||||
}
|
|
||||||
|
|
||||||
// unicodeTranslator übersetzt UTF-8 (z. B. Umlaute) in die cp1252-
|
|
||||||
// Kodierung der Helvetica-Kernschriftart. Die Map-Datei ist eingebettet,
|
|
||||||
// damit Deklarix trotz PDF-Erzeugung ein einzelnes Binary bleibt.
|
|
||||||
func unicodeTranslator() (func(string) string, error) {
|
|
||||||
data, err := assetsFS.ReadFile("assets/cp1252.map")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return fpdf.UnicodeTranslator(bytes.NewReader(data))
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package dossier_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/dossier"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRenderProducesValidPDF(t *testing.T) {
|
|
||||||
content, err := dossier.BuildContent(validData(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildContent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := dossier.Render(&buf, content); err != nil {
|
|
||||||
t.Fatalf("Render: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := buf.Bytes()
|
|
||||||
if !bytes.HasPrefix(out, []byte("%PDF-")) {
|
|
||||||
t.Fatalf("output does not start with %%PDF- header: %q", out[:min(20, len(out))])
|
|
||||||
}
|
|
||||||
if !bytes.Contains(out, []byte("%%EOF")) {
|
|
||||||
t.Fatal("output does not contain the expected PDF EOF trailer")
|
|
||||||
}
|
|
||||||
if len(out) < 500 {
|
|
||||||
t.Fatalf("output suspiciously small (%d bytes) for a multi-section dossier", len(out))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerateProducesValidPDF(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := dossier.Generate(&buf, validData(t)); err != nil {
|
|
||||||
t.Fatalf("Generate: %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.HasPrefix(buf.Bytes(), []byte("%PDF-")) {
|
|
||||||
t.Fatal("Generate output does not start with the expected PDF header")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGeneratePropagatesBuildContentErrors(t *testing.T) {
|
|
||||||
data := validData(t)
|
|
||||||
data.Submission.Platform = ""
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := dossier.Generate(&buf, data); err == nil {
|
|
||||||
t.Fatal("expected Generate to propagate a BuildContent error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Package evidence bildet die Beweiskette: Hashing von Assets und
|
|
||||||
// Metadaten, RFC-3161-Zeitstempel über diesen Hash. Das Append-only-
|
|
||||||
// Prinzip selbst wird von internal/store (Postgres-Trigger) erzwungen —
|
|
||||||
// dieses Paket liefert nur die Bausteine, die evidence_package braucht.
|
|
||||||
package evidence
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HashBytes berechnet den SHA-256-Digest über beliebige Bytes, z. B.
|
|
||||||
// ein Asset wie Screenshot oder Video.
|
|
||||||
func HashBytes(data []byte) []byte {
|
|
||||||
sum := sha256.Sum256(data)
|
|
||||||
return sum[:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// HashMetadata berechnet den SHA-256-Digest über die kanonisierte JSON-
|
|
||||||
// Repräsentation von v. "Kanonisiert" heißt hier: encoding/json sortiert
|
|
||||||
// Objektschlüssel bereits deterministisch, und Struct-Felder behalten
|
|
||||||
// ihre Deklarationsreihenfolge — für unsere eigenen, fest definierten
|
|
||||||
// Go-Typen ist das schon eine stabile, reproduzierbare Serialisierung,
|
|
||||||
// ohne dass es eine eigene Kanonisierungs-Bibliothek bräuchte.
|
|
||||||
func HashMetadata(v any) ([]byte, error) {
|
|
||||||
data, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("evidence: metadata marshal: %w", err)
|
|
||||||
}
|
|
||||||
return HashBytes(data), nil
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package evidence_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHashBytesIsDeterministic(t *testing.T) {
|
|
||||||
data := []byte("ein beispiel-asset")
|
|
||||||
a := evidence.HashBytes(data)
|
|
||||||
b := evidence.HashBytes(data)
|
|
||||||
if !bytes.Equal(a, b) {
|
|
||||||
t.Fatalf("HashBytes ist nicht deterministisch: %x != %x", a, b)
|
|
||||||
}
|
|
||||||
if len(a) != 32 {
|
|
||||||
t.Fatalf("expected 32-byte SHA-256 digest, got %d bytes", len(a))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHashBytesDiffersForDifferentInput(t *testing.T) {
|
|
||||||
a := evidence.HashBytes([]byte("foo"))
|
|
||||||
b := evidence.HashBytes([]byte("bar"))
|
|
||||||
if bytes.Equal(a, b) {
|
|
||||||
t.Fatal("expected different hashes for different input, got the same")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHashMetadataIsStableAcrossMapKeyOrder(t *testing.T) {
|
|
||||||
m1 := map[string]any{"a": 1, "b": 2, "c": 3}
|
|
||||||
m2 := map[string]any{"c": 3, "a": 1, "b": 2}
|
|
||||||
|
|
||||||
h1, err := evidence.HashMetadata(m1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("HashMetadata(m1): %v", err)
|
|
||||||
}
|
|
||||||
h2, err := evidence.HashMetadata(m2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("HashMetadata(m2): %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(h1, h2) {
|
|
||||||
t.Fatal("HashMetadata should be stable across map key insertion order")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHashMetadataDiffersForDifferentContent(t *testing.T) {
|
|
||||||
h1, err := evidence.HashMetadata(map[string]any{"a": 1})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("HashMetadata: %v", err)
|
|
||||||
}
|
|
||||||
h2, err := evidence.HashMetadata(map[string]any{"a": 2})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("HashMetadata: %v", err)
|
|
||||||
}
|
|
||||||
if bytes.Equal(h1, h2) {
|
|
||||||
t.Fatal("expected different hashes for different metadata content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHashMetadataRejectsUnmarshalableValue(t *testing.T) {
|
|
||||||
// Kanäle können nicht als JSON serialisiert werden — muss einen
|
|
||||||
// Fehler liefern statt still einen falschen Hash zurückzugeben.
|
|
||||||
if _, err := evidence.HashMetadata(make(chan int)); err == nil {
|
|
||||||
t.Fatal("expected error for unmarshalable value, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package evidence
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/digitorus/timestamp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TimestampTime liefert den im RFC-3161-Token bescheinigten Zeitpunkt,
|
|
||||||
// z. B. für die Anzeige im Nachweis-Dossier.
|
|
||||||
func TimestampTime(token []byte) (time.Time, error) {
|
|
||||||
ts, err := timestamp.Parse(token)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("evidence: timestamp token parse: %w", err)
|
|
||||||
}
|
|
||||||
return ts.Time, nil
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package evidence_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTimestampTimeRejectsGarbage(t *testing.T) {
|
|
||||||
if _, err := evidence.TimestampTime([]byte("not a timestamp token")); err == nil {
|
|
||||||
t.Fatal("expected error for garbage token bytes, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package evidence
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// Timestamper fragt einen RFC-3161-Zeitstempel für einen Hash (i. d. R.
|
|
||||||
// aus HashBytes/HashMetadata) bei einer Time-Stamp Authority (TSA) an
|
|
||||||
// und liefert den rohen Zeitstempel-Token zurück, wie er unverändert in
|
|
||||||
// evidence_package.timestamp_token gespeichert wird.
|
|
||||||
//
|
|
||||||
// Es gibt hier bewusst noch keine konkrete Implementierung: welche TSA
|
|
||||||
// verwendet wird, ist in CLAUDE.md als offener Punkt vermerkt (freie
|
|
||||||
// TSA vs. eIDAS-qualifizierter Zeitstempeldienst — das betrifft direkt
|
|
||||||
// die Beweiskraft des Archivs und ist keine rein technische
|
|
||||||
// Entscheidung). Sobald das geklärt ist, implementiert ein konkreter
|
|
||||||
// Typ dieses Interface.
|
|
||||||
type Timestamper interface {
|
|
||||||
Timestamp(ctx context.Context, hash []byte) (token []byte, err error)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package evidence
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto"
|
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/digitorus/timestamp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultTSAURL ist eine freie, RFC-3161-konforme Time-Stamp Authority.
|
|
||||||
//
|
|
||||||
// NICHT eIDAS-qualifiziert — reicht, um die Beweiskette technisch
|
|
||||||
// funktionsfähig zu haben, aber vor echtem Kundeneinsatz auf einen
|
|
||||||
// eIDAS-qualifizierten Zeitstempeldienst umstellen (z. B. D-Trust,
|
|
||||||
// Bundesdruckerei), der eine gesetzliche Vermutungswirkung nach
|
|
||||||
// eIDAS Art. 41 hat. Siehe CLAUDE.md, Offene Punkte.
|
|
||||||
const DefaultTSAURL = "https://freetsa.org/tsr"
|
|
||||||
|
|
||||||
// HTTPTimestamper implementiert Timestamper gegen eine RFC-3161-TSA
|
|
||||||
// über HTTP (application/timestamp-query, siehe RFC 3161 Abschnitt 3.4).
|
|
||||||
type HTTPTimestamper struct {
|
|
||||||
url string
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// TimestamperOption konfiguriert einen HTTPTimestamper.
|
|
||||||
type TimestamperOption func(*HTTPTimestamper)
|
|
||||||
|
|
||||||
// WithTimestamperHTTPClient überschreibt den verwendeten *http.Client
|
|
||||||
// (für Tests).
|
|
||||||
func WithTimestamperHTTPClient(hc *http.Client) TimestamperOption {
|
|
||||||
return func(t *HTTPTimestamper) { t.httpClient = hc }
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHTTPTimestamper erstellt einen Timestamper. Ein leerer url-Wert
|
|
||||||
// verwendet DefaultTSAURL.
|
|
||||||
func NewHTTPTimestamper(url string, opts ...TimestamperOption) *HTTPTimestamper {
|
|
||||||
if url == "" {
|
|
||||||
url = DefaultTSAURL
|
|
||||||
}
|
|
||||||
t := &HTTPTimestamper{url: url, httpClient: http.DefaultClient}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(t)
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timestamp fragt einen RFC-3161-Zeitstempel für hash (ein SHA-256-
|
|
||||||
// Digest) an und liefert den rohen TimeStampToken zurück. Die Antwort
|
|
||||||
// wird gegen den angefragten Hash und die gesendete Nonce geprüft,
|
|
||||||
// bevor der Token akzeptiert wird — eine TSA-Antwort, die nicht zum
|
|
||||||
// eigenen Request passt, ist kein gültiger Zeitstempel für diesen Hash.
|
|
||||||
func (t *HTTPTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) {
|
|
||||||
if len(hash) != crypto.SHA256.Size() {
|
|
||||||
return nil, fmt.Errorf("evidence: timestamp erwartet einen SHA-256-Digest (%d Bytes), bekam %d", crypto.SHA256.Size(), len(hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 64))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("evidence: nonce generieren: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := timestamp.Request{
|
|
||||||
HashAlgorithm: crypto.SHA256,
|
|
||||||
HashedMessage: hash,
|
|
||||||
Nonce: nonce,
|
|
||||||
Certificates: true,
|
|
||||||
}
|
|
||||||
reqBytes, err := req.Marshal()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("evidence: timestamp request marshal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(reqBytes))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("evidence: request bauen: %w", err)
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Content-Type", "application/timestamp-query")
|
|
||||||
|
|
||||||
resp, err := t.httpClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("evidence: TSA-Request fehlgeschlagen: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("evidence: TSA-Response lesen: %w", err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("evidence: TSA-Status %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err := timestamp.ParseResponse(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("evidence: TSA-Response parse: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ts.HashAlgorithm != crypto.SHA256 || !bytes.Equal(ts.HashedMessage, hash) {
|
|
||||||
return nil, fmt.Errorf("evidence: TSA-Antwort passt nicht zum angefragten Hash")
|
|
||||||
}
|
|
||||||
if ts.Nonce == nil || ts.Nonce.Cmp(nonce) != 0 {
|
|
||||||
return nil, fmt.Errorf("evidence: TSA-Antwort hat falsche oder fehlende Nonce")
|
|
||||||
}
|
|
||||||
|
|
||||||
return ts.RawToken, nil
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package evidence_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/digitorus/timestamp"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTimestampRejectsWrongHashSize(t *testing.T) {
|
|
||||||
called := false
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
called = true
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
ts := evidence.NewHTTPTimestamper(server.URL)
|
|
||||||
_, err := ts.Timestamp(context.Background(), []byte("zu kurz"))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for a non-SHA-256-sized hash, got nil")
|
|
||||||
}
|
|
||||||
if called {
|
|
||||||
t.Fatal("expected no HTTP call for an invalid hash size")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTimestampSendsValidRequest prüft serverseitig, dass Timestamp()
|
|
||||||
// einen tatsächlich gültigen, RFC-3161-konformen Request schickt (parsbar,
|
|
||||||
// korrekter Hash, korrekter Algorithmus, Nonce gesetzt) — ohne dafür eine
|
|
||||||
// echte signierte Antwort fälschen zu müssen.
|
|
||||||
func TestTimestampSendsValidRequest(t *testing.T) {
|
|
||||||
hash := evidence.HashBytes([]byte("test-content"))
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if ct := r.Header.Get("Content-Type"); ct != "application/timestamp-query" {
|
|
||||||
t.Errorf("Content-Type = %q, want application/timestamp-query", ct)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("read request body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := timestamp.ParseRequest(body)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseRequest: %v", err)
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !bytes.Equal(req.HashedMessage, hash) {
|
|
||||||
t.Errorf("HashedMessage = %x, want %x", req.HashedMessage, hash)
|
|
||||||
}
|
|
||||||
if req.Nonce == nil {
|
|
||||||
t.Error("expected a nonce to be set on the request")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keine echte TSA hier — nur die Request-Validierung interessiert
|
|
||||||
// dieser Test. Ein absichtlich ungültiger Response-Body lässt
|
|
||||||
// Timestamp() mit einem Parse-Fehler zurückkommen, was für diesen
|
|
||||||
// Test in Ordnung ist.
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("not a valid timestamp response"))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
ts := evidence.NewHTTPTimestamper(server.URL)
|
|
||||||
_, err := ts.Timestamp(context.Background(), hash)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected a parse error for the fake response, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTimestampRejectsNon200(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("tsa down"))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
ts := evidence.NewHTTPTimestamper(server.URL)
|
|
||||||
hash := evidence.HashBytes([]byte("x"))
|
|
||||||
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
|
|
||||||
t.Fatal("expected error for non-200 TSA status, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTimestampRejectsMalformedResponse(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("garbage, not ASN.1 DER"))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
ts := evidence.NewHTTPTimestamper(server.URL)
|
|
||||||
hash := evidence.HashBytes([]byte("x"))
|
|
||||||
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
|
|
||||||
t.Fatal("expected error for malformed TSA response, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTimestampIntegration ruft die echte Standard-TSA (FreeTSA.org) auf.
|
|
||||||
// Läuft nur, wenn DEKLARIX_TSA_INTEGRATION gesetzt ist — kein Netzwerkzugriff
|
|
||||||
// in normalen Testläufen (siehe internal/store für dasselbe Muster mit
|
|
||||||
// DATABASE_URL).
|
|
||||||
func TestTimestampIntegration(t *testing.T) {
|
|
||||||
if os.Getenv("DEKLARIX_TSA_INTEGRATION") == "" {
|
|
||||||
t.Skip("DEKLARIX_TSA_INTEGRATION nicht gesetzt, überspringe echten TSA-Aufruf")
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := evidence.HashBytes([]byte(t.Name()))
|
|
||||||
ts := evidence.NewHTTPTimestamper("")
|
|
||||||
|
|
||||||
token, err := ts.Timestamp(context.Background(), hash)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Timestamp: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := timestamp.Parse(token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Parse(token): %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(parsed.HashedMessage, hash) {
|
|
||||||
t.Fatalf("token hash = %x, want %x", parsed.HashedMessage, hash)
|
|
||||||
}
|
|
||||||
if parsed.Time.IsZero() {
|
|
||||||
t.Fatal("expected a non-zero timestamp time")
|
|
||||||
}
|
|
||||||
|
|
||||||
tm, err := evidence.TimestampTime(token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TimestampTime: %v", err)
|
|
||||||
}
|
|
||||||
if !tm.Equal(parsed.Time) {
|
|
||||||
t.Fatalf("TimestampTime() = %v, want %v", tm, parsed.Time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
// Package extract ist Stufe 1 aus dem Kernprinzip: es bestimmt
|
|
||||||
// ausschließlich beobachtbare Fakten zu einem Beitrag. Es bewertet
|
|
||||||
// nichts — das Urteil fällt ausschließlich das Regelwerk in
|
|
||||||
// internal/rules.
|
|
||||||
//
|
|
||||||
// Die Gegenleistung (Consideration) wird NICHT aus dem Text geraten —
|
|
||||||
// aus reinem Keyword-Matching lässt sich eine verschwiegene
|
|
||||||
// Zusammenarbeit nicht von einem echten organischen Post unterscheiden
|
|
||||||
// (beide sehen textlich identisch aus). Nur wer den Beitrag einreicht,
|
|
||||||
// weiß, ob eine Gegenleistung vorlag, darum kommt dieser Wert vom
|
|
||||||
// Aufrufer (siehe Input.Consideration). Was diese Stufe zuverlässig
|
|
||||||
// automatisieren kann, ist reine Zeichenketten-Logik: steht ein
|
|
||||||
// Kennzeichnungswort in der Caption, und steht es vor der
|
|
||||||
// "mehr anzeigen"-Kürzung der Plattform.
|
|
||||||
package extract
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EngineVersion wird zusammen mit jeder Extraktion gespeichert (siehe
|
|
||||||
// Datenmodell: extraction.model_version) — hochzählen bei jeder
|
|
||||||
// inhaltlichen Änderung an der Erkennungslogik unten.
|
|
||||||
const EngineVersion = "regelbasiert-v1"
|
|
||||||
|
|
||||||
// PromptVersion wird ebenfalls gespeichert (extraction.prompt_version).
|
|
||||||
// Es gibt kein Sprachmodell und keinen Prompt mehr, aber die Spalte
|
|
||||||
// bleibt (keine neue Migration nur für einen Namenswechsel) — der Wert
|
|
||||||
// markiert weiterhin den Stand der Extraktionslogik.
|
|
||||||
const PromptVersion = "v1"
|
|
||||||
|
|
||||||
// disclosureKeywords sind Kennzeichnungshinweise, nach denen in der
|
|
||||||
// Caption gesucht wird — bewusst großzügig (auch rechtlich unzureichende
|
|
||||||
// wie "#ad", siehe rules/OPEN.md/Recherche zu OLG Celle/Kammergericht
|
|
||||||
// Berlin): ob ein Wortlaut rechtlich ausreicht, entscheidet das
|
|
||||||
// Regelwerk anhand von DisclosureWording, nicht diese Erkennung.
|
|
||||||
var disclosureKeywords = []string{
|
|
||||||
"werbung", "anzeige", "bezahlte partnerschaft", "paid partnership",
|
|
||||||
"#ad", "#werbung", "#anzeige", "#sponsored", "#sponsoredby", "#sponsoredpost",
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncationThresholds sind ungefähre Zeichen-Schwellen, ab denen
|
|
||||||
// Instagram/TikTok eine Caption in der Zeitleiste hinter "... mehr" /
|
|
||||||
// "mehr anzeigen" kürzen. Plattformen ändern das ohne Ankündigung — vor
|
|
||||||
// echtem Kundeneinsatz stichprobenartig nachprüfen, ob die Werte noch
|
|
||||||
// stimmen (siehe CLAUDE.md, Offene Punkte).
|
|
||||||
var truncationThresholds = map[string]int{
|
|
||||||
"instagram": 125,
|
|
||||||
"tiktok": 150,
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultTruncationThreshold = 125
|
|
||||||
|
|
||||||
// Input ist, was Stufe 1 braucht. Platform, Jurisdiction UND
|
|
||||||
// Consideration kommen alle vom Aufrufer — keines davon lässt sich aus
|
|
||||||
// der Caption zuverlässig ableiten oder ist Sache dieser Stufe.
|
|
||||||
type Input struct {
|
|
||||||
Platform string
|
|
||||||
Jurisdiction string
|
|
||||||
Consideration string // "bezahlt" | "sachbezug" | "keine" | "unklar"
|
|
||||||
Caption string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result ist die Ausgabe von Extract: die für das Regelwerk
|
|
||||||
// aufbereiteten Facts, plus RawJSON — die vollständige, unveränderte
|
|
||||||
// Aufzeichnung dessen, was diese Stufe bestimmt hat. RawJSON gehört
|
|
||||||
// unverändert in extraction.payload (siehe Datenmodell); Facts ist eine
|
|
||||||
// abgeleitete Sicht darauf und nicht der Beweis-Eintrag selbst.
|
|
||||||
type Result struct {
|
|
||||||
Facts rules.Facts
|
|
||||||
RawJSON []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// Engine bestimmt die Facts für einen Beitrag über deterministische
|
|
||||||
// Zeichenketten-Logik — kein externer Dienst, keine Netzwerk-Abhängigkeit.
|
|
||||||
type Engine struct{}
|
|
||||||
|
|
||||||
// NewEngine erstellt eine Engine.
|
|
||||||
func NewEngine() *Engine {
|
|
||||||
return &Engine{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelVersion liefert EngineVersion (siehe Datenmodell: extraction.model_version).
|
|
||||||
func (e *Engine) ModelVersion() string { return EngineVersion }
|
|
||||||
|
|
||||||
// Extract bestimmt die Facts für in. Die Gegenleistung wird validiert,
|
|
||||||
// nicht erraten — ein leerer oder ungültiger Wert ist ein Fehler, keine
|
|
||||||
// Lücke, die stillschweigend als "keine" interpretiert wird (das wäre
|
|
||||||
// hier besonders gefährlich: es würde unentdeckte Schleichwerbung
|
|
||||||
// systematisch als unauffällig durchwinken).
|
|
||||||
func (e *Engine) Extract(ctx context.Context, in Input) (Result, error) {
|
|
||||||
if in.Caption == "" {
|
|
||||||
return Result{}, fmt.Errorf("extract: caption ist leer")
|
|
||||||
}
|
|
||||||
consideration, err := parseConsideration(in.Consideration)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, fmt.Errorf("extract: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
present, wording, index := detectDisclosure(in.Caption)
|
|
||||||
beforeCut := present && index < truncationThreshold(in.Platform)
|
|
||||||
|
|
||||||
args := extractionArgs{
|
|
||||||
Consideration: string(consideration),
|
|
||||||
DisclosurePresent: present,
|
|
||||||
DisclosureWording: wording,
|
|
||||||
DisclosureBeforeCut: beforeCut,
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(args)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, fmt.Errorf("extract: payload marshal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result{
|
|
||||||
Facts: rules.Facts{
|
|
||||||
Platform: in.Platform,
|
|
||||||
Jurisdiction: in.Jurisdiction,
|
|
||||||
Consideration: consideration,
|
|
||||||
DisclosurePresent: present,
|
|
||||||
DisclosureWording: wording,
|
|
||||||
DisclosureBeforeCut: beforeCut,
|
|
||||||
},
|
|
||||||
RawJSON: raw,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectDisclosure sucht die am frühesten in caption vorkommende
|
|
||||||
// Kennzeichnung aus disclosureKeywords (case-insensitive) und liefert
|
|
||||||
// deren Wortlaut in Original-Schreibweise plus Byte-Index.
|
|
||||||
func detectDisclosure(caption string) (present bool, wording string, index int) {
|
|
||||||
lower := strings.ToLower(caption)
|
|
||||||
bestIdx := -1
|
|
||||||
bestLen := 0
|
|
||||||
for _, kw := range disclosureKeywords {
|
|
||||||
if idx := strings.Index(lower, kw); idx != -1 && (bestIdx == -1 || idx < bestIdx) {
|
|
||||||
bestIdx = idx
|
|
||||||
bestLen = len(kw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bestIdx == -1 {
|
|
||||||
return false, "", -1
|
|
||||||
}
|
|
||||||
return true, caption[bestIdx : bestIdx+bestLen], bestIdx
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncationThreshold(platform string) int {
|
|
||||||
if t, ok := truncationThresholds[platform]; ok {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
return defaultTruncationThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsePayload rekonstruiert Facts aus einem zuvor gespeicherten
|
|
||||||
// RawJSON-Payload (z. B. aus extraction.payload). platform/jurisdiction
|
|
||||||
// müssen erneut mitgegeben werden — sie sind, wie bei Extract, nie Teil
|
|
||||||
// des gespeicherten Payloads.
|
|
||||||
func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) {
|
|
||||||
var args extractionArgs
|
|
||||||
if err := json.Unmarshal(payload, &args); err != nil {
|
|
||||||
return rules.Facts{}, fmt.Errorf("extract: payload parse: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
consideration, err := parseConsideration(args.Consideration)
|
|
||||||
if err != nil {
|
|
||||||
return rules.Facts{}, fmt.Errorf("extract: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rules.Facts{
|
|
||||||
Platform: platform,
|
|
||||||
Jurisdiction: jurisdiction,
|
|
||||||
Consideration: consideration,
|
|
||||||
DisclosurePresent: args.DisclosurePresent,
|
|
||||||
DisclosureWording: args.DisclosureWording,
|
|
||||||
DisclosureBeforeCut: args.DisclosureBeforeCut,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractionArgs ist die gespeicherte Form eines Extraktionsergebnisses.
|
|
||||||
type extractionArgs struct {
|
|
||||||
Consideration string `json:"gegenleistung"`
|
|
||||||
DisclosurePresent bool `json:"kennzeichnung_vorhanden"`
|
|
||||||
DisclosureWording string `json:"kennzeichnung_wortlaut"`
|
|
||||||
DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseConsideration(s string) (rules.Consideration, error) {
|
|
||||||
switch rules.Consideration(s) {
|
|
||||||
case rules.ConsiderationPaid, rules.ConsiderationInKind, rules.ConsiderationNone, rules.ConsiderationUnclear:
|
|
||||||
return rules.Consideration(s), nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("ungültiger gegenleistung-Wert %q — muss vom Einreichenden angegeben werden", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
package extract_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExtractRejectsEmptyCaption(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
if _, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Consideration: "bezahlt"}); err == nil {
|
|
||||||
t.Fatal("expected error for empty caption, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRejectsMissingConsideration(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
if _, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "irgendein Text"}); err == nil {
|
|
||||||
t.Fatal("expected error for missing consideration, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRejectsInvalidConsideration(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
_, err := e.Extract(context.Background(), extract.Input{
|
|
||||||
Platform: "instagram", Caption: "Text", Consideration: "vielleicht",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for an out-of-enum consideration value, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDetectsDisclosureKeyword(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
result, err := e.Extract(context.Background(), extract.Input{
|
|
||||||
Platform: "instagram", Consideration: "bezahlt",
|
|
||||||
Caption: "Werbung: Schaut euch dieses tolle Produkt an!",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract: %v", err)
|
|
||||||
}
|
|
||||||
if !result.Facts.DisclosurePresent {
|
|
||||||
t.Error("expected DisclosurePresent = true")
|
|
||||||
}
|
|
||||||
if result.Facts.DisclosureWording != "Werbung" {
|
|
||||||
t.Errorf("DisclosureWording = %q, want Werbung (original casing preserved)", result.Facts.DisclosureWording)
|
|
||||||
}
|
|
||||||
if !result.Facts.DisclosureBeforeCut {
|
|
||||||
t.Error("expected DisclosureBeforeCut = true (Werbung is at index 0)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractIsCaseInsensitive(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
result, err := e.Extract(context.Background(), extract.Input{
|
|
||||||
Platform: "instagram", Consideration: "bezahlt", Caption: "WERBUNG fuer ein Produkt",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract: %v", err)
|
|
||||||
}
|
|
||||||
if !result.Facts.DisclosurePresent {
|
|
||||||
t.Fatal("expected case-insensitive match to find the keyword")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDetectsDisclosureAfterTruncationCut(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
// 130 Fuellzeichen vor "Werbung" -> jenseits der Instagram-Schwelle (125).
|
|
||||||
padding := strings.Repeat("x", 130)
|
|
||||||
result, err := e.Extract(context.Background(), extract.Input{
|
|
||||||
Platform: "instagram", Consideration: "bezahlt", Caption: padding + " Werbung",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract: %v", err)
|
|
||||||
}
|
|
||||||
if !result.Facts.DisclosurePresent {
|
|
||||||
t.Fatal("expected the keyword to still be found even though it's late in the caption")
|
|
||||||
}
|
|
||||||
if result.Facts.DisclosureBeforeCut {
|
|
||||||
t.Error("expected DisclosureBeforeCut = false when the keyword appears after the platform's truncation threshold")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractNoDisclosureFound(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
result, err := e.Extract(context.Background(), extract.Input{
|
|
||||||
Platform: "instagram", Consideration: "keine", Caption: "Ein ganz normaler Tag im Park.",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract: %v", err)
|
|
||||||
}
|
|
||||||
if result.Facts.DisclosurePresent {
|
|
||||||
t.Error("expected DisclosurePresent = false when no keyword is present")
|
|
||||||
}
|
|
||||||
if result.Facts.DisclosureBeforeCut {
|
|
||||||
t.Error("expected DisclosureBeforeCut = false when there is no disclosure at all")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractPassesThroughUnclearConsideration(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
result, err := e.Extract(context.Background(), extract.Input{
|
|
||||||
Platform: "instagram", Consideration: "unklar", Caption: "Text ohne klare Angabe",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract: %v", err)
|
|
||||||
}
|
|
||||||
if result.Facts.Consideration != rules.ConsiderationUnclear {
|
|
||||||
t.Fatalf("Consideration = %q, want unklar", result.Facts.Consideration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractDifferentPlatformThresholds(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
// 140 Fuellzeichen: unter der TikTok-Schwelle (150), aber ueber der
|
|
||||||
// Instagram-Schwelle (125) -> gleiche Caption, unterschiedliches Ergebnis.
|
|
||||||
padding := strings.Repeat("x", 140)
|
|
||||||
caption := padding + " Werbung"
|
|
||||||
|
|
||||||
igResult, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Consideration: "bezahlt", Caption: caption})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract (instagram): %v", err)
|
|
||||||
}
|
|
||||||
ttResult, err := e.Extract(context.Background(), extract.Input{Platform: "tiktok", Consideration: "bezahlt", Caption: caption})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract (tiktok): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if igResult.Facts.DisclosureBeforeCut {
|
|
||||||
t.Error("expected DisclosureBeforeCut = false for instagram at this length")
|
|
||||||
}
|
|
||||||
if !ttResult.Facts.DisclosureBeforeCut {
|
|
||||||
t.Error("expected DisclosureBeforeCut = true for tiktok at this length")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRawJSONRoundTripsThroughParsePayload(t *testing.T) {
|
|
||||||
e := extract.NewEngine()
|
|
||||||
result, err := e.Extract(context.Background(), extract.Input{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: "sachbezug",
|
|
||||||
Caption: "Anzeige: dieses Produkt wurde mir geschenkt",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Extract: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
facts, err := extract.ParsePayload(result.RawJSON, "instagram", "DE")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParsePayload: %v", err)
|
|
||||||
}
|
|
||||||
if facts != result.Facts {
|
|
||||||
t.Fatalf("ParsePayload(Extract().RawJSON) = %+v, want %+v", facts, result.Facts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParsePayloadRejectsInvalidConsideration(t *testing.T) {
|
|
||||||
payload := []byte(`{"gegenleistung":"vielleicht","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)
|
|
||||||
if _, err := extract.ParsePayload(payload, "tiktok", "DE"); err == nil {
|
|
||||||
t.Fatal("expected error for out-of-enum stored payload, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package rules
|
|
||||||
|
|
||||||
// Finding ist das Ergebnis einer einzelnen zutreffenden Regel.
|
|
||||||
type Finding struct {
|
|
||||||
RuleID string
|
|
||||||
RuleVersion int
|
|
||||||
Severity Severity
|
|
||||||
Title string
|
|
||||||
Fix string
|
|
||||||
Sources []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede
|
|
||||||
// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules.
|
|
||||||
//
|
|
||||||
// Ist f.Consideration "unklar", wird KEINE Bewertung abgegeben —
|
|
||||||
// needsClarification ist dann true und findings ist immer leer. Das ist
|
|
||||||
// Absicht (Kernprinzip): eine unsichere Extraktion erzeugt eine
|
|
||||||
// Rückfrage an den Nutzer, niemals eine stille "keine Findings"-
|
|
||||||
// Bewertung, die wie "alles in Ordnung" aussähe.
|
|
||||||
func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification bool) {
|
|
||||||
if f.Consideration == ConsiderationUnclear {
|
|
||||||
return nil, true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range rules {
|
|
||||||
if r.Jurisdiction != f.Jurisdiction {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r.Condition.Matches(f) {
|
|
||||||
findings = append(findings, Finding{
|
|
||||||
RuleID: r.ID,
|
|
||||||
RuleVersion: r.Version,
|
|
||||||
Severity: r.Severity,
|
|
||||||
Title: r.Title,
|
|
||||||
Fix: r.Fix,
|
|
||||||
Sources: r.Sources,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return findings, false
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package rules
|
|
||||||
|
|
||||||
// Consideration ist die Gegenleistung für einen Beitrag, wie sie die
|
|
||||||
// Extraktion (Stufe 1) liefert.
|
|
||||||
type Consideration string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ConsiderationPaid Consideration = "bezahlt"
|
|
||||||
ConsiderationInKind Consideration = "sachbezug"
|
|
||||||
ConsiderationNone Consideration = "keine"
|
|
||||||
ConsiderationUnclear Consideration = "unklar"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Facts sind die Fakten aus der Extraktion (Stufe 1), auf denen die
|
|
||||||
// Regelauswertung (Stufe 2) urteilt. Die Extraktion liefert diese Werte,
|
|
||||||
// sie bewertet sie nicht — das Urteil fällt ausschließlich das Regelwerk.
|
|
||||||
//
|
|
||||||
// Jurisdiction ist keine vom Modell extrahierte Tatsache (aus Caption/
|
|
||||||
// Bild lässt sich keine Rechtsordnung ablesen), sondern kommt vom
|
|
||||||
// Aufrufer — analog zu Platform. Aktuell ist "DE" der einzig unterstützte
|
|
||||||
// Wert; siehe Rule.Jurisdiction.
|
|
||||||
type Facts struct {
|
|
||||||
Platform string `json:"platform"`
|
|
||||||
Jurisdiction string `json:"jurisdiction"`
|
|
||||||
Consideration Consideration `json:"consideration"`
|
|
||||||
DisclosurePresent bool `json:"disclosure_present"`
|
|
||||||
DisclosureWording string `json:"disclosure_wording"`
|
|
||||||
DisclosureBeforeCut bool `json:"disclosure_before_cut"`
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package rules_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
// goldenCase spiegelt eine Datei aus testdata/golden/: die extrahierten
|
|
||||||
// Fakten eines Beispielbeitrags plus die Findings, die das Regelwerk
|
|
||||||
// dafür liefern muss. Das ist das eigentliche Asset des Projekts, nicht
|
|
||||||
// die UI — siehe CLAUDE.md.
|
|
||||||
type goldenCase struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Facts rules.Facts `json:"facts"`
|
|
||||||
ExpectedFindings []goldenFinding `json:"expected_findings"`
|
|
||||||
ExpectNeedsClarification bool `json:"expect_needs_clarification"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type goldenFinding struct {
|
|
||||||
RuleID string `json:"rule_id"`
|
|
||||||
RuleVersion int `json:"rule_version"`
|
|
||||||
Severity string `json:"severity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
rulesDir = "../../rules"
|
|
||||||
goldenDir = "../../testdata/golden"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGolden(t *testing.T) {
|
|
||||||
ruleSet, err := rules.Load(os.DirFS(rulesDir))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load rules: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(goldenDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read golden dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := 0
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
found++
|
|
||||||
entry := entry
|
|
||||||
t.Run(entry.Name(), func(t *testing.T) {
|
|
||||||
data, err := os.ReadFile(filepath.Join(goldenDir, entry.Name()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read %s: %v", entry.Name(), err)
|
|
||||||
}
|
|
||||||
var gc goldenCase
|
|
||||||
if err := json.Unmarshal(data, &gc); err != nil {
|
|
||||||
t.Fatalf("parse %s: %v", entry.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, needsClarification := rules.Evaluate(ruleSet, gc.Facts)
|
|
||||||
|
|
||||||
if needsClarification != gc.ExpectNeedsClarification {
|
|
||||||
t.Fatalf("%s: needsClarification = %v, want %v", gc.Name, needsClarification, gc.ExpectNeedsClarification)
|
|
||||||
}
|
|
||||||
|
|
||||||
gotKeys := make([]string, 0, len(got))
|
|
||||||
for _, f := range got {
|
|
||||||
gotKeys = append(gotKeys, findingKey(f.RuleID, f.RuleVersion, string(f.Severity)))
|
|
||||||
}
|
|
||||||
wantKeys := make([]string, 0, len(gc.ExpectedFindings))
|
|
||||||
for _, ef := range gc.ExpectedFindings {
|
|
||||||
wantKeys = append(wantKeys, findingKey(ef.RuleID, ef.RuleVersion, ef.Severity))
|
|
||||||
}
|
|
||||||
sort.Strings(gotKeys)
|
|
||||||
sort.Strings(wantKeys)
|
|
||||||
|
|
||||||
if !equalStrings(gotKeys, wantKeys) {
|
|
||||||
t.Fatalf("%s: got findings %v, want %v", gc.Name, gotKeys, wantKeys)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if found == 0 {
|
|
||||||
t.Fatal("no golden cases found in " + goldenDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findingKey(ruleID string, version int, severity string) string {
|
|
||||||
return ruleID + "/" + strconv.Itoa(version) + "/" + severity
|
|
||||||
}
|
|
||||||
|
|
||||||
func equalStrings(a, b []string) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for i := range a {
|
|
||||||
if a[i] != b[i] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -3,50 +3,109 @@ package rules
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load liest alle *.yaml-Dateien aus fsys (nicht rekursiv) und parst sie
|
// LoadDatenklasse lädt und validiert die Datenklasse-Ableitungstabelle.
|
||||||
// als Regeln. Ein Fehler in einer Datei (Parse-Fehler, fehlende ID/
|
func LoadDatenklasse(fsys fs.FS, path string) (DatenklasseRegelwerk, error) {
|
||||||
// Version) bricht das Laden komplett ab, statt die Datei stillschweigend
|
var rw DatenklasseRegelwerk
|
||||||
// zu überspringen — ein halb geladenes Regelwerk ist gefährlicher als
|
data, err := fs.ReadFile(fsys, path)
|
||||||
// ein Start, der mit einem klaren Fehler abbricht.
|
|
||||||
func Load(fsys fs.FS) ([]Rule, error) {
|
|
||||||
entries, err := fs.ReadDir(fsys, ".")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("rules: read dir: %w", err)
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse lesen: %w", err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
|
||||||
|
}
|
||||||
|
if rw.Version < 1 {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: version fehlt oder ungültig")
|
||||||
|
}
|
||||||
|
if len(rw.Stufen) == 0 {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: keine stufen definiert")
|
||||||
|
}
|
||||||
|
seenIDs := map[string]bool{}
|
||||||
|
seenRaenge := map[int]string{}
|
||||||
|
for _, st := range rw.Stufen {
|
||||||
|
if st.ID == "" {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe ohne id")
|
||||||
|
}
|
||||||
|
if seenIDs[st.ID] {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: doppelte stufe-id %q", st.ID)
|
||||||
|
}
|
||||||
|
seenIDs[st.ID] = true
|
||||||
|
if other, ok := seenRaenge[st.Rang]; ok {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufen %q und %q teilen sich rang %d — \"höchste Stufe gewinnt\" braucht eindeutige Ränge", st.ID, other, st.Rang)
|
||||||
|
}
|
||||||
|
seenRaenge[st.Rang] = st.ID
|
||||||
|
if len(st.Ausloeser) == 0 {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe %q hat keine ausloeser", st.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
// LoadEinstufung lädt und validiert die KI-VO-Einstufungstabelle.
|
||||||
var result []Rule
|
func LoadEinstufung(fsys fs.FS, path string) (EinstufungRegelwerk, error) {
|
||||||
for _, entry := range entries {
|
var rw EinstufungRegelwerk
|
||||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
data, err := fs.ReadFile(fsys, path)
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := fs.ReadFile(fsys, entry.Name())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("rules: read %s: %w", entry.Name(), err)
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung lesen: %w", err)
|
||||||
}
|
}
|
||||||
var r Rule
|
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||||
if err := yaml.Unmarshal(data, &r); err != nil {
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung parsen: %w", err)
|
||||||
return nil, fmt.Errorf("rules: parse %s: %w", entry.Name(), err)
|
|
||||||
}
|
}
|
||||||
if r.ID == "" {
|
if rw.Version < 1 {
|
||||||
return nil, fmt.Errorf("rules: %s: missing id", entry.Name())
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: version fehlt oder ungültig")
|
||||||
}
|
}
|
||||||
if r.Version == 0 {
|
if len(rw.Stufen) == 0 {
|
||||||
return nil, fmt.Errorf("rules: %s: missing version", entry.Name())
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: keine stufen definiert")
|
||||||
}
|
}
|
||||||
if r.Jurisdiction == "" {
|
seenIDs := map[string]bool{}
|
||||||
return nil, fmt.Errorf("rules: %s: missing land (jurisdiction)", entry.Name())
|
for i, st := range rw.Stufen {
|
||||||
|
if st.ID == "" {
|
||||||
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe ohne id")
|
||||||
}
|
}
|
||||||
if seen[r.ID] {
|
if seenIDs[st.ID] {
|
||||||
return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), r.ID)
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: doppelte stufe-id %q", st.ID)
|
||||||
}
|
}
|
||||||
seen[r.ID] = true
|
seenIDs[st.ID] = true
|
||||||
result = append(result, r)
|
// Die letzte Stufe darf eine Auffangregel ohne Bedingungen sein
|
||||||
|
// (z. B. "minimal"), alle davor brauchen mindestens eine Variante.
|
||||||
|
if len(st.Varianten) == 0 && i != len(rw.Stufen)-1 {
|
||||||
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe %q hat keine varianten (nur die letzte Stufe darf eine Auffangregel sein)", st.ID)
|
||||||
}
|
}
|
||||||
return result, nil
|
}
|
||||||
|
return rw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAnforderungen lädt und validiert die Anforderungsprofil-Tabelle.
|
||||||
|
func LoadAnforderungen(fsys fs.FS, path string) (AnforderungsRegelwerk, error) {
|
||||||
|
var rw AnforderungsRegelwerk
|
||||||
|
data, err := fs.ReadFile(fsys, path)
|
||||||
|
if err != nil {
|
||||||
|
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen lesen: %w", err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||||
|
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen parsen: %w", err)
|
||||||
|
}
|
||||||
|
if rw.Version < 1 {
|
||||||
|
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: version fehlt oder ungültig")
|
||||||
|
}
|
||||||
|
if len(rw.Anforderungen) == 0 {
|
||||||
|
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: keine anforderungen definiert")
|
||||||
|
}
|
||||||
|
seenIDs := map[string]bool{}
|
||||||
|
for _, a := range rw.Anforderungen {
|
||||||
|
if a.ID == "" {
|
||||||
|
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung ohne id")
|
||||||
|
}
|
||||||
|
if seenIDs[a.ID] {
|
||||||
|
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: doppelte anforderung-id %q", a.ID)
|
||||||
|
}
|
||||||
|
seenIDs[a.ID] = true
|
||||||
|
if len(a.AusDatenklassen) == 0 && len(a.AusEinstufungen) == 0 {
|
||||||
|
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung %q hat weder aus_datenklassen noch aus_einstufungen", a.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rw, nil
|
||||||
}
|
}
|
||||||
|
|||||||
132
internal/rules/loader_test.go
Normal file
132
internal/rules/loader_test.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package rules_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
func realRulesFS(t *testing.T) fs.FS {
|
||||||
|
t.Helper()
|
||||||
|
return os.DirFS("../../rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDatenklasseRealFile(t *testing.T) {
|
||||||
|
rw, err := rules.LoadDatenklasse(realRulesFS(t), "datenklasse.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDatenklasse: %v", err)
|
||||||
|
}
|
||||||
|
if rw.Version != 1 {
|
||||||
|
t.Fatalf("Version = %d, want 1", rw.Version)
|
||||||
|
}
|
||||||
|
if len(rw.Stufen) != 6 {
|
||||||
|
t.Fatalf("expected 6 Stufen, got %d", len(rw.Stufen))
|
||||||
|
}
|
||||||
|
byID := map[string]rules.DatenklasseStufe{}
|
||||||
|
for _, st := range rw.Stufen {
|
||||||
|
byID[st.ID] = st
|
||||||
|
}
|
||||||
|
for _, id := range []string{"oeffentlich", "intern", "auftragsdaten", "personenbezogen", "berufsgeheimnis", "besondere_kategorie"} {
|
||||||
|
if _, ok := byID[id]; !ok {
|
||||||
|
t.Errorf("expected Stufe %q to be defined", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if byID["besondere_kategorie"].Rang <= byID["personenbezogen"].Rang {
|
||||||
|
t.Error("expected besondere_kategorie to outrank personenbezogen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDatenklasseRejectsDuplicateRang(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
version: 1
|
||||||
|
stufen:
|
||||||
|
- {id: a, rang: 1, ausloeser: [b1]}
|
||||||
|
- {id: b, rang: 1, ausloeser: [b2]}
|
||||||
|
`)},
|
||||||
|
}
|
||||||
|
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
|
||||||
|
t.Fatal("expected an error for duplicate rang values")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDatenklasseRejectsMissingVersion(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
stufen:
|
||||||
|
- {id: a, rang: 1, ausloeser: [b1]}
|
||||||
|
`)},
|
||||||
|
}
|
||||||
|
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
|
||||||
|
t.Fatal("expected an error for a missing version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEinstufungRealFile(t *testing.T) {
|
||||||
|
rw, err := rules.LoadEinstufung(realRulesFS(t), "kivo_einstufung.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if len(rw.Stufen) != 4 {
|
||||||
|
t.Fatalf("expected 4 Stufen, got %d", len(rw.Stufen))
|
||||||
|
}
|
||||||
|
if rw.Stufen[0].ID != "verboten" {
|
||||||
|
t.Fatalf("expected 'verboten' to be checked first (K.-o.-Prüfung), got %q", rw.Stufen[0].ID)
|
||||||
|
}
|
||||||
|
if rw.Stufen[len(rw.Stufen)-1].ID != "minimal" {
|
||||||
|
t.Fatalf("expected 'minimal' as the last (catch-all) Stufe, got %q", rw.Stufen[len(rw.Stufen)-1].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEinstufungRejectsNonLastStufeWithoutVarianten(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"kivo_einstufung.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
version: 1
|
||||||
|
stufen:
|
||||||
|
- {id: verboten, varianten: []}
|
||||||
|
- {id: minimal, varianten: []}
|
||||||
|
`)},
|
||||||
|
}
|
||||||
|
if _, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml"); err == nil {
|
||||||
|
t.Fatal("expected an error when a non-last Stufe has no Varianten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnforderungenRealFile(t *testing.T) {
|
||||||
|
rw, err := rules.LoadAnforderungen(realRulesFS(t), "anforderungen.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnforderungen: %v", err)
|
||||||
|
}
|
||||||
|
wantIDs := []string{
|
||||||
|
"avv_erforderlich", "eu_verarbeitung", "kein_training_auf_eingabe",
|
||||||
|
"loeschfrist_max_tage", "menschliche_aufsicht", "kennzeichnungspflicht", "dsfa_erforderlich",
|
||||||
|
}
|
||||||
|
if len(rw.Anforderungen) != len(wantIDs) {
|
||||||
|
t.Fatalf("expected %d Anforderungen, got %d", len(wantIDs), len(rw.Anforderungen))
|
||||||
|
}
|
||||||
|
byID := map[string]bool{}
|
||||||
|
for _, a := range rw.Anforderungen {
|
||||||
|
byID[a.ID] = true
|
||||||
|
}
|
||||||
|
for _, id := range wantIDs {
|
||||||
|
if !byID[id] {
|
||||||
|
t.Errorf("expected Anforderung %q to be defined", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnforderungenRejectsAnforderungWithoutTrigger(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"anforderungen.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
version: 1
|
||||||
|
anforderungen:
|
||||||
|
- {id: x}
|
||||||
|
`)},
|
||||||
|
}
|
||||||
|
if _, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml"); err == nil {
|
||||||
|
t.Fatal("expected an error for an Anforderung without any trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
68
internal/rules/regelwerk.go
Normal file
68
internal/rules/regelwerk.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Package rules lädt das Bewertungsregelwerk (Datenklasse-Ableitung,
|
||||||
|
// KI-VO-Einstufung, Anforderungsprofil) aus versionierten YAML-Dateien.
|
||||||
|
// Regelbasiert, nicht ML — die Regeln liegen als Daten vor, nicht im
|
||||||
|
// Code (siehe CLAUDE.md). Dieses Paket lädt und validiert nur die
|
||||||
|
// Struktur; die eigentliche Auswertung gegen Fragebogen-Antworten ist
|
||||||
|
// bewusst noch nicht Teil von Phase 1 (siehe rules/OPEN.md und die
|
||||||
|
// Baureihenfolge in CLAUDE.md — "Ableitungen und harte Filter" ist ein
|
||||||
|
// eigener, späterer Schritt, sobald der Fragebogen aus Phase 2 die
|
||||||
|
// exakten Fakten-Feldnamen festlegt).
|
||||||
|
package rules
|
||||||
|
|
||||||
|
// DatenklasseStufe ist eine mögliche Datenklasse mit ihren Auslösern aus
|
||||||
|
// Fragebogen-Abschnitt B. Rang bestimmt, welche Stufe gewinnt, wenn
|
||||||
|
// mehrere Auslöser gleichzeitig zutreffen ("höchste zutreffende Stufe
|
||||||
|
// gewinnt") — höherer Rang gewinnt.
|
||||||
|
type DatenklasseStufe struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Rang int `yaml:"rang"`
|
||||||
|
Ausloeser []string `yaml:"ausloeser"`
|
||||||
|
Folge string `yaml:"folge"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatenklasseRegelwerk ist die vollständige Ableitungstabelle für
|
||||||
|
// Fragebogen-Abschnitt B.
|
||||||
|
type DatenklasseRegelwerk struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Stufen []DatenklasseStufe `yaml:"stufen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EinstufungVariante ist eine mögliche Kombination von Fragebogen-Fakten
|
||||||
|
// (Abschnitt C), die zu einer KI-VO-Einstufung führt — mehrere Varianten
|
||||||
|
// sind ODER-verknüpft, die Felder innerhalb einer Variante UND-verknüpft.
|
||||||
|
// Bewusst als freie Schlüssel-Werte-Paare statt fester Go-Felder: welche
|
||||||
|
// Fakten-Schlüssel es gibt, legt der Fragebogen aus Phase 2 fest, nicht
|
||||||
|
// dieses Paket.
|
||||||
|
type EinstufungVariante map[string]any
|
||||||
|
|
||||||
|
// EinstufungStufe ist eine mögliche KI-VO-Einstufung.
|
||||||
|
type EinstufungStufe struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Quelle string `yaml:"quelle,omitempty"`
|
||||||
|
Varianten []EinstufungVariante `yaml:"varianten"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EinstufungRegelwerk ist die vollständige Ableitungstabelle für
|
||||||
|
// Fragebogen-Abschnitt C. Die Reihenfolge der Stufen ist Prüfreihenfolge
|
||||||
|
// (erste zutreffende Stufe gewinnt) — siehe Bewertungslogik, Schritt 1
|
||||||
|
// (K.-o.-Prüfung: "verboten" muss zuerst geprüft werden).
|
||||||
|
type EinstufungRegelwerk struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Stufen []EinstufungStufe `yaml:"stufen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnforderungsRegel leitet eine einzelne Anforderung aus Datenklasse
|
||||||
|
// und/oder KI-VO-Einstufung ab (z. B. avv_erforderlich).
|
||||||
|
type AnforderungsRegel struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
AusDatenklassen []string `yaml:"aus_datenklassen,omitempty"`
|
||||||
|
AusEinstufungen []string `yaml:"aus_einstufungen,omitempty"`
|
||||||
|
Beschreibung string `yaml:"beschreibung,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnforderungsRegelwerk ist die vollständige Mapping-Tabelle
|
||||||
|
// Datenklasse/Einstufung -> Anforderungsprofil.
|
||||||
|
type AnforderungsRegelwerk struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Anforderungen []AnforderungsRegel `yaml:"anforderungen"`
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package rules
|
|
||||||
|
|
||||||
// Severity ist die Schwere eines Findings.
|
|
||||||
type Severity string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SeverityLow Severity = "niedrig"
|
|
||||||
SeverityMedium Severity = "mittel"
|
|
||||||
SeverityHigh Severity = "hoch"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rule ist eine versionierte Regel aus einer YAML-Datei in rules/.
|
|
||||||
// Regel-IDs werden nie umbenannt oder wiederverwendet — Änderungen an
|
|
||||||
// einer Regel erhöhen die Version.
|
|
||||||
//
|
|
||||||
// Jurisdiction ordnet eine Regel genau einer Rechtsordnung zu (aktuell
|
|
||||||
// nur "DE" — deutsche Rechtslage zuerst). Damit lassen sich künftige
|
|
||||||
// AT/CH-Regeln als zusätzliche Dateien ergänzen, ohne bestehende Regeln
|
|
||||||
// anzufassen: eine Regel gilt nie für mehrere Rechtsordnungen gleichzeitig,
|
|
||||||
// auch wenn sich Gesetzestexte ähneln — jede Rechtsordnung bekommt ihre
|
|
||||||
// eigene, einzeln geprüfte Fundstelle.
|
|
||||||
type Rule struct {
|
|
||||||
ID string `yaml:"id"`
|
|
||||||
Version int `yaml:"version"`
|
|
||||||
Jurisdiction string `yaml:"land"`
|
|
||||||
Title string `yaml:"titel"`
|
|
||||||
Condition Condition `yaml:"bedingung"`
|
|
||||||
Severity Severity `yaml:"schwere"`
|
|
||||||
Sources []string `yaml:"fundstelle"`
|
|
||||||
Fix string `yaml:"korrektur"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Condition ist eine flache UND-Bedingung über Facts. Ein nil/leeres
|
|
||||||
// Feld bedeutet "keine Einschränkung durch dieses Feld".
|
|
||||||
type Condition struct {
|
|
||||||
Consideration []Consideration `yaml:"gegenleistung,omitempty"`
|
|
||||||
DisclosurePresent *bool `yaml:"kennzeichnung_vorhanden,omitempty"`
|
|
||||||
DisclosureBeforeCut *bool `yaml:"kennzeichnung_vor_kuerzung,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matches prüft, ob f alle gesetzten Bedingungsfelder erfüllt.
|
|
||||||
func (c Condition) Matches(f Facts) bool {
|
|
||||||
if len(c.Consideration) > 0 {
|
|
||||||
found := false
|
|
||||||
for _, allowed := range c.Consideration {
|
|
||||||
if allowed == f.Consideration {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.DisclosurePresent != nil && *c.DisclosurePresent != f.DisclosurePresent {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if c.DisclosureBeforeCut != nil && *c.DisclosureBeforeCut != f.DisclosureBeforeCut {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package rules_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"testing/fstest"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoadRejectsMissingID(t *testing.T) {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"bad.yaml": &fstest.MapFile{Data: []byte("version: 1\ntitel: x\nschwere: hoch\n")},
|
|
||||||
}
|
|
||||||
if _, err := rules.Load(fsys); err == nil {
|
|
||||||
t.Fatal("expected error for rule without id, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadRejectsMissingVersion(t *testing.T) {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\ntitel: x\nschwere: hoch\n")},
|
|
||||||
}
|
|
||||||
if _, err := rules.Load(fsys); err == nil {
|
|
||||||
t.Fatal("expected error for rule without version, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadRejectsMissingJurisdiction(t *testing.T) {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")},
|
|
||||||
}
|
|
||||||
if _, err := rules.Load(fsys); err == nil {
|
|
||||||
t.Fatal("expected error for rule without land (jurisdiction), got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadRejectsDuplicateID(t *testing.T) {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
|
|
||||||
"b.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 2\nland: DE\ntitel: y\nschwere: hoch\n")},
|
|
||||||
}
|
|
||||||
if _, err := rules.Load(fsys); err == nil {
|
|
||||||
t.Fatal("expected error for duplicate rule id, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadIgnoresNonYAMLFiles(t *testing.T) {
|
|
||||||
fsys := fstest.MapFS{
|
|
||||||
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
|
|
||||||
"README.md": &fstest.MapFile{Data: []byte("not a rule")},
|
|
||||||
}
|
|
||||||
got, err := rules.Load(fsys)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("expected 1 rule, got %d", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionMatches(t *testing.T) {
|
|
||||||
yes := true
|
|
||||||
no := false
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
cond rules.Condition
|
|
||||||
fact rules.Facts
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "consideration list matches",
|
|
||||||
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
|
||||||
fact: rules.Facts{Consideration: rules.ConsiderationPaid},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "consideration list does not match",
|
|
||||||
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
|
||||||
fact: rules.Facts{Consideration: rules.ConsiderationNone},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "disclosure present must match",
|
|
||||||
cond: rules.Condition{DisclosurePresent: &no},
|
|
||||||
fact: rules.Facts{DisclosurePresent: true},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unset fields impose no constraint",
|
|
||||||
cond: rules.Condition{},
|
|
||||||
fact: rules.Facts{Consideration: rules.ConsiderationUnclear},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all constraints must hold (AND)",
|
|
||||||
cond: rules.Condition{
|
|
||||||
Consideration: []rules.Consideration{rules.ConsiderationPaid},
|
|
||||||
DisclosurePresent: &yes,
|
|
||||||
DisclosureBeforeCut: &no,
|
|
||||||
},
|
|
||||||
fact: rules.Facts{Consideration: rules.ConsiderationPaid, DisclosurePresent: true, DisclosureBeforeCut: false},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := tc.cond.Matches(tc.fact); got != tc.want {
|
|
||||||
t.Fatalf("Matches() = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvaluateFiltersByJurisdiction(t *testing.T) {
|
|
||||||
rule := rules.Rule{
|
|
||||||
ID: "WK-TEST",
|
|
||||||
Version: 1,
|
|
||||||
Jurisdiction: "AT",
|
|
||||||
Condition: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
|
||||||
Severity: rules.SeverityHigh,
|
|
||||||
}
|
|
||||||
fact := rules.Facts{Jurisdiction: "DE", Consideration: rules.ConsiderationPaid}
|
|
||||||
|
|
||||||
findings, needsClarification := rules.Evaluate([]rules.Rule{rule}, fact)
|
|
||||||
if needsClarification {
|
|
||||||
t.Fatal("needsClarification = true, want false")
|
|
||||||
}
|
|
||||||
if len(findings) != 0 {
|
|
||||||
t.Fatalf("expected no findings for a rule from a different jurisdiction, got %v", findings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
85
internal/store/abteilung.go
Normal file
85
internal/store/abteilung.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Abteilung ist Stammdatum für den Fragebogen (Feld A.abteilung).
|
||||||
|
type Abteilung struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||||
|
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||||
|
var a Abteilung
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||||
|
RETURNING id, account_id, name, created_at
|
||||||
|
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Abteilung{}, fmt.Errorf("store: create abteilung: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
||||||
|
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||||
|
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, account_id, name, created_at FROM abteilung
|
||||||
|
WHERE account_id = $1 ORDER BY name
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Abteilung
|
||||||
|
for rows.Next() {
|
||||||
|
var a Abteilung
|
||||||
|
if err := rows.Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan abteilung: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||||
|
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||||
|
var a Abteilung
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
||||||
|
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Abteilung{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Abteilung{}, fmt.Errorf("store: get abteilung: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
||||||
|
// angelegt).
|
||||||
|
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete abteilung: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
62
internal/store/abteilung_test.go
Normal file
62
internal/store/abteilung_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAbteilungCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
a, err := s.CreateAbteilung(ctx, accID, "IT")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAbteilung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAbteilung(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAbteilung: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "IT" || got.AccountID != accID {
|
||||||
|
t.Fatalf("GetAbteilung = %+v, unerwartete Werte", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListAbteilungenForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAbteilungenForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].ID != a.ID {
|
||||||
|
t.Fatalf("ListAbteilungenForAccount = %+v, want exactly the created Abteilung", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteAbteilung(ctx, a.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteAbteilung: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetAbteilung(ctx, a.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbteilungIsolatedPerAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accA := testAccountID(t, s)
|
||||||
|
accB := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.CreateAbteilung(ctx, accA, "Marketing"); err != nil {
|
||||||
|
t.Fatalf("CreateAbteilung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListAbteilungenForAccount(ctx, accB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAbteilungenForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("expected no Abteilungen for Mandant B, got %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,25 +9,21 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als
|
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
|
||||||
// eigene Organisation). Jeder Beitrag gehört genau einem Account.
|
// nutzt). Jeder Antrag gehört genau einem Account.
|
||||||
// Verified ist nur für Kanzlei-Accounts relevant: ob sie im öffentlichen
|
|
||||||
// Kanzlei-Verzeichnis gelistet werden (siehe Migration 0004).
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Verified bool
|
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAccount legt einen neuen Mandanten an (verified startet false —
|
// CreateAccount legt einen neuen Mandanten an.
|
||||||
// jede Freigabe fürs Kanzlei-Verzeichnis ist eine bewusste Admin-Aktion).
|
|
||||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||||
var a Account
|
var a Account
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO account (name) VALUES ($1)
|
INSERT INTO account (name) VALUES ($1)
|
||||||
RETURNING id, name, verified, created_at
|
RETURNING id, name, created_at
|
||||||
`, name).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
|
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Account{}, fmt.Errorf("store: create account: %w", err)
|
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||||
}
|
}
|
||||||
@@ -38,8 +34,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
|
|||||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||||
var a Account
|
var a Account
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.Pool.QueryRow(ctx, `
|
||||||
SELECT id, name, verified, created_at FROM account WHERE id = $1
|
SELECT id, name, created_at FROM account WHERE id = $1
|
||||||
`, id).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
|
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Account{}, ErrNotFound
|
return Account{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -53,7 +49,7 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
|||||||
// Admin-Bereich (Accounts-Verwaltung).
|
// Admin-Bereich (Accounts-Verwaltung).
|
||||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||||
rows, err := s.Pool.Query(ctx, `
|
rows, err := s.Pool.Query(ctx, `
|
||||||
SELECT id, name, verified, created_at FROM account ORDER BY created_at DESC
|
SELECT id, name, created_at FROM account ORDER BY created_at DESC
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||||
@@ -63,7 +59,7 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
|||||||
var out []Account
|
var out []Account
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Account
|
var a Account
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil {
|
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
return nil, fmt.Errorf("store: scan account: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
@@ -73,50 +69,3 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListVerifiedKanzleien liefert alle Accounts, die fürs öffentliche
|
|
||||||
// Kanzlei-Verzeichnis freigegeben sind UND mindestens einen Nutzer der
|
|
||||||
// Rolle "kanzlei" haben — verified allein reicht nicht, falls ein Admin
|
|
||||||
// versehentlich einen Nicht-Kanzlei-Account markiert.
|
|
||||||
func (s *Store) ListVerifiedKanzleien(ctx context.Context) ([]Account, error) {
|
|
||||||
rows, err := s.Pool.Query(ctx, `
|
|
||||||
SELECT DISTINCT a.id, a.name, a.verified, a.created_at
|
|
||||||
FROM account a
|
|
||||||
JOIN app_user u ON u.account_id = a.id
|
|
||||||
WHERE a.verified = true AND u.role = 'kanzlei'
|
|
||||||
ORDER BY a.name
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("store: list verified kanzleien: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []Account
|
|
||||||
for rows.Next() {
|
|
||||||
var a Account
|
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, a)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: list verified kanzleien: %w", err)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAccountVerified setzt die Freigabe fürs Kanzlei-Verzeichnis.
|
|
||||||
func (s *Store) SetAccountVerified(ctx context.Context, id string, verified bool) (Account, error) {
|
|
||||||
var a Account
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
UPDATE account SET verified = $2 WHERE id = $1
|
|
||||||
RETURNING id, name, verified, created_at
|
|
||||||
`, id, verified).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Account{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Account{}, fmt.Errorf("store: set account verified: %w", err)
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,54 +5,27 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAppUserRoleAllowsAdmin(t *testing.T) {
|
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
accID := testAccountID(t, s)
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "admin")
|
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "betreiber")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateUser mit role=admin: %v", err)
|
t.Fatalf("CreateUser mit role=betreiber: %v", err)
|
||||||
}
|
}
|
||||||
if u.Role != "admin" {
|
if u.Role != "betreiber" {
|
||||||
t.Fatalf("Role = %q, want admin", u.Role)
|
t.Fatalf("Role = %q, want betreiber", u.Role)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccountVerifiedDefaultsFalseAndCanBeSet(t *testing.T) {
|
func TestAppUserRoleRejectsUnknownRole(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
acc, err := s.CreateAccount(ctx, "Kanzlei Musterfrau")
|
if _, err := s.CreateUser(ctx, accID, "unbekannt@example.com", "hash", "kanzlei"); err == nil {
|
||||||
if err != nil {
|
t.Fatal("expected the old role 'kanzlei' to be rejected after the product pivot")
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
|
||||||
}
|
|
||||||
if acc.Verified {
|
|
||||||
t.Fatal("expected a new account to be unverified by default")
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.SetAccountVerified(ctx, acc.ID, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SetAccountVerified: %v", err)
|
|
||||||
}
|
|
||||||
if !updated.Verified {
|
|
||||||
t.Fatal("expected the account to be verified after SetAccountVerified(true)")
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := s.GetAccount(ctx, acc.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAccount: %v", err)
|
|
||||||
}
|
|
||||||
if !got.Verified {
|
|
||||||
t.Fatal("expected verified=true to persist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetAccountVerifiedNotFound(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
_, err := s.SetAccountVerified(context.Background(), "00000000-0000-0000-0000-000000000000", true)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected an error for an unknown account")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,75 +60,19 @@ func TestListAccounts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListVerifiedKanzleienRequiresBothVerifiedAndKanzleiRole(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Verifiziert, aber kein Kanzlei-Nutzer -> darf nicht auftauchen.
|
|
||||||
verifiedNonKanzlei, err := s.CreateAccount(ctx, "Verifizierte Marke")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.CreateUser(ctx, verifiedNonKanzlei.ID, "marke@example.com", "hash", "marke"); err != nil {
|
|
||||||
t.Fatalf("CreateUser: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.SetAccountVerified(ctx, verifiedNonKanzlei.ID, true); err != nil {
|
|
||||||
t.Fatalf("SetAccountVerified: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kanzlei-Nutzer, aber nicht verifiziert -> darf nicht auftauchen.
|
|
||||||
unverifiedKanzlei, err := s.CreateAccount(ctx, "Unverifizierte Kanzlei")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.CreateUser(ctx, unverifiedKanzlei.ID, "unverifiziert@example.com", "hash", "kanzlei"); err != nil {
|
|
||||||
t.Fatalf("CreateUser: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beides erfuellt -> muss auftauchen.
|
|
||||||
verifiedKanzlei, err := s.CreateAccount(ctx, "Verifizierte Kanzlei")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.CreateUser(ctx, verifiedKanzlei.ID, "verifiziert@example.com", "hash", "kanzlei"); err != nil {
|
|
||||||
t.Fatalf("CreateUser: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.SetAccountVerified(ctx, verifiedKanzlei.ID, true); err != nil {
|
|
||||||
t.Fatalf("SetAccountVerified: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := s.ListVerifiedKanzleien(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListVerifiedKanzleien: %v", err)
|
|
||||||
}
|
|
||||||
byID := map[string]bool{}
|
|
||||||
for _, a := range list {
|
|
||||||
byID[a.ID] = true
|
|
||||||
}
|
|
||||||
if byID[verifiedNonKanzlei.ID] {
|
|
||||||
t.Error("verified non-kanzlei account should not appear in the directory")
|
|
||||||
}
|
|
||||||
if byID[unverifiedKanzlei.ID] {
|
|
||||||
t.Error("unverified kanzlei account should not appear in the directory")
|
|
||||||
}
|
|
||||||
if !byID[verifiedKanzlei.ID] {
|
|
||||||
t.Error("expected the verified kanzlei account in the directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListUsersForAccount(t *testing.T) {
|
func TestListUsersForAccount(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
accID := testAccountID(t, s)
|
accID := testAccountID(t, s)
|
||||||
otherAccID := testAccountID(t, s)
|
otherAccID := testAccountID(t, s)
|
||||||
|
|
||||||
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "creator"); err != nil {
|
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "mitarbeiter"); err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "agentur"); err != nil {
|
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "verantwortlicher"); err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "marke"); err != nil {
|
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "mitarbeiter"); err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +89,12 @@ func TestAuditLogCreateAndList(t *testing.T) {
|
|||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
accID := testAccountID(t, s)
|
accID := testAccountID(t, s)
|
||||||
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "admin")
|
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "betreiber")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "account.verified", "account", accID, "manuell freigegeben")
|
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "manuell geprüft")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAuditEntry: %v", err)
|
t.Fatalf("CreateAuditEntry: %v", err)
|
||||||
}
|
}
|
||||||
@@ -201,11 +118,11 @@ func TestAuditLogIsAppendOnly(t *testing.T) {
|
|||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
accID := testAccountID(t, s)
|
accID := testAccountID(t, s)
|
||||||
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "admin")
|
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "betreiber")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "account.verified", "account", accID, "")
|
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAuditEntry: %v", err)
|
t.Fatalf("CreateAuditEntry: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
162
internal/store/antrag.go
Normal file
162
internal/store/antrag.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Antrag ist ein Vorhaben aus dem Fragebogen (Abschnitt A) mit den
|
||||||
|
// vollständigen Antworten aus B/C/D als JSON — der Fragebogen ist
|
||||||
|
// adaptiv (Folgefragen hängen von vorherigen Antworten ab), ein starres
|
||||||
|
// Spaltenschema könnte das nicht abbilden. Antworten ist bewusst
|
||||||
|
// []byte (rohes JSON), nicht ein aufgelöster Go-Typ — die Struktur der
|
||||||
|
// Antworten ist Sache von internal/rules (Stufe 2), store speichert nur.
|
||||||
|
type Antrag struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
ErstellerUserID string
|
||||||
|
AbteilungID *string
|
||||||
|
Titel string
|
||||||
|
Beschreibung string
|
||||||
|
Ergebnis string
|
||||||
|
Haeufigkeit string
|
||||||
|
Antworten []byte
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const antragColumns = `id, account_id, ersteller_user_id, abteilung_id, titel, beschreibung,
|
||||||
|
ergebnis, haeufigkeit, antworten, status, created_at, updated_at`
|
||||||
|
|
||||||
|
func scanAntrag(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Antrag, error) {
|
||||||
|
var a Antrag
|
||||||
|
err := row.Scan(
|
||||||
|
&a.ID, &a.AccountID, &a.ErstellerUserID, &a.AbteilungID, &a.Titel, &a.Beschreibung,
|
||||||
|
&a.Ergebnis, &a.Haeufigkeit, &a.Antworten, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
||||||
|
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING `+antragColumns,
|
||||||
|
accountID, erstellerUserID, abteilungID, titel,
|
||||||
|
)
|
||||||
|
a, err := scanAntrag(row)
|
||||||
|
if err != nil {
|
||||||
|
return Antrag{}, fmt.Errorf("store: create antrag: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
||||||
|
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||||
|
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
||||||
|
a, err := scanAntrag(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Antrag{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Antrag{}, fmt.Errorf("store: get antrag: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAntragFelder aktualisiert die Fragebogen-Felder eines Antrags —
|
||||||
|
// solange er im Entwurf ist, kann der Fragebogen adaptiv weiter
|
||||||
|
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
||||||
|
// Status hier nicht).
|
||||||
|
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE antrag SET
|
||||||
|
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+antragColumns,
|
||||||
|
id, titel, beschreibung, ergebnis, haeufigkeit, antworten,
|
||||||
|
)
|
||||||
|
a, err := scanAntrag(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Antrag{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Antrag{}, fmt.Errorf("store: update antrag felder: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAntragStatus setzt den Status eines Antrags (entwurf -> eingereicht
|
||||||
|
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
||||||
|
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||||
|
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: set antrag status: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAntraegeForUser liefert alle Anträge, die ein bestimmter Nutzer
|
||||||
|
// gestellt hat, neueste zuerst — für Ebene 2 ("eigene Anträge
|
||||||
|
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
||||||
|
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||||
|
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
||||||
|
`, erstellerUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Antrag
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAntrag(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||||
|
// zuerst.
|
||||||
|
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list antraege for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Antrag
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAntrag(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list antraege for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
133
internal/store/antrag_test.go
Normal file
133
internal/store/antrag_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testUserID(t *testing.T, s *store.Store, accountID string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := s.CreateUser(context.Background(), accountID, "mitarbeiter-"+accountID+"@example.com", "hash", "mitarbeiter")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
abt, err := s.CreateAbteilung(ctx, accID, "Vertrieb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAbteilung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := s.CreateAntrag(ctx, accID, userID, &abt.ID, "Angebotstexte generieren")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if a.Status != "entwurf" {
|
||||||
|
t.Fatalf("Status = %q, want entwurf", a.Status)
|
||||||
|
}
|
||||||
|
if a.AbteilungID == nil || *a.AbteilungID != abt.ID {
|
||||||
|
t.Fatalf("AbteilungID = %v, want %q", a.AbteilungID, abt.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAntrag(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.Titel != "Angebotstexte generieren" {
|
||||||
|
t.Fatalf("Titel = %q, unerwartet", got.Titel)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.UpdateAntragFelder(ctx, a.ID, "Angebotstexte generieren", "KI schreibt Angebotstexte", "Fertiger Text zur Freigabe", "gelegentlich", []byte(`{"b1":"nein"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateAntragFelder: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Beschreibung != "KI schreibt Angebotstexte" || updated.Haeufigkeit != "gelegentlich" {
|
||||||
|
t.Fatalf("UpdateAntragFelder = %+v, unerwartete Werte", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetAntragStatus(ctx, a.ID, "eingereicht"); err != nil {
|
||||||
|
t.Fatalf("SetAntragStatus: %v", err)
|
||||||
|
}
|
||||||
|
got, err = s.GetAntrag(ctx, a.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag nach Statuswechsel: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != "eingereicht" {
|
||||||
|
t.Fatalf("Status nach SetAntragStatus = %q, want eingereicht", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAntragNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.GetAntrag(context.Background(), "00000000-0000-0000-0000-000000000000")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetAntragStatusNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
err := s.SetAntragStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "eingereicht")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAntraegeForUserOnlyReturnsOwnAntraege(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userA := testUserID(t, s, accID)
|
||||||
|
userB, err := s.CreateUser(ctx, accID, "zweiter-mitarbeiter-"+accID+"@example.com", "hash", "mitarbeiter")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.CreateAntrag(ctx, accID, userA, nil, "Antrag von A"); err != nil {
|
||||||
|
t.Fatalf("CreateAntrag (A): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateAntrag(ctx, accID, userB.ID, nil, "Antrag von B"); err != nil {
|
||||||
|
t.Fatalf("CreateAntrag (B): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listA, err := s.ListAntraegeForUser(ctx, userA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAntraegeForUser: %v", err)
|
||||||
|
}
|
||||||
|
if len(listA) != 1 || listA[0].Titel != "Antrag von A" {
|
||||||
|
t.Fatalf("ListAntraegeForUser(A) = %+v, want exactly Antrag von A", listA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAntraegeForAccountIsolatesTenants(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accA := testAccountID(t, s)
|
||||||
|
accB := testAccountID(t, s)
|
||||||
|
userA := testUserID(t, s, accA)
|
||||||
|
userB := testUserID(t, s, accB)
|
||||||
|
|
||||||
|
if _, err := s.CreateAntrag(ctx, accA, userA, nil, "Antrag A"); err != nil {
|
||||||
|
t.Fatalf("CreateAntrag (A): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateAntrag(ctx, accB, userB, nil, "Antrag B"); err != nil {
|
||||||
|
t.Fatalf("CreateAntrag (B): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listA, err := s.ListAntraegeForAccount(ctx, accA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAntraegeForAccount (A): %v", err)
|
||||||
|
}
|
||||||
|
if len(listA) != 1 || listA[0].Titel != "Antrag A" {
|
||||||
|
t.Fatalf("ListAntraegeForAccount (A) = %+v, want exactly Antrag A", listA)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ func TestUserCRUD(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
accID := testAccountID(t, s)
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "agentur")
|
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
@@ -67,10 +67,10 @@ func TestUserEmailIsUnique(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
accID := testAccountID(t, s)
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "creator"); err != nil {
|
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "mitarbeiter"); err != nil {
|
||||||
t.Fatalf("CreateUser (1): %v", err)
|
t.Fatalf("CreateUser (1): %v", err)
|
||||||
}
|
}
|
||||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "creator"); err == nil {
|
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "mitarbeiter"); err == nil {
|
||||||
t.Fatal("expected error for a duplicate email, got nil")
|
t.Fatal("expected error for a duplicate email, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ func TestSessionCRUD(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
accID := testAccountID(t, s)
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "marke")
|
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
package store_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openTestStore(t *testing.T) *store.Store {
|
|
||||||
t.Helper()
|
|
||||||
url := testDatabaseURL(t)
|
|
||||||
if err := store.Migrate(url); err != nil {
|
|
||||||
t.Fatalf("Migrate: %v", err)
|
|
||||||
}
|
|
||||||
s, err := store.Open(context.Background(), url)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(s.Close)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// testAccountID legt einen Mandanten an und liefert dessen ID — jede
|
|
||||||
// Submission braucht seit der Auth-Migration einen Account.
|
|
||||||
func testAccountID(t *testing.T, s *store.Store) string {
|
|
||||||
t.Helper()
|
|
||||||
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
|
||||||
}
|
|
||||||
return acc.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmissionCRUD(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
accID := testAccountID(t, s)
|
|
||||||
created, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "Werbung fuer ein Produkt")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSubmission: %v", err)
|
|
||||||
}
|
|
||||||
if created.Status != "draft" {
|
|
||||||
t.Fatalf("Status = %q, want draft", created.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := s.GetSubmission(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSubmission: %v", err)
|
|
||||||
}
|
|
||||||
if got.Platform != "instagram" || got.Caption != "Werbung fuer ein Produkt" {
|
|
||||||
t.Fatalf("GetSubmission = %+v, unerwartete Werte", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.SetSubmissionStatus(ctx, created.ID, "checked"); err != nil {
|
|
||||||
t.Fatalf("SetSubmissionStatus: %v", err)
|
|
||||||
}
|
|
||||||
got, err = s.GetSubmission(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetSubmission nach Statuswechsel: %v", err)
|
|
||||||
}
|
|
||||||
if got.Status != "checked" {
|
|
||||||
t.Fatalf("Status nach SetSubmissionStatus = %q, want checked", got.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSubmissionNotFound(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
if _, err := s.GetSubmission(context.Background(), "00000000-0000-0000-0000-000000000000"); err == nil {
|
|
||||||
t.Fatal("expected error for a nonexistent submission, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetSubmissionStatusNotFound(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
err := s.SetSubmissionStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "checked")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error when updating a nonexistent submission, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionCRUD(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
accID := testAccountID(t, s)
|
|
||||||
sub, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "...")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSubmission: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := []byte(`{"gegenleistung":"bezahlt"}`)
|
|
||||||
ext, err := s.CreateExtraction(ctx, sub.ID, payload, "claude-sonnet-5", "v1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateExtraction: %v", err)
|
|
||||||
}
|
|
||||||
// JSONB normalisiert die Textform (z. B. Leerzeichen nach ':'), der Inhalt
|
|
||||||
// muss aber semantisch identisch bleiben — kein Byte-Vergleich.
|
|
||||||
var gotPayload, wantPayload map[string]any
|
|
||||||
if err := json.Unmarshal(ext.Payload, &gotPayload); err != nil {
|
|
||||||
t.Fatalf("unmarshal stored payload: %v", err)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(payload, &wantPayload); err != nil {
|
|
||||||
t.Fatalf("unmarshal input payload: %v", err)
|
|
||||||
}
|
|
||||||
if gotPayload["gegenleistung"] != wantPayload["gegenleistung"] {
|
|
||||||
t.Fatalf("stored payload = %v, want %v", gotPayload, wantPayload)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := s.GetLatestExtraction(ctx, sub.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetLatestExtraction: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != ext.ID {
|
|
||||||
t.Fatalf("GetLatestExtraction returned a different row than CreateExtraction")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eine zweite Extraktion (erneute Pruefung) muss die "latest" sein.
|
|
||||||
payload2 := []byte(`{"gegenleistung":"keine"}`)
|
|
||||||
ext2, err := s.CreateExtraction(ctx, sub.ID, payload2, "claude-sonnet-5", "v1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateExtraction (2): %v", err)
|
|
||||||
}
|
|
||||||
got, err = s.GetLatestExtraction(ctx, sub.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetLatestExtraction (2): %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != ext2.ID {
|
|
||||||
t.Fatalf("GetLatestExtraction did not return the most recent extraction")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindingCRUDAndSupersedes(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
accID := testAccountID(t, s)
|
|
||||||
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSubmission: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung", "alte Korrektur", []string{"§ 5a Abs. 4 UWG"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateFinding (old): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
current, err := s.ListCurrentFindings(ctx, sub.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListCurrentFindings: %v", err)
|
|
||||||
}
|
|
||||||
if len(current) != 1 || current[0].ID != old.ID {
|
|
||||||
t.Fatalf("expected exactly the old finding before any correction, got %+v", current)
|
|
||||||
}
|
|
||||||
if len(current[0].Sources) != 1 || current[0].Sources[0] != "§ 5a Abs. 4 UWG" {
|
|
||||||
t.Fatalf("Sources = %v, want [§ 5a Abs. 4 UWG]", current[0].Sources)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Korrektur: neue Zeile, die die alte per supersedes ersetzt.
|
|
||||||
_, err = s.Pool.Exec(ctx, `
|
|
||||||
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources, supersedes)
|
|
||||||
VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', 'neue Korrektur', '{}', $2)
|
|
||||||
`, sub.ID, old.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert superseding finding: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
current, err = s.ListCurrentFindings(ctx, sub.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListCurrentFindings nach Korrektur: %v", err)
|
|
||||||
}
|
|
||||||
if len(current) != 1 {
|
|
||||||
t.Fatalf("expected exactly one current finding after a correction, got %d: %+v", len(current), current)
|
|
||||||
}
|
|
||||||
if current[0].Title != "korrigierte Fassung" {
|
|
||||||
t.Fatalf("expected the corrected finding to be current, got %+v", current[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvidencePackageCRUD(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
accID := testAccountID(t, s)
|
|
||||||
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSubmission: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
token := []byte("fake-rfc3161-token-bytes")
|
|
||||||
pkg, err := s.CreateEvidencePackage(ctx, sub.ID, "/var/lib/deklarix/dossiers/x.pdf", "deadbeef", token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateEvidencePackage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := s.GetLatestEvidencePackage(ctx, sub.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetLatestEvidencePackage: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != pkg.ID || string(got.TimestampToken) != string(token) {
|
|
||||||
t.Fatalf("GetLatestEvidencePackage = %+v, unerwartete Werte", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append-only: ein UPDATE muss vom Trigger abgelehnt werden.
|
|
||||||
_, err = s.Pool.Exec(ctx, `UPDATE evidence_package SET sha256 = 'geaendert' WHERE id = $1`, pkg.ID)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected UPDATE on evidence_package to be rejected, but it succeeded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EvidencePackage ist das Ergebnis der Archivierung eines Beitrags:
|
|
||||||
// Dossier-Pfad, Hash der kanonisierten Metadaten und RFC-3161-Token.
|
|
||||||
// Append-only: siehe Migration.
|
|
||||||
type EvidencePackage struct {
|
|
||||||
ID string
|
|
||||||
SubmissionID string
|
|
||||||
DossierPath string
|
|
||||||
SHA256 string
|
|
||||||
TimestampToken []byte
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateEvidencePackage speichert ein EvidencePackage.
|
|
||||||
func (s *Store) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (EvidencePackage, error) {
|
|
||||||
var e EvidencePackage
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO evidence_package (submission_id, dossier_path, sha256, timestamp_token)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, submission_id, dossier_path, sha256, timestamp_token, created_at
|
|
||||||
`, submissionID, dossierPath, sha256Hex, timestampToken).Scan(
|
|
||||||
&e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return EvidencePackage{}, fmt.Errorf("store: create evidence package: %w", err)
|
|
||||||
}
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLatestEvidencePackage liefert das zuletzt erzeugte EvidencePackage
|
|
||||||
// für einen Beitrag.
|
|
||||||
func (s *Store) GetLatestEvidencePackage(ctx context.Context, submissionID string) (EvidencePackage, error) {
|
|
||||||
var e EvidencePackage
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
SELECT id, submission_id, dossier_path, sha256, timestamp_token, created_at
|
|
||||||
FROM evidence_package
|
|
||||||
WHERE submission_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, submissionID).Scan(
|
|
||||||
&e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return EvidencePackage{}, fmt.Errorf("store: get latest evidence package: %w", err)
|
|
||||||
}
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Extraction ist das Ergebnis der Stufe-1-Extraktion für einen Beitrag.
|
|
||||||
// Payload ist das rohe, vom Modell gelieferte JSON — nicht eine
|
|
||||||
// abgeleitete Repräsentation. Append-only: siehe Migration.
|
|
||||||
type Extraction struct {
|
|
||||||
ID string
|
|
||||||
SubmissionID string
|
|
||||||
Payload []byte
|
|
||||||
ModelVersion string
|
|
||||||
PromptVersion string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateExtraction speichert eine Extraktion. payload ist das rohe
|
|
||||||
// JSON, wie es das Modell zurückgegeben hat (siehe extract.Result.RawJSON).
|
|
||||||
func (s *Store) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (Extraction, error) {
|
|
||||||
var e Extraction
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO extraction (submission_id, payload, model_version, prompt_version)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, submission_id, payload, model_version, prompt_version, created_at
|
|
||||||
`, submissionID, payload, modelVersion, promptVersion).Scan(
|
|
||||||
&e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Extraction{}, fmt.Errorf("store: create extraction: %w", err)
|
|
||||||
}
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLatestExtraction liest die zuletzt erzeugte Extraktion für einen
|
|
||||||
// Beitrag (append-only: es kann mehrere geben, z. B. bei einer erneuten
|
|
||||||
// Prüfung — die aktuellste zählt).
|
|
||||||
func (s *Store) GetLatestExtraction(ctx context.Context, submissionID string) (Extraction, error) {
|
|
||||||
var e Extraction
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
SELECT id, submission_id, payload, model_version, prompt_version, created_at
|
|
||||||
FROM extraction
|
|
||||||
WHERE submission_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, submissionID).Scan(
|
|
||||||
&e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Extraction{}, fmt.Errorf("store: get latest extraction: %w", err)
|
|
||||||
}
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only:
|
|
||||||
// siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht
|
|
||||||
// direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde).
|
|
||||||
// Title/Fix/Sources sind zum Zeitpunkt der Regelauswertung fixiert
|
|
||||||
// gespeichert (nicht nur rule_id/rule_version referenziert), weil ein
|
|
||||||
// späteres Update der Regel-YAML eine ältere Version sonst nicht mehr
|
|
||||||
// nachträglich auflösen könnte — der Wortlaut zum Zeitpunkt des
|
|
||||||
// Findings ist der Beweis, kein Verweis darauf.
|
|
||||||
type Finding struct {
|
|
||||||
ID string
|
|
||||||
SubmissionID string
|
|
||||||
ExtractionID *string
|
|
||||||
RuleID string
|
|
||||||
RuleVersion int
|
|
||||||
Severity string
|
|
||||||
Title string
|
|
||||||
Fix string
|
|
||||||
Sources []string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateFinding speichert ein Finding. sources ist NOT NULL in der DB
|
|
||||||
// (TEXT[]) — ein nil-Slice (z. B. eine Regel ohne fundstelle-Eintrag)
|
|
||||||
// würde als SQL-NULL ankommen und mit einer wenig hilfreichen Constraint-
|
|
||||||
// Fehlermeldung abgelehnt; hier stattdessen auf eine leere Liste normiert.
|
|
||||||
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
|
|
||||||
if sources == nil {
|
|
||||||
sources = []string{}
|
|
||||||
}
|
|
||||||
var f Finding
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources, created_at
|
|
||||||
`, submissionID, extractionID, ruleID, ruleVersion, severity, title, fix, sources).Scan(
|
|
||||||
&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Finding{}, fmt.Errorf("store: create finding: %w", err)
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListCurrentFindings liefert die aktuell gültigen Findings eines
|
|
||||||
// Beitrags — Zeilen, die von keiner anderen Zeile per supersedes
|
|
||||||
// ersetzt wurden (siehe Migrationskommentar zu finding.supersedes).
|
|
||||||
func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([]Finding, error) {
|
|
||||||
rows, err := s.Pool.Query(ctx, `
|
|
||||||
SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.title, f.fix, f.sources, f.created_at
|
|
||||||
FROM finding f
|
|
||||||
WHERE f.submission_id = $1
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
|
|
||||||
ORDER BY f.created_at
|
|
||||||
`, submissionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("store: list current findings: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var findings []Finding
|
|
||||||
for rows.Next() {
|
|
||||||
var f Finding
|
|
||||||
if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: scan finding: %w", err)
|
|
||||||
}
|
|
||||||
findings = append(findings, f)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: list current findings: %w", err)
|
|
||||||
}
|
|
||||||
return findings, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TRIGGER asset_append_only ON asset;
|
||||||
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
|
||||||
|
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
|
||||||
|
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
|
||||||
|
-- finding/evidence_package — es nachträglich austauschen zu können,
|
||||||
|
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
|
||||||
|
-- sind.
|
||||||
|
CREATE TRIGGER asset_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON asset
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE platform_connection;
|
||||||
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
|
||||||
|
-- OAuth verbinden (siehe internal/socialconnect), damit die
|
||||||
|
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||||
|
-- abrufen kann statt ihn manuell hochzuladen. Anders als
|
||||||
|
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
|
||||||
|
-- und werden erneuert, eine Verbindung kann getrennt und neu
|
||||||
|
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
|
||||||
|
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
|
||||||
|
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
|
||||||
|
-- ON CONFLICT in UpsertPlatformConnection).
|
||||||
|
CREATE TABLE platform_connection (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||||
|
platform_user_id TEXT NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT NOT NULL DEFAULT '',
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, platform)
|
||||||
|
);
|
||||||
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE asset DROP COLUMN purpose;
|
||||||
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
|
||||||
|
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
|
||||||
|
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
|
||||||
|
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
|
||||||
|
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
|
||||||
|
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
|
||||||
|
-- angelegten Zeilen unverändert.
|
||||||
|
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
|
||||||
|
CHECK (purpose IN ('initial', 'insights'));
|
||||||
104
internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql
Normal file
104
internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
-- Best-effort-Rückbau auf die Schema-Form des alten Werberecht-Produkts
|
||||||
|
-- (Stand nach Migration 0007) — reine Struktur, keine Daten. Ein
|
||||||
|
-- kompletter Produktwechsel wird in der Praxis nicht zurückgerollt,
|
||||||
|
-- diese Datei existiert nur, damit "migrate down" nicht bricht.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS antrag;
|
||||||
|
DROP TABLE IF EXISTS werkzeug_sperre;
|
||||||
|
DROP TABLE IF EXISTS werkzeug;
|
||||||
|
DROP TABLE IF EXISTS abteilung;
|
||||||
|
|
||||||
|
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||||
|
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||||
|
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
|
||||||
|
|
||||||
|
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE TABLE submission (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
|
||||||
|
post_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
|
||||||
|
caption TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE asset (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
purpose TEXT NOT NULL DEFAULT 'initial' CHECK (purpose IN ('initial', 'insights'))
|
||||||
|
);
|
||||||
|
CREATE TRIGGER asset_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON asset
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
|
|
||||||
|
CREATE TABLE extraction (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
model_version TEXT NOT NULL,
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TRIGGER extraction_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON extraction
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
|
|
||||||
|
CREATE TABLE finding (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
extraction_id UUID REFERENCES extraction (id),
|
||||||
|
rule_id TEXT NOT NULL,
|
||||||
|
rule_version INTEGER NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
|
||||||
|
supersedes UUID REFERENCES finding (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
fix TEXT NOT NULL,
|
||||||
|
sources TEXT[] NOT NULL DEFAULT '{}'
|
||||||
|
);
|
||||||
|
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||||
|
CREATE TRIGGER finding_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON finding
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
|
|
||||||
|
CREATE TABLE evidence_package (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
dossier_path TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
timestamp_token BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TRIGGER evidence_package_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON evidence_package
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
|
|
||||||
|
CREATE TABLE participant (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
vorgegeben BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
freigegeben BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE platform_connection (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||||
|
platform_user_id TEXT NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT NOT NULL DEFAULT '',
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, platform)
|
||||||
|
);
|
||||||
117
internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql
Normal file
117
internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
-- Produktwechsel: Deklarix war eine Kennzeichnungsprüfung für
|
||||||
|
-- Werbe-Content (UWG/MStV), wird jetzt eine KI-Antragsprüfung
|
||||||
|
-- (Fragebogen -> Datenklasse/KI-VO-Einstufung -> Werkzeug-Katalog ->
|
||||||
|
-- Entscheidung). Alles, was ausschließlich für das alte Werberecht-
|
||||||
|
-- Produkt existierte, wird entfernt. account/app_user/session/
|
||||||
|
-- audit_log bleiben — Mandantentrennung, Login und
|
||||||
|
-- Protokollierungsprinzip sind produktunabhängig.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS platform_connection;
|
||||||
|
DROP TABLE IF EXISTS asset;
|
||||||
|
DROP TABLE IF EXISTS participant;
|
||||||
|
DROP TABLE IF EXISTS evidence_package;
|
||||||
|
DROP TABLE IF EXISTS finding;
|
||||||
|
DROP TABLE IF EXISTS extraction;
|
||||||
|
DROP TABLE IF EXISTS submission;
|
||||||
|
|
||||||
|
-- "verified"/Kanzlei-Verzeichnis gab es nur für die alte Berufsrecht-
|
||||||
|
-- Sonderrolle "kanzlei".
|
||||||
|
ALTER TABLE account DROP COLUMN IF EXISTS verified;
|
||||||
|
|
||||||
|
-- Fünf Rollen (Ebenen 2-5 der Frontend-Spezifikation):
|
||||||
|
-- mitarbeiter Ebene 2 — stellt Anträge, sieht nur eigene
|
||||||
|
-- verantwortlicher Ebene 3 — KI-Verantwortlicher, volles Entscheidungsrecht
|
||||||
|
-- pruefer Ebene 3 — identische Sicht wie verantwortlicher, aber
|
||||||
|
-- ohne Entscheidungsrecht (reine Prüfsicht)
|
||||||
|
-- admin Ebene 4 — Mandanten-Verwaltung (Nutzer, Abteilungen,
|
||||||
|
-- Anmeldeverfahren, eigene Werkzeug-Freigaben) — bezogen
|
||||||
|
-- auf GENAU EINEN Mandanten, nicht plattformweit
|
||||||
|
-- betreiber Ebene 5 — Netcell-IT-Personal, plattformweit
|
||||||
|
-- (Werkzeugkatalog, Regelwerk, Mandantenverwaltung);
|
||||||
|
-- entspricht der alten "admin"-Rolle vor dieser Migration
|
||||||
|
--
|
||||||
|
-- Die alte CHECK-Constraint muss zuerst weg, sonst lehnt sie die
|
||||||
|
-- Datenmigration unten (die neue Rollenwerte wie "betreiber" schreibt)
|
||||||
|
-- sofort ab — die alte Constraint kennt diese Werte ja noch nicht.
|
||||||
|
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||||
|
|
||||||
|
-- Bestehende Zeilen auf gültige neue Werte umstellen, bevor die neue
|
||||||
|
-- Constraint das erzwingt — sonst schlägt sie auf jeder Installation
|
||||||
|
-- mit echten Nutzern fehl. Die alte Rolle "admin" war der Plattform-
|
||||||
|
-- Betreiber (Netcell-IT) — wird zu "betreiber", nicht zum neuen,
|
||||||
|
-- mandantenbezogenen "admin". Alle anderen alten Rollen (creator/agentur/
|
||||||
|
-- marke/kanzlei) waren gewöhnliche Mandanten-Logins ohne Sonderrechte —
|
||||||
|
-- werden zu "mitarbeiter", der schlankesten neuen Rolle.
|
||||||
|
UPDATE app_user SET role = 'betreiber' WHERE role = 'admin';
|
||||||
|
UPDATE app_user SET role = 'mitarbeiter' WHERE role IN ('creator', 'agentur', 'marke', 'kanzlei');
|
||||||
|
|
||||||
|
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||||
|
CHECK (role IN ('mitarbeiter', 'verantwortlicher', 'pruefer', 'admin', 'betreiber'));
|
||||||
|
|
||||||
|
-- Stammdaten: Abteilungen zur Auswahl im Fragebogen (Feld A.abteilung).
|
||||||
|
CREATE TABLE abteilung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Werkzeugkatalog: der eigentliche Wert des Produkts. account_id NULL
|
||||||
|
-- markiert einen zentral gepflegten, für alle Mandanten identischen
|
||||||
|
-- Katalogeintrag; ein gesetzter account_id ist eine mandantenspezifische
|
||||||
|
-- Ergänzung (siehe Spezifikation "jeder Mandant kann zusätzlich eigene
|
||||||
|
-- Einträge ... führen"). letzte_pruefung und quelle sind Pflicht — jede
|
||||||
|
-- Zusicherung im Katalog muss belegbar sein, siehe CLAUDE.md.
|
||||||
|
CREATE TABLE werkzeug (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID REFERENCES account (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
anbieter TEXT NOT NULL,
|
||||||
|
verarbeitungsort TEXT NOT NULL CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem')),
|
||||||
|
avv_verfuegbar BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
avv_url TEXT NOT NULL DEFAULT '',
|
||||||
|
training_opt_out BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
training_standard BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
aufbewahrung_tage INTEGER NOT NULL DEFAULT 0,
|
||||||
|
zertifizierungen TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
geeignete_zwecke TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
einschraenkungen TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
letzte_pruefung TIMESTAMPTZ NOT NULL,
|
||||||
|
quelle TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Mandant kann einen (auch zentralen) Katalogeintrag für sich
|
||||||
|
-- sperren, ohne den zentralen Katalog selbst zu verändern.
|
||||||
|
CREATE TABLE werkzeug_sperre (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
werkzeug_id UUID NOT NULL REFERENCES werkzeug (id),
|
||||||
|
grund TEXT NOT NULL,
|
||||||
|
gesperrt_am TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, werkzeug_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Antrag: das Vorhaben aus Fragebogen-Abschnitt A, plus die vollständigen
|
||||||
|
-- Antworten aus B/C/D als JSON (adaptiver Fragebogen — welche Folgefragen
|
||||||
|
-- beantwortet wurden, hängt von vorherigen Antworten ab, ein starres
|
||||||
|
-- Spaltenschema würde das nicht abbilden). status ist eine normale
|
||||||
|
-- Zustandsänderung (wie submission es früher war), kein Beweis-Eintrag.
|
||||||
|
CREATE TABLE antrag (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
ersteller_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
abteilung_id UUID REFERENCES abteilung (id),
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
beschreibung TEXT NOT NULL DEFAULT '',
|
||||||
|
ergebnis TEXT NOT NULL DEFAULT '',
|
||||||
|
haeufigkeit TEXT NOT NULL DEFAULT 'einmalig'
|
||||||
|
CHECK (haeufigkeit IN ('einmalig', 'gelegentlich', 'taeglich', 'automatisiert')),
|
||||||
|
antworten JSONB NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'entwurf'
|
||||||
|
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Participant ist ein Beteiligter an einer Submission (Verantwortungs-
|
|
||||||
// matrix). Anders als finding/extraction/evidence_package ist participant
|
|
||||||
// NICHT append-only — wer vorgegeben/freigegeben hat, kann sich klären
|
|
||||||
// oder korrigieren, ohne dass das ein Beweis-Eintrag ist.
|
|
||||||
type Participant struct {
|
|
||||||
ID string
|
|
||||||
SubmissionID string
|
|
||||||
Role string
|
|
||||||
Name string
|
|
||||||
Vorgegeben bool
|
|
||||||
Freigegeben bool
|
|
||||||
ApprovedAt *time.Time
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateParticipant fügt einen Beteiligten zu einer Submission hinzu.
|
|
||||||
func (s *Store) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (Participant, error) {
|
|
||||||
var p Participant
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO participant (submission_id, role, name, vorgegeben, freigegeben, approved_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, CASE WHEN $5 THEN now() ELSE NULL END)
|
|
||||||
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
|
||||||
`, submissionID, role, name, vorgegeben, freigegeben).Scan(
|
|
||||||
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Participant{}, fmt.Errorf("store: create participant: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListParticipants liefert alle Beteiligten einer Submission.
|
|
||||||
func (s *Store) ListParticipants(ctx context.Context, submissionID string) ([]Participant, error) {
|
|
||||||
rows, err := s.Pool.Query(ctx, `
|
|
||||||
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
|
||||||
FROM participant WHERE submission_id = $1 ORDER BY created_at
|
|
||||||
`, submissionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("store: list participants: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var participants []Participant
|
|
||||||
for rows.Next() {
|
|
||||||
var p Participant
|
|
||||||
if err := rows.Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: scan participant: %w", err)
|
|
||||||
}
|
|
||||||
participants = append(participants, p)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: list participants: %w", err)
|
|
||||||
}
|
|
||||||
return participants, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetParticipant liest einen Beteiligten anhand seiner ID — u. a. um vor
|
|
||||||
// einem Update/Delete zu prüfen, zu welcher Submission (und damit zu
|
|
||||||
// welchem Account) er gehört.
|
|
||||||
func (s *Store) GetParticipant(ctx context.Context, id string) (Participant, error) {
|
|
||||||
var p Participant
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
|
||||||
FROM participant WHERE id = $1
|
|
||||||
`, id).Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Participant{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Participant{}, fmt.Errorf("store: get participant: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateParticipant setzt vorgegeben/freigegeben. approved_at wird beim
|
|
||||||
// ersten Wechsel zu freigegeben=true gesetzt und danach nicht mehr
|
|
||||||
// verändert (er hält fest, wann zuerst freigegeben wurde).
|
|
||||||
func (s *Store) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (Participant, error) {
|
|
||||||
var p Participant
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
UPDATE participant
|
|
||||||
SET vorgegeben = $2,
|
|
||||||
freigegeben = $3,
|
|
||||||
approved_at = CASE WHEN $3 AND approved_at IS NULL THEN now() ELSE approved_at END
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
|
||||||
`, id, vorgegeben, freigegeben).Scan(
|
|
||||||
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
|
|
||||||
)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Participant{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Participant{}, fmt.Errorf("store: update participant: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteParticipant entfernt einen Beteiligten (z. B. versehentlich
|
|
||||||
// falsch angelegt).
|
|
||||||
func (s *Store) DeleteParticipant(ctx context.Context, id string) error {
|
|
||||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM participant WHERE id = $1`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("store: delete participant: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package store_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParticipantCRUD(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
accID := testAccountID(t, s)
|
|
||||||
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSubmission: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := s.CreateParticipant(ctx, sub.ID, "creator", "Max Mustermann", false, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateParticipant: %v", err)
|
|
||||||
}
|
|
||||||
if p.ApprovedAt != nil {
|
|
||||||
t.Fatalf("ApprovedAt should be nil when freigegeben=false, got %v", p.ApprovedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := s.ListParticipants(ctx, sub.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListParticipants: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 1 || list[0].ID != p.ID {
|
|
||||||
t.Fatalf("ListParticipants = %+v, want exactly the created participant", list)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := s.GetParticipant(ctx, p.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetParticipant: %v", err)
|
|
||||||
}
|
|
||||||
if got.SubmissionID != sub.ID {
|
|
||||||
t.Fatalf("GetParticipant.SubmissionID = %q, want %q", got.SubmissionID, sub.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.UpdateParticipant(ctx, p.ID, true, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpdateParticipant: %v", err)
|
|
||||||
}
|
|
||||||
if !updated.Vorgegeben || !updated.Freigegeben {
|
|
||||||
t.Fatalf("UpdateParticipant did not apply new flags: %+v", updated)
|
|
||||||
}
|
|
||||||
if updated.ApprovedAt == nil {
|
|
||||||
t.Fatal("expected ApprovedAt to be set once freigegeben became true")
|
|
||||||
}
|
|
||||||
firstApproval := *updated.ApprovedAt
|
|
||||||
|
|
||||||
// Ein erneutes Update (weiterhin freigegeben) darf approved_at nicht
|
|
||||||
// verschieben — es haelt fest, wann ZUERST freigegeben wurde.
|
|
||||||
updated2, err := s.UpdateParticipant(ctx, p.ID, true, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpdateParticipant (2): %v", err)
|
|
||||||
}
|
|
||||||
if !updated2.ApprovedAt.Equal(firstApproval) {
|
|
||||||
t.Fatalf("ApprovedAt changed on a no-op update: %v -> %v", firstApproval, *updated2.ApprovedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.DeleteParticipant(ctx, p.ID); err != nil {
|
|
||||||
t.Fatalf("DeleteParticipant: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.GetParticipant(ctx, p.ID); !errors.Is(err, store.ErrNotFound) {
|
|
||||||
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateParticipantNotFound(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
_, err := s.UpdateParticipant(context.Background(), "00000000-0000-0000-0000-000000000000", true, true)
|
|
||||||
if !errors.Is(err, store.ErrNotFound) {
|
|
||||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteParticipantNotFound(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
err := s.DeleteParticipant(context.Background(), "00000000-0000-0000-0000-000000000000")
|
|
||||||
if !errors.Is(err, store.ErrNotFound) {
|
|
||||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListSubmissionsForAccount(t *testing.T) {
|
|
||||||
s := openTestStore(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
accID := testAccountID(t, s)
|
|
||||||
|
|
||||||
subNoFindings, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "organic")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSubmission: %v", err)
|
|
||||||
}
|
|
||||||
subWithFinding, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "unmarked ad")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSubmission: %v", err)
|
|
||||||
}
|
|
||||||
// sources bewusst nil statt []string{} — CreateFinding muss das
|
|
||||||
// selbst abfangen (siehe Kommentar dort), nicht der Aufrufer.
|
|
||||||
if _, err := s.CreateFinding(ctx, subWithFinding.ID, nil, "WK-001", 1, "hoch", "t", "f", nil); err != nil {
|
|
||||||
t.Fatalf("CreateFinding: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Andere Mandanten duerfen nicht auftauchen.
|
|
||||||
otherAccID := testAccountID(t, s)
|
|
||||||
if _, err := s.CreateSubmission(ctx, otherAccID, "instagram", "reel", "anderer Mandant"); err != nil {
|
|
||||||
t.Fatalf("CreateSubmission (other account): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := s.ListSubmissionsForAccount(ctx, accID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListSubmissionsForAccount: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 2 {
|
|
||||||
t.Fatalf("expected 2 submissions for this account, got %d: %+v", len(list), list)
|
|
||||||
}
|
|
||||||
|
|
||||||
byID := map[string]store.SubmissionSummary{}
|
|
||||||
for _, s := range list {
|
|
||||||
byID[s.ID] = s
|
|
||||||
}
|
|
||||||
if byID[subNoFindings.ID].FindingCount != 0 || byID[subNoFindings.ID].HighestSeverity != "" {
|
|
||||||
t.Errorf("subNoFindings summary = %+v, want 0 findings and no severity", byID[subNoFindings.ID])
|
|
||||||
}
|
|
||||||
if byID[subWithFinding.ID].FindingCount != 1 || byID[subWithFinding.ID].HighestSeverity != "hoch" {
|
|
||||||
t.Errorf("subWithFinding summary = %+v, want 1 finding, severity hoch", byID[subWithFinding.ID])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,30 @@ func testDatabaseURL(t *testing.T) string {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
url := testDatabaseURL(t)
|
||||||
|
if err := store.Migrate(url); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
s, err := store.Open(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
||||||
|
func testAccountID(t *testing.T, s *store.Store) string {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
return acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
func TestMigrateAndOpen(t *testing.T) {
|
func TestMigrateAndOpen(t *testing.T) {
|
||||||
url := testDatabaseURL(t)
|
url := testDatabaseURL(t)
|
||||||
|
|
||||||
@@ -36,56 +60,11 @@ func TestMigrateAndOpen(t *testing.T) {
|
|||||||
err = s.Pool.QueryRow(context.Background(), `
|
err = s.Pool.QueryRow(context.Background(), `
|
||||||
SELECT count(*) FROM information_schema.tables
|
SELECT count(*) FROM information_schema.tables
|
||||||
WHERE table_schema = 'public' AND table_name = ANY($1)
|
WHERE table_schema = 'public' AND table_name = ANY($1)
|
||||||
`, []string{"submission", "asset", "extraction", "finding", "evidence_package", "participant"}).Scan(&tableCount)
|
`, []string{"account", "app_user", "session", "audit_log", "abteilung", "werkzeug", "werkzeug_sperre", "antrag"}).Scan(&tableCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query tables: %v", err)
|
t.Fatalf("query tables: %v", err)
|
||||||
}
|
}
|
||||||
if tableCount != 6 {
|
if tableCount != 8 {
|
||||||
t.Fatalf("expected 6 tables, got %d", tableCount)
|
t.Fatalf("expected 8 tables, got %d", tableCount)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindingIsAppendOnly(t *testing.T) {
|
|
||||||
url := testDatabaseURL(t)
|
|
||||||
|
|
||||||
if err := store.Migrate(url); err != nil {
|
|
||||||
t.Fatalf("Migrate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s, err := store.Open(context.Background(), url)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Open: %v", err)
|
|
||||||
}
|
|
||||||
defer s.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
acc, err := s.CreateAccount(ctx, "Test-Mandant")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create account: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var submissionID string
|
|
||||||
err = s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO submission (account_id, platform, post_type) VALUES ($1, 'instagram', 'reel')
|
|
||||||
RETURNING id
|
|
||||||
`, acc.ID).Scan(&submissionID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert submission: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var findingID string
|
|
||||||
err = s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources)
|
|
||||||
VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung', 'Testkorrektur', '{}')
|
|
||||||
RETURNING id
|
|
||||||
`, submissionID).Scan(&findingID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert finding: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.Pool.Exec(ctx, `UPDATE finding SET title = 'geändert' WHERE id = $1`, findingID)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected UPDATE on finding to be rejected, but it succeeded")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Submission ist ein eingereichter Beitrag. AccountID ist der Mandant,
|
|
||||||
// dem der Beitrag gehört (Mandantentrennung) — jede Abfrage, die einen
|
|
||||||
// Beitrag ausliefert, muss AccountID gegen den angemeldeten Account
|
|
||||||
// prüfen (siehe internal/web-Middleware), store selbst erzwingt das
|
|
||||||
// nicht auf Zeilenebene.
|
|
||||||
type Submission struct {
|
|
||||||
ID string
|
|
||||||
AccountID string
|
|
||||||
Platform string
|
|
||||||
PostType string
|
|
||||||
Caption string
|
|
||||||
Status string
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSubmission legt einen neuen Beitrag für einen Mandanten an
|
|
||||||
// (Status "draft").
|
|
||||||
func (s *Store) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (Submission, error) {
|
|
||||||
var sub Submission
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO submission (account_id, platform, post_type, caption)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, account_id, platform, post_type, caption, status, created_at, updated_at
|
|
||||||
`, accountID, platform, postType, caption).Scan(
|
|
||||||
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Submission{}, fmt.Errorf("store: create submission: %w", err)
|
|
||||||
}
|
|
||||||
return sub, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSubmission liest einen Beitrag anhand seiner ID — ohne
|
|
||||||
// Mandanten-Prüfung, das ist Sache des Aufrufers (siehe Submission.AccountID).
|
|
||||||
func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) {
|
|
||||||
var sub Submission
|
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
SELECT id, account_id, platform, post_type, caption, status, created_at, updated_at
|
|
||||||
FROM submission WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
|
||||||
)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Submission{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Submission{}, fmt.Errorf("store: get submission: %w", err)
|
|
||||||
}
|
|
||||||
return sub, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubmissionSummary ist eine Submission plus einer Kurzfassung ihrer
|
|
||||||
// aktuell gültigen Findings, wie sie eine Übersichtsliste braucht (ohne
|
|
||||||
// für jede Zeile extra ListCurrentFindings aufzurufen).
|
|
||||||
type SubmissionSummary struct {
|
|
||||||
Submission
|
|
||||||
FindingCount int
|
|
||||||
HighestSeverity string // "" wenn keine Findings
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListSubmissionsForAccount liefert alle Beiträge eines Mandanten,
|
|
||||||
// neueste zuerst, mit Findings-Kurzfassung.
|
|
||||||
func (s *Store) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]SubmissionSummary, error) {
|
|
||||||
rows, err := s.Pool.Query(ctx, `
|
|
||||||
SELECT
|
|
||||||
s.id, s.account_id, s.platform, s.post_type, s.caption, s.status, s.created_at, s.updated_at,
|
|
||||||
COUNT(f.id) AS finding_count,
|
|
||||||
COALESCE(MAX(CASE f.severity WHEN 'hoch' THEN 3 WHEN 'mittel' THEN 2 WHEN 'niedrig' THEN 1 ELSE 0 END), 0) AS severity_rank
|
|
||||||
FROM submission s
|
|
||||||
LEFT JOIN finding f
|
|
||||||
ON f.submission_id = s.id
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
|
|
||||||
WHERE s.account_id = $1
|
|
||||||
GROUP BY s.id
|
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
`, accountID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("store: list submissions for account: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []SubmissionSummary
|
|
||||||
for rows.Next() {
|
|
||||||
var sub SubmissionSummary
|
|
||||||
var severityRank int
|
|
||||||
if err := rows.Scan(
|
|
||||||
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
|
||||||
&sub.FindingCount, &severityRank,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: scan submission summary: %w", err)
|
|
||||||
}
|
|
||||||
switch severityRank {
|
|
||||||
case 3:
|
|
||||||
sub.HighestSeverity = "hoch"
|
|
||||||
case 2:
|
|
||||||
sub.HighestSeverity = "mittel"
|
|
||||||
case 1:
|
|
||||||
sub.HighestSeverity = "niedrig"
|
|
||||||
}
|
|
||||||
out = append(out, sub)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("store: list submissions for account: %w", err)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
|
|
||||||
// anders als extraction/finding/evidence_package, NICHT append-only —
|
|
||||||
// der Lebenszyklus draft → checked → published → archived ist eine
|
|
||||||
// normale Zustandsänderung, kein Beweis-Eintrag).
|
|
||||||
func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error {
|
|
||||||
tag, err := s.Pool.Exec(ctx, `
|
|
||||||
UPDATE submission SET status = $2, updated_at = now() WHERE id = $1
|
|
||||||
`, id, status)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("store: set submission status: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
301
internal/store/werkzeug.go
Normal file
301
internal/store/werkzeug.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Werkzeug ist ein Eintrag im KI-Werkzeugkatalog — der eigentliche Wert
|
||||||
|
// des Produkts (siehe CLAUDE.md). AccountID nil markiert einen zentral
|
||||||
|
// gepflegten, für alle Mandanten identischen Katalogeintrag; ein
|
||||||
|
// gesetzter AccountID ist eine mandantenspezifische Ergänzung.
|
||||||
|
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
|
||||||
|
// muss belegbar sein.
|
||||||
|
type Werkzeug struct {
|
||||||
|
ID string
|
||||||
|
AccountID *string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungsort string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
AVVURL string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
AufbewahrungTage int
|
||||||
|
Zertifizierungen []string
|
||||||
|
GeeigneteZwecke []string
|
||||||
|
Einschraenkungen []string
|
||||||
|
LetztePruefung time.Time
|
||||||
|
Quelle string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
||||||
|
// Create/Update — bei 14 Feldern lesbarer als eine positionale
|
||||||
|
// Parameterliste.
|
||||||
|
type WerkzeugInput struct {
|
||||||
|
AccountID *string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungsort string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
AVVURL string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
AufbewahrungTage int
|
||||||
|
Zertifizierungen []string
|
||||||
|
GeeigneteZwecke []string
|
||||||
|
Einschraenkungen []string
|
||||||
|
LetztePruefung time.Time
|
||||||
|
Quelle string
|
||||||
|
}
|
||||||
|
|
||||||
|
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||||
|
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
|
||||||
|
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||||
|
|
||||||
|
func scanWerkzeug(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Werkzeug, error) {
|
||||||
|
var w Werkzeug
|
||||||
|
err := row.Scan(
|
||||||
|
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
||||||
|
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
|
||||||
|
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||||
|
)
|
||||||
|
return w, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeWerkzeugSlices ersetzt nil-Slices durch leere Slices — die
|
||||||
|
// Spalten sind TEXT[] NOT NULL, ein nil-Slice (z. B. wenn eine
|
||||||
|
// Einschränkung optional ist) käme sonst als SQL-NULL an und würde mit
|
||||||
|
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
||||||
|
// Fall wie früher bei finding.sources).
|
||||||
|
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||||
|
if in.Zertifizierungen == nil {
|
||||||
|
in.Zertifizierungen = []string{}
|
||||||
|
}
|
||||||
|
if in.GeeigneteZwecke == nil {
|
||||||
|
in.GeeigneteZwecke = []string{}
|
||||||
|
}
|
||||||
|
if in.Einschraenkungen == nil {
|
||||||
|
in.Einschraenkungen = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||||
|
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||||
|
normalizeWerkzeugSlices(&in)
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO werkzeug (
|
||||||
|
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||||
|
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
|
||||||
|
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
RETURNING `+werkzeugColumns,
|
||||||
|
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||||
|
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||||
|
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||||
|
)
|
||||||
|
w, err := scanWerkzeug(row)
|
||||||
|
if err != nil {
|
||||||
|
return Werkzeug{}, fmt.Errorf("store: create werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWerkzeug ersetzt die Felder eines bestehenden Katalogeintrags
|
||||||
|
// (z. B. bei einer erneuten Prüfung der Zusicherungen). Werkzeug ist
|
||||||
|
// bewusst nicht append-only — anders als ein Beweisstück ist der
|
||||||
|
// Katalog ein gepflegter, sich änderender Datenbestand; eine Entscheidung
|
||||||
|
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
||||||
|
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
||||||
|
normalizeWerkzeugSlices(&in)
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE werkzeug SET
|
||||||
|
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||||
|
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
||||||
|
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
|
||||||
|
letzte_pruefung = $13, quelle = $14, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+werkzeugColumns,
|
||||||
|
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||||
|
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||||
|
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||||
|
)
|
||||||
|
w, err := scanWerkzeug(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Werkzeug{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Werkzeug{}, fmt.Errorf("store: update werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
||||||
|
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
||||||
|
w, err := scanWerkzeug(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Werkzeug{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Werkzeug{}, fmt.Errorf("store: get werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||||
|
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWerkzeugeForAccount liefert den für einen Mandanten sichtbaren
|
||||||
|
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
||||||
|
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||||
|
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT `+werkzeugColumns+` FROM werkzeug w
|
||||||
|
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
||||||
|
)) OR w.account_id = $1
|
||||||
|
ORDER BY w.name
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Werkzeug
|
||||||
|
for rows.Next() {
|
||||||
|
w, err := scanWerkzeug(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListZentraleWerkzeuge liefert den vollständigen zentralen Katalog
|
||||||
|
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
||||||
|
// Mandanten-Sperrungen.
|
||||||
|
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Werkzeug
|
||||||
|
for rows.Next() {
|
||||||
|
w, err := scanWerkzeug(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentKatalogVersion liefert eine reproduzierbare Kennung des
|
||||||
|
// aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird
|
||||||
|
// in jeder Bewertung/Entscheidung eingefroren, damit im Audit
|
||||||
|
// nachvollziehbar bleibt, mit welchem Katalogstand ein Vorschlag
|
||||||
|
// erzeugt wurde.
|
||||||
|
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||||
|
var count int
|
||||||
|
var lastUpdate time.Time
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
||||||
|
`).Scan(&count, &lastUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("store: current katalog version: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d-%d", count, lastUpdate.Unix()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugSperre ist die Sperrung eines (auch zentralen) Katalogeintrags
|
||||||
|
// durch einen einzelnen Mandanten — der zentrale Katalog selbst bleibt
|
||||||
|
// dabei unverändert.
|
||||||
|
type WerkzeugSperre struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
WerkzeugID string
|
||||||
|
Grund string
|
||||||
|
GesperrtAm time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||||
|
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||||
|
var sp WerkzeugSperre
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
||||||
|
`, accountID, werkzeugID, grund).Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm)
|
||||||
|
if err != nil {
|
||||||
|
return WerkzeugSperre{}, fmt.Errorf("store: create werkzeug sperre: %w", err)
|
||||||
|
}
|
||||||
|
return sp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
||||||
|
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
||||||
|
`, accountID, werkzeugID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete werkzeug sperre: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||||
|
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
||||||
|
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []WerkzeugSperre
|
||||||
|
for rows.Next() {
|
||||||
|
var sp WerkzeugSperre
|
||||||
|
if err := rows.Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan werkzeug sperre: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, sp)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
219
internal/store/werkzeug_test.go
Normal file
219
internal/store/werkzeug_test.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||||
|
return store.WerkzeugInput{
|
||||||
|
AccountID: accountID,
|
||||||
|
Name: name,
|
||||||
|
Anbieter: "Beispiel-Anbieter GmbH",
|
||||||
|
Verarbeitungsort: "EU",
|
||||||
|
AVVVerfuegbar: true,
|
||||||
|
AVVURL: "https://beispiel.example/avv",
|
||||||
|
TrainingOptOut: true,
|
||||||
|
TrainingStandard: true,
|
||||||
|
AufbewahrungTage: 30,
|
||||||
|
Zertifizierungen: []string{"ISO 27001"},
|
||||||
|
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||||
|
Einschraenkungen: nil,
|
||||||
|
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
||||||
|
Quelle: "https://beispiel.example/beleg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWerkzeugCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentrales Werkzeug"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug: %v", err)
|
||||||
|
}
|
||||||
|
if w.AccountID != nil {
|
||||||
|
t.Fatalf("expected AccountID nil fuer zentralen Katalogeintrag, got %v", w.AccountID)
|
||||||
|
}
|
||||||
|
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
||||||
|
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetWerkzeug(ctx, w.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWerkzeug: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Zentrales Werkzeug" {
|
||||||
|
t.Fatalf("Name = %q, want Zentrales Werkzeug", got.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||||
|
updateInput.AufbewahrungTage = 14
|
||||||
|
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateWerkzeug: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
|
||||||
|
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteWerkzeug(ctx, w.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteWerkzeug: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetWerkzeug(ctx, w.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateWerkzeugNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.UpdateWerkzeug(context.Background(), "00000000-0000-0000-0000-000000000000", testWerkzeugInput(nil, "x"))
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListWerkzeugeForAccountIncludesCentralAndOwnEntries(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral A"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug (zentral): %v", err)
|
||||||
|
}
|
||||||
|
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes Werkzeug"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anderer Mandant darf das eigene Werkzeug nicht sehen.
|
||||||
|
otherAcc := testAccountID(t, s)
|
||||||
|
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
|
||||||
|
}
|
||||||
|
for _, w := range otherList {
|
||||||
|
if w.ID == eigenes.ID {
|
||||||
|
t.Fatal("expected the other account's own werkzeug to stay isolated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWerkzeugeForAccount: %v", err)
|
||||||
|
}
|
||||||
|
byID := map[string]bool{}
|
||||||
|
for _, w := range list {
|
||||||
|
byID[w.ID] = true
|
||||||
|
}
|
||||||
|
if !byID[zentral.ID] || !byID[eigenes.ID] {
|
||||||
|
t.Fatalf("expected both the central and the own werkzeug, got %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWerkzeugSperreHidesCentralEntry(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zu sperrendes Werkzeug"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.CreateWerkzeugSperre(ctx, accID, zentral.ID, "vom Mandanten intern verboten"); err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeugSperre: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWerkzeugeForAccount: %v", err)
|
||||||
|
}
|
||||||
|
for _, w := range list {
|
||||||
|
if w.ID == zentral.ID {
|
||||||
|
t.Fatal("expected the sperred central werkzeug to be hidden for this account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der zentrale Katalog selbst bleibt fuer andere Mandanten sichtbar.
|
||||||
|
otherAcc := testAccountID(t, s)
|
||||||
|
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, w := range otherList {
|
||||||
|
if w.ID == zentral.ID {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected the central werkzeug to remain visible for a different account")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteWerkzeugSperre(ctx, accID, zentral.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteWerkzeugSperre: %v", err)
|
||||||
|
}
|
||||||
|
listAfter, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWerkzeugeForAccount nach Entsperren: %v", err)
|
||||||
|
}
|
||||||
|
found = false
|
||||||
|
for _, w := range listAfter {
|
||||||
|
if w.ID == zentral.ID {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected the werkzeug to be visible again after DeleteWerkzeugSperre")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentKatalogVersionChangesOnCreate(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before, err := s.CurrentKatalogVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CurrentKatalogVersion: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Version-Test-Werkzeug")); err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug: %v", err)
|
||||||
|
}
|
||||||
|
after, err := s.CurrentKatalogVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CurrentKatalogVersion (2): %v", err)
|
||||||
|
}
|
||||||
|
if before == after {
|
||||||
|
t.Fatalf("expected CurrentKatalogVersion to change after adding a werkzeug, got %q both times", before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListZentraleWerkzeugeExcludesMandantenEigene(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral B")); err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug (zentral): %v", err)
|
||||||
|
}
|
||||||
|
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes B"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListZentraleWerkzeuge(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListZentraleWerkzeuge: %v", err)
|
||||||
|
}
|
||||||
|
for _, w := range list {
|
||||||
|
if w.ID == eigenes.ID {
|
||||||
|
t.Fatal("expected ListZentraleWerkzeuge to exclude mandantenspezifische Eintraege")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type kanzleiListItem struct {
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
type kanzleiListData struct {
|
|
||||||
Title string
|
|
||||||
Kanzleien []kanzleiListItem
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePublicKanzleiList zeigt das öffentliche, kostenlose Kanzlei-
|
|
||||||
// Verzeichnis — keine Anmeldung nötig. Bewusst nur Name, kein Kontakt-
|
|
||||||
// Button/Routing (siehe CLAUDE.md, § 49b Abs. 3 BRAO): Nutzer wählen
|
|
||||||
// selbst, es gibt keine Vermittlung.
|
|
||||||
func (s *Server) handlePublicKanzleiList(w http.ResponseWriter, r *http.Request) {
|
|
||||||
accounts, err := s.store.ListVerifiedKanzleien(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Verzeichnis konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := kanzleiListData{Title: "Kanzlei-Verzeichnis"}
|
|
||||||
for _, a := range accounts {
|
|
||||||
data.Kanzleien = append(data.Kanzleien, kanzleiListItem{Name: a.Name})
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "kanzleien", data); err != nil {
|
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminDashboardData struct {
|
|
||||||
Title string
|
|
||||||
AccountCount int
|
|
||||||
UnverifiedCount int
|
|
||||||
RecentAuditCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdminDashboard zeigt eine kurze Übersicht als Einstieg in den
|
|
||||||
// Admin-Bereich.
|
|
||||||
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
accounts, err := s.store.ListAccounts(ctx)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unverified := 0
|
|
||||||
for _, a := range accounts {
|
|
||||||
if !a.Verified {
|
|
||||||
unverified++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := adminDashboardData{
|
|
||||||
Title: "Admin", AccountCount: len(accounts), UnverifiedCount: unverified, RecentAuditCount: len(auditLog),
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "admin-dashboard", data); err != nil {
|
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminAccountListItem struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
Verified bool
|
|
||||||
CreatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminAccountListData struct {
|
|
||||||
Title string
|
|
||||||
Accounts []adminAccountListItem
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdminAccountList listet alle Mandanten der Plattform.
|
|
||||||
func (s *Server) handleAdminAccountList(w http.ResponseWriter, r *http.Request) {
|
|
||||||
accounts, err := s.store.ListAccounts(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := adminAccountListData{Title: "Accounts"}
|
|
||||||
for _, a := range accounts {
|
|
||||||
data.Accounts = append(data.Accounts, adminAccountListItem{
|
|
||||||
ID: a.ID, Name: a.Name, Verified: a.Verified, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "admin-accounts", data); err != nil {
|
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminUserView struct {
|
|
||||||
Email string
|
|
||||||
Role string
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminAccountDetailData struct {
|
|
||||||
Title string
|
|
||||||
AccountID string
|
|
||||||
Name string
|
|
||||||
Verified bool
|
|
||||||
HasKanzlei bool
|
|
||||||
Users []adminUserView
|
|
||||||
CreatedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdminAccountDetail zeigt einen Mandanten mit seinen Logins und —
|
|
||||||
// falls mindestens ein Login die Rolle "kanzlei" hat — der Möglichkeit,
|
|
||||||
// die Freigabe fürs öffentliche Kanzlei-Verzeichnis zu setzen.
|
|
||||||
func (s *Server) handleAdminAccountDetail(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := adminAccountDetailData{
|
|
||||||
Title: "Account", AccountID: acc.ID, Name: acc.Name, Verified: acc.Verified,
|
|
||||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
|
||||||
}
|
|
||||||
for _, u := range users {
|
|
||||||
data.Users = append(data.Users, adminUserView{Email: u.Email, Role: u.Role})
|
|
||||||
if u.Role == "kanzlei" {
|
|
||||||
data.HasKanzlei = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "admin-account-detail", data); err != nil {
|
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdminSetVerified schaltet die Freigabe fürs Kanzlei-Verzeichnis
|
|
||||||
// um und protokolliert die Aktion im Audit-Log.
|
|
||||||
func (s *Server) handleAdminSetVerified(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
accountID := r.PathValue("id")
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
if _, err := s.store.GetAccount(ctx, accountID); err != nil {
|
|
||||||
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
verified := r.FormValue("verified") == "true"
|
|
||||||
|
|
||||||
updated, err := s.store.SetAccountVerified(ctx, accountID, verified)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Freigabe konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
action := "account.unverified"
|
|
||||||
details := "Kanzlei-Verzeichnis: Freigabe entzogen"
|
|
||||||
if updated.Verified {
|
|
||||||
action = "account.verified"
|
|
||||||
details = "Kanzlei-Verzeichnis: Freigabe erteilt"
|
|
||||||
}
|
|
||||||
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, action, "account", accountID, details); err != nil {
|
|
||||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Redirect(w, r, "/admin/accounts/"+accountID, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminAuditEntryView struct {
|
|
||||||
CreatedAt string
|
|
||||||
Action string
|
|
||||||
TargetType string
|
|
||||||
TargetID string
|
|
||||||
Details string
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminAuditLogData struct {
|
|
||||||
Title string
|
|
||||||
Entries []adminAuditEntryView
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdminAuditLog zeigt das Protokoll der Admin-Aktionen.
|
|
||||||
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
|
||||||
entries, err := s.store.ListAuditLog(r.Context(), 200)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := adminAuditLogData{Title: "Audit-Log"}
|
|
||||||
for _, e := range entries {
|
|
||||||
data.Entries = append(data.Entries, adminAuditEntryView{
|
|
||||||
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
|
||||||
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "admin-audit-log", data); err != nil {
|
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package web_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAdminRoutesRejectNonAdminWith404(t *testing.T) {
|
|
||||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
||||||
|
|
||||||
for _, path := range []string{"/admin", "/admin/accounts", "/admin/audit-log"} {
|
|
||||||
resp := getWithCookie(t, s, cookie, path)
|
|
||||||
if resp.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("GET %s status = %d, want 404 for a non-admin user", path, resp.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminRoutesRedirectToLoginWithoutSession(t *testing.T) {
|
|
||||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
|
||||||
|
|
||||||
resp := getWithCookie(t, s, nil, "/admin")
|
|
||||||
if resp.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminDashboardAccessibleForAdmin(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
|
||||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
|
||||||
|
|
||||||
resp := getWithCookie(t, s, adminCookie, "/admin")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
|
||||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
|
||||||
seedAccount(t, fs, "Mandant A", "a@example.com")
|
|
||||||
seedAccount(t, fs, "Mandant B", "b@example.com")
|
|
||||||
|
|
||||||
resp := getWithCookie(t, s, adminCookie, "/admin/accounts")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
body := resp.Body.String()
|
|
||||||
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Admin"} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Errorf("expected %q in the admin account list, got: %s", want, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminAccountDetailShowsUsersAndVerifyToggleOnlyForKanzlei(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
|
||||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
|
||||||
|
|
||||||
creatorCookie := seedAccount(t, fs, "Nur Creator", "creator@example.com")
|
|
||||||
_ = creatorCookie
|
|
||||||
var creatorAccID string
|
|
||||||
for id, acc := range fs.accounts {
|
|
||||||
if acc.Name == "Nur Creator" {
|
|
||||||
creatorAccID = id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kanzleiCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
|
|
||||||
_ = kanzleiCookie
|
|
||||||
var kanzleiAccID string
|
|
||||||
for id, acc := range fs.accounts {
|
|
||||||
if acc.Name == "Kanzlei Musterfrau" {
|
|
||||||
kanzleiAccID = id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
creatorResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+creatorAccID)
|
|
||||||
if creatorResp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", creatorResp.Code)
|
|
||||||
}
|
|
||||||
if strings.Contains(creatorResp.Body.String(), "verifizieren") {
|
|
||||||
t.Errorf("expected no verify action for a non-kanzlei account, got: %s", creatorResp.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
kanzleiResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID)
|
|
||||||
if kanzleiResp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d", kanzleiResp.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(kanzleiResp.Body.String(), "kanzlei@example.com") {
|
|
||||||
t.Errorf("expected the kanzlei user's email on the account detail page, got: %s", kanzleiResp.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(kanzleiResp.Body.String(), "/admin/accounts/"+kanzleiAccID+"/verifizieren") {
|
|
||||||
t.Errorf("expected a verify action for a kanzlei account, got: %s", kanzleiResp.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminVerifyAddsAccountToPublicDirectoryAndAuditLog(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
|
||||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
|
||||||
seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
|
|
||||||
|
|
||||||
var kanzleiAccID string
|
|
||||||
for id, acc := range fs.accounts {
|
|
||||||
if acc.Name == "Kanzlei Musterfrau" {
|
|
||||||
kanzleiAccID = id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vor der Freigabe taucht die Kanzlei nicht im oeffentlichen
|
|
||||||
// Verzeichnis auf.
|
|
||||||
before := getWithCookie(t, s, nil, "/kanzleien")
|
|
||||||
if strings.Contains(before.Body.String(), "Kanzlei Musterfrau") {
|
|
||||||
t.Fatalf("kanzlei should not be public before verification, got: %s", before.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyResp := postForm(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
|
|
||||||
if verifyResp.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("verify status = %d, want 303, body: %s", verifyResp.Code, verifyResp.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
after := getWithCookie(t, s, nil, "/kanzleien")
|
|
||||||
if !strings.Contains(after.Body.String(), "Kanzlei Musterfrau") {
|
|
||||||
t.Fatalf("expected the kanzlei to be listed publicly after verification, got: %s", after.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fs.auditLog) != 1 {
|
|
||||||
t.Fatalf("expected exactly one audit entry, got %d", len(fs.auditLog))
|
|
||||||
}
|
|
||||||
if fs.auditLog[0].Action != "account.verified" || fs.auditLog[0].TargetID != kanzleiAccID {
|
|
||||||
t.Errorf("unexpected audit entry: %+v", fs.auditLog[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
auditPageResp := getWithCookie(t, s, adminCookie, "/admin/audit-log")
|
|
||||||
if !strings.Contains(auditPageResp.Body.String(), "account.verified") {
|
|
||||||
t.Errorf("expected the audit entry on the audit log page, got: %s", auditPageResp.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminVerifyRejectsNonAdmin(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
|
||||||
tenantCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
|
|
||||||
|
|
||||||
var kanzleiAccID string
|
|
||||||
for id, acc := range fs.accounts {
|
|
||||||
if acc.Name == "Kanzlei Musterfrau" {
|
|
||||||
kanzleiAccID = id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := postForm(t, s, tenantCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
|
|
||||||
if resp.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("status = %d, want 404 for a non-admin actor", resp.Code)
|
|
||||||
}
|
|
||||||
if fs.accounts[kanzleiAccID].Verified {
|
|
||||||
t.Fatal("account should not have been verified by a non-admin request")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPublicKanzleiDirectoryRequiresNoLogin(t *testing.T) {
|
|
||||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{Platform: "instagram"}})
|
|
||||||
|
|
||||||
req := getWithCookie(t, s, nil, "/kanzleien")
|
|
||||||
if req.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200 without any session", req.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
213
internal/web/antrag_handlers.go
Normal file
213
internal/web/antrag_handlers.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten-
|
||||||
|
// Schlüsseln in rules/datenklasse.yaml (b1..b7) und
|
||||||
|
// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3
|
||||||
|
// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten,
|
||||||
|
// ohne sie erst umbenennen zu müssen.
|
||||||
|
var datenfragen = []struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
{"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"},
|
||||||
|
{"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"},
|
||||||
|
{"b3", "Daten von Patienten, Mandanten oder Versicherten"},
|
||||||
|
{"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"},
|
||||||
|
{"b5", "Daten, die uns ein Kunde anvertraut hat"},
|
||||||
|
{"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"},
|
||||||
|
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type antragFormData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Abteilungen []store.Abteilung
|
||||||
|
Datenfragen []struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen").
|
||||||
|
// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten —
|
||||||
|
// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als
|
||||||
|
// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive
|
||||||
|
// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet,
|
||||||
|
// ohne zusätzlichen Server-Roundtrip.
|
||||||
|
func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if currentUser(r).Role == "betreiber" {
|
||||||
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert
|
||||||
|
// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln.
|
||||||
|
// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT
|
||||||
|
// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja")
|
||||||
|
// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der
|
||||||
|
// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert
|
||||||
|
// erhalten.
|
||||||
|
func antwortenFromForm(r *http.Request) []byte {
|
||||||
|
m := map[string]any{}
|
||||||
|
for _, f := range datenfragen {
|
||||||
|
v := r.FormValue(f.Key)
|
||||||
|
if v == "" {
|
||||||
|
v = "nein"
|
||||||
|
}
|
||||||
|
m[f.Key] = v
|
||||||
|
}
|
||||||
|
m["c1"] = r.FormValue("c1") == "ja"
|
||||||
|
m["c2"] = r.FormValue("c2") == "ja"
|
||||||
|
m["c2_folge"] = r.FormValue("c2_folge")
|
||||||
|
m["c3"] = r.FormValue("c3") == "ja"
|
||||||
|
m["c3_art"] = r.FormValue("c3_art")
|
||||||
|
m["c4"] = r.FormValue("c4") == "ja"
|
||||||
|
m["c5"] = r.FormValue("c5") == "ja"
|
||||||
|
m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext")
|
||||||
|
m["d_zugang"] = r.FormValue("d_zugang")
|
||||||
|
m["d_geraet"] = r.FormValue("d_geraet")
|
||||||
|
|
||||||
|
data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein
|
||||||
|
// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine
|
||||||
|
// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht
|
||||||
|
// vorgesehen.
|
||||||
|
func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if currentUser(r).Role == "betreiber" {
|
||||||
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
titel := r.FormValue("titel")
|
||||||
|
beschreibung := r.FormValue("beschreibung")
|
||||||
|
ergebnis := r.FormValue("ergebnis")
|
||||||
|
haeufigkeit := r.FormValue("haeufigkeit")
|
||||||
|
if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" {
|
||||||
|
http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var abteilungID *string
|
||||||
|
if v := r.FormValue("abteilung_id"); v != "" {
|
||||||
|
abteilungID = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
user := currentUser(r)
|
||||||
|
|
||||||
|
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antwortenFromForm(r)); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type antragListItem struct {
|
||||||
|
ID string
|
||||||
|
Titel string
|
||||||
|
Status string
|
||||||
|
Haeufigkeit string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type antragListData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Antraege []antragListItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
||||||
|
// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten.
|
||||||
|
func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if currentUser(r).Role == "betreiber" {
|
||||||
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
|
||||||
|
for _, a := range list {
|
||||||
|
data.Antraege = append(data.Antraege, antragListItem{
|
||||||
|
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
||||||
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type antragDetailData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
ID string
|
||||||
|
Titel string
|
||||||
|
Beschreibung string
|
||||||
|
Ergebnis string
|
||||||
|
Haeufigkeit string
|
||||||
|
Status string
|
||||||
|
CreatedAt string
|
||||||
|
Antworten map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
||||||
|
// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender
|
||||||
|
// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe
|
||||||
|
// Muster wie schon beim alten Produkt für fremde Mandanten.
|
||||||
|
func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil || antrag.ErstellerUserID != currentUser(r).ID {
|
||||||
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var antworten map[string]any
|
||||||
|
if len(antrag.Antworten) > 0 {
|
||||||
|
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := antragDetailData{
|
||||||
|
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||||
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||||
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten,
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
internal/web/antrag_handlers_test.go
Normal file
136
internal/web/antrag_handlers_test.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fullAntragForm() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"titel": {"Angebotstexte generieren"}, "beschreibung": {"KI schreibt Angebotstexte"},
|
||||||
|
"ergebnis": {"Fertiger Text zur Freigabe"}, "haeufigkeit": {"gelegentlich"},
|
||||||
|
"b1": {"ja"}, "b2": {"nein"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
|
||||||
|
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
|
||||||
|
"c4": {"nein"}, "c5": {"ja"},
|
||||||
|
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragNewFormRendersFragebogen(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/antraege/neu")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
for _, want := range []string{`name="titel"`, `name="b1"`, `name="c2_folge"`, `name="d_zugang"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("expected %q in the Fragebogen, got: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
loc := resp.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(loc, "/antraege/") {
|
||||||
|
t.Fatalf("Location = %q, want a redirect to the new antrag", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if antrag.Status != "eingereicht" {
|
||||||
|
t.Fatalf("Status = %q, want eingereicht", antrag.Status)
|
||||||
|
}
|
||||||
|
var antworten map[string]any
|
||||||
|
if err := json.Unmarshal(antrag.Antworten, &antworten); err != nil {
|
||||||
|
t.Fatalf("Antworten unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if antworten["b1"] != "ja" {
|
||||||
|
t.Errorf("b1 = %v, want ja", antworten["b1"])
|
||||||
|
}
|
||||||
|
if antworten["c5"] != true {
|
||||||
|
t.Errorf("c5 = %v, want true", antworten["c5"])
|
||||||
|
}
|
||||||
|
|
||||||
|
detailResp := getWithCookie(t, s, cookie, loc)
|
||||||
|
if detailResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragCreateRejectsMissingFields(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", url.Values{"titel": {"Nur Titel"}})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for missing required fields", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragListShowsOnlyOwnAntraege(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||||
|
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||||
|
|
||||||
|
if resp := postForm(t, s, cookieA, "/antraege", fullAntragForm()); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("create (A) status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
listA := getWithCookie(t, s, cookieA, "/antraege")
|
||||||
|
if !strings.Contains(listA.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected Mandant A to see their own antrag, got: %s", listA.Body.String())
|
||||||
|
}
|
||||||
|
listB := getWithCookie(t, s, cookieB, "/antraege")
|
||||||
|
if strings.Contains(listB.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected Mandant B to see no antrag, got: %s", listB.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragDetailRejectsForeignUser(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||||
|
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||||
|
|
||||||
|
createResp := postForm(t, s, cookieA, "/antraege", fullAntragForm())
|
||||||
|
antragURL := createResp.Header().Get("Location")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookieB, antragURL)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign user's antrag", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
for _, path := range []string{"/antraege", "/antraege/neu"} {
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, path)
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("GET %s status = %d, want 303 redirect for a betreiber", path, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type submissionListItem struct {
|
|
||||||
ID string
|
|
||||||
Platform string
|
|
||||||
PostType string
|
|
||||||
Status string
|
|
||||||
CreatedAt string
|
|
||||||
FindingCount int
|
|
||||||
HighestSeverity string
|
|
||||||
}
|
|
||||||
|
|
||||||
type submissionListData struct {
|
|
||||||
Title string
|
|
||||||
Submissions []submissionListItem
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des
|
|
||||||
// angemeldeten Mandanten mit Kurzfassung der Findings.
|
|
||||||
func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := submissionListData{Title: "Beiträge"}
|
|
||||||
for _, sum := range summaries {
|
|
||||||
data.Submissions = append(data.Submissions, submissionListItem{
|
|
||||||
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
|
|
||||||
CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"),
|
|
||||||
FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil {
|
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type participantView struct {
|
|
||||||
ID string
|
|
||||||
Role string
|
|
||||||
Name string
|
|
||||||
Vorgegeben bool
|
|
||||||
Freigegeben bool
|
|
||||||
ApprovedAt string // leer, wenn noch nicht freigegeben
|
|
||||||
}
|
|
||||||
|
|
||||||
type submissionDetailData struct {
|
|
||||||
Title string
|
|
||||||
SubmissionID string
|
|
||||||
Platform string
|
|
||||||
PostType string
|
|
||||||
Caption string
|
|
||||||
Status string
|
|
||||||
CreatedAt string
|
|
||||||
CanArchive bool
|
|
||||||
IsPublished bool
|
|
||||||
DossierURL string
|
|
||||||
Findings []findingView
|
|
||||||
Participants []participantView
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
|
|
||||||
// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag
|
|
||||||
// eines anderen Accounts wird wie ein nicht existierender behandelt.
|
|
||||||
func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) {
|
|
||||||
sub, err := s.store.GetSubmission(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
return store.Submission{}, err
|
|
||||||
}
|
|
||||||
if sub.AccountID != currentUser(r).AccountID {
|
|
||||||
return store.Submission{}, store.ErrNotFound
|
|
||||||
}
|
|
||||||
return sub, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toParticipantViews(participants []store.Participant) []participantView {
|
|
||||||
views := make([]participantView, len(participants))
|
|
||||||
for i, p := range participants {
|
|
||||||
v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben}
|
|
||||||
if p.ApprovedAt != nil {
|
|
||||||
v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04")
|
|
||||||
}
|
|
||||||
views[i] = v
|
|
||||||
}
|
|
||||||
return views
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings,
|
|
||||||
// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung.
|
|
||||||
func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
findings := make([]findingView, len(storeFindings))
|
|
||||||
for i, f := range storeFindings {
|
|
||||||
findings[i] = findingView{
|
|
||||||
RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity,
|
|
||||||
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
participants, err := s.store.ListParticipants(ctx, sub.ID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := submissionDetailData{
|
|
||||||
Title: "Beitrag", SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
|
|
||||||
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
|
|
||||||
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
|
|
||||||
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
|
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type participantListData struct {
|
|
||||||
SubmissionID string
|
|
||||||
Participants []participantView
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für
|
|
||||||
// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu
|
|
||||||
// geladen werden muss.
|
|
||||||
func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) {
|
|
||||||
participants, err := s.store.ListParticipants(r.Context(), submissionID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil {
|
|
||||||
http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu.
|
|
||||||
func (s *Server) handleAddParticipant(w http.ResponseWriter, r *http.Request) {
|
|
||||||
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
role := r.FormValue("role")
|
|
||||||
name := r.FormValue("name")
|
|
||||||
if role == "" || name == "" {
|
|
||||||
http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil {
|
|
||||||
http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.renderParticipantList(w, r, sub.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich
|
|
||||||
// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein
|
|
||||||
// fremder Mandant über eine erratene participant_id einen Beteiligten
|
|
||||||
// eines anderen Accounts ändern oder löschen.
|
|
||||||
func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) {
|
|
||||||
p, err := s.store.GetParticipant(r.Context(), participantID)
|
|
||||||
if err != nil {
|
|
||||||
return store.Participant{}, err
|
|
||||||
}
|
|
||||||
if p.SubmissionID != submissionIDFromURL {
|
|
||||||
return store.Participant{}, store.ErrNotFound
|
|
||||||
}
|
|
||||||
if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil {
|
|
||||||
return store.Participant{}, err
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten.
|
|
||||||
func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
|
|
||||||
submissionID := r.PathValue("id")
|
|
||||||
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
vorgegeben := r.FormValue("vorgegeben") == "on"
|
|
||||||
freigegeben := r.FormValue("freigegeben") == "on"
|
|
||||||
|
|
||||||
if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil {
|
|
||||||
http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.renderParticipantList(w, r, submissionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteParticipant entfernt einen Beteiligten.
|
|
||||||
func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
|
|
||||||
submissionID := r.PathValue("id")
|
|
||||||
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil {
|
|
||||||
http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.renderParticipantList(w, r, submissionID)
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
package web_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die
|
|
||||||
// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen
|
|
||||||
// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal
|
|
||||||
// auszuschreiben.
|
|
||||||
func checkAndReturnSubID(t *testing.T, s interface {
|
|
||||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
|
||||||
}, cookie *http.Cookie) string {
|
|
||||||
t.Helper()
|
|
||||||
form := checkForm()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
body := w.Body.String()
|
|
||||||
const marker = `name="submission_id" value="`
|
|
||||||
idx := strings.Index(body, marker)
|
|
||||||
if idx == -1 {
|
|
||||||
t.Fatalf("expected a submission_id field in the result, got: %s", body)
|
|
||||||
}
|
|
||||||
rest := body[idx+len(marker):]
|
|
||||||
return rest[:strings.Index(rest, `"`)]
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmissionListShowsOwnSubmissionsOnly(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
|
||||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
||||||
}}, fs)
|
|
||||||
|
|
||||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
|
||||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
|
||||||
|
|
||||||
subA := checkAndReturnSubID(t, s, cookieA)
|
|
||||||
_ = checkAndReturnSubID(t, s, cookieB)
|
|
||||||
|
|
||||||
resp := getWithCookie(t, s, cookieA, "/beitraege")
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
body := resp.Body.String()
|
|
||||||
if !strings.Contains(body, "/beitraege/"+subA) {
|
|
||||||
t.Errorf("expected Mandant A's own submission link, got: %s", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mandant B hat genau einen eigenen Beitrag, keinen von A.
|
|
||||||
respB := getWithCookie(t, s, cookieB, "/beitraege")
|
|
||||||
if strings.Contains(respB.Body.String(), "/beitraege/"+subA) {
|
|
||||||
t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmissionListRequiresSession(t *testing.T) {
|
|
||||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/beitraege", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmissionDetailShowsFactsAndFindings(t *testing.T) {
|
|
||||||
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
|
||||||
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
|
||||||
}})
|
|
||||||
subID := checkAndReturnSubID(t, s, cookie)
|
|
||||||
_ = fs
|
|
||||||
|
|
||||||
resp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
|
||||||
if resp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
|
||||||
}
|
|
||||||
body := resp.Body.String()
|
|
||||||
if !strings.Contains(body, "WK-004") {
|
|
||||||
t.Errorf("expected the finding to be shown, got: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "/veroeffentlichen") {
|
|
||||||
t.Errorf("expected an archive option for a checked submission, got: %s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubmissionDetailTenantIsolation(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
|
||||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
||||||
}}, fs)
|
|
||||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
|
||||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
|
||||||
subA := checkAndReturnSubID(t, s, cookieA)
|
|
||||||
|
|
||||||
resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA)
|
|
||||||
if resp.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParticipantAddUpdateDeleteFlow(t *testing.T) {
|
|
||||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
||||||
}})
|
|
||||||
subID := checkAndReturnSubID(t, s, cookie)
|
|
||||||
|
|
||||||
addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{
|
|
||||||
"role": {"creator"}, "name": {"Max Mustermann"},
|
|
||||||
})
|
|
||||||
if addResp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(addResp.Body.String(), "Max Mustermann") {
|
|
||||||
t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
|
||||||
if !strings.Contains(detailResp.Body.String(), "Max Mustermann") {
|
|
||||||
t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beteiligten-ID aus dem Update-Formular extrahieren.
|
|
||||||
body := addResp.Body.String()
|
|
||||||
const marker = "/beteiligte/"
|
|
||||||
idx := strings.Index(body, marker)
|
|
||||||
if idx == -1 {
|
|
||||||
t.Fatalf("expected a participant action URL, got: %s", body)
|
|
||||||
}
|
|
||||||
rest := body[idx+len(marker):]
|
|
||||||
pID := rest[:strings.Index(rest, "/")]
|
|
||||||
|
|
||||||
updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{
|
|
||||||
"vorgegeben": {"on"}, "freigegeben": {"on"},
|
|
||||||
})
|
|
||||||
if updateResp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(updateResp.Body.String(), "freigegeben am") {
|
|
||||||
t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{})
|
|
||||||
if deleteResp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
|
||||||
}
|
|
||||||
if strings.Contains(deleteResp.Body.String(), "Max Mustermann") {
|
|
||||||
t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParticipantActionsRejectCrossTenantAccess(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
|
||||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
||||||
}}, fs)
|
|
||||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
|
||||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
|
||||||
subA := checkAndReturnSubID(t, s, cookieA)
|
|
||||||
|
|
||||||
// Mandant B darf für As Beitrag gar keinen Beteiligten anlegen.
|
|
||||||
addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{
|
|
||||||
"role": {"creator"}, "name": {"Fremd"},
|
|
||||||
})
|
|
||||||
if addResp.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateParticipant: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{
|
|
||||||
"vorgegeben": {"on"},
|
|
||||||
})
|
|
||||||
if updateResp.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{})
|
|
||||||
if deleteResp.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil {
|
|
||||||
t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,10 +26,13 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRegister legt einen neuen Mandanten (Account) und den ersten
|
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||||
// Nutzer darin an. Es gibt bewusst keinen separaten "Account beitreten"-
|
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
|
||||||
// Flow — das wäre Schritt 4 (Agentur-/Markensicht mit mehreren Nutzern
|
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
|
||||||
// pro Account), hier reicht ein Nutzer pro neuem Account.
|
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
|
||||||
|
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
|
||||||
|
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink
|
||||||
|
// (spätere Ausbaustufe), nicht über dieses Formular.
|
||||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
|
||||||
@@ -39,8 +42,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
accountName := r.FormValue("account_name")
|
accountName := r.FormValue("account_name")
|
||||||
email := r.FormValue("email")
|
email := r.FormValue("email")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
role := r.FormValue("role")
|
if accountName == "" || email == "" || password == "" {
|
||||||
if accountName == "" || email == "" || password == "" || role == "" {
|
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, role)
|
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
||||||
return
|
return
|
||||||
@@ -96,6 +98,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der
|
||||||
|
// Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch
|
||||||
|
// getrennt vom Mandantenbereich).
|
||||||
|
if user.Role == "betreiber" {
|
||||||
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
139
internal/web/betreiber_handlers.go
Normal file
139
internal/web/betreiber_handlers.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type betreiberDashboardData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
AccountCount int
|
||||||
|
RecentAuditCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberDashboard zeigt eine kurze Übersicht als Einstieg in
|
||||||
|
// den Plattform-Bereich (Ebene 5 — nur Betreiber, technisch getrennt
|
||||||
|
// vom Mandantenbereich).
|
||||||
|
func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
accounts, err := s.store.ListAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := betreiberDashboardData{
|
||||||
|
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog),
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAccountListItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAccountListData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Accounts []betreiberAccountListItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
||||||
|
func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accounts, err := s.store.ListAccounts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
|
||||||
|
for _, a := range accounts {
|
||||||
|
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
||||||
|
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-accounts", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberUserView struct {
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAccountDetailData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
AccountID string
|
||||||
|
Name string
|
||||||
|
Users []betreiberUserView
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountDetail zeigt einen Mandanten mit seinen Logins.
|
||||||
|
func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := betreiberAccountDetailData{
|
||||||
|
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name,
|
||||||
|
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAuditEntryView struct {
|
||||||
|
CreatedAt string
|
||||||
|
Action string
|
||||||
|
TargetType string
|
||||||
|
TargetID string
|
||||||
|
Details string
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAuditLogData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Entries []betreiberAuditEntryView
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
||||||
|
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := s.store.ListAuditLog(r.Context(), 200)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
|
||||||
|
for _, e := range entries {
|
||||||
|
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
||||||
|
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||||
|
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-audit-log", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/dossier"
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -21,254 +12,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
type indexData struct {
|
type indexData struct {
|
||||||
Title string
|
Title string
|
||||||
|
Nav navData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
|
||||||
|
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
|
||||||
|
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
|
||||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.templates.ExecuteTemplate(w, "index", indexData{Title: "Pre-Publish-Prüfung"}); err != nil {
|
data := indexData{Title: "Start", Nav: navFor(r)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type findingView struct {
|
|
||||||
RuleID string
|
|
||||||
Version int
|
|
||||||
Severity string
|
|
||||||
Title string
|
|
||||||
Fix string
|
|
||||||
Sources []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type resultData struct {
|
|
||||||
SubmissionID string
|
|
||||||
NeedsClarification bool
|
|
||||||
CanArchive bool
|
|
||||||
Findings []findingView
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1),
|
|
||||||
// Regelauswertung (Stufe 2), und speichert Submission + Extraction +
|
|
||||||
// Findings. Die eigentliche Archivierung (Hash, Zeitstempel, Dossier)
|
|
||||||
// passiert erst in handleArchive, wenn der Beitrag tatsächlich
|
|
||||||
// veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im
|
|
||||||
// Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2).
|
|
||||||
func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
platform := r.FormValue("platform")
|
|
||||||
postType := r.FormValue("post_type")
|
|
||||||
caption := r.FormValue("caption")
|
|
||||||
consideration := r.FormValue("consideration")
|
|
||||||
if platform == "" || postType == "" || caption == "" || consideration == "" {
|
|
||||||
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
accountID := currentUser(r).AccountID
|
|
||||||
|
|
||||||
result, err := s.extractor.Extract(ctx, extract.Input{
|
|
||||||
Platform: platform,
|
|
||||||
Jurisdiction: "DE",
|
|
||||||
Consideration: consideration,
|
|
||||||
Caption: caption,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sub, err := s.store.CreateSubmission(ctx, accountID, platform, postType, caption)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
findings, needsClarification := rules.Evaluate(s.ruleSet, result.Facts)
|
|
||||||
|
|
||||||
data := resultData{SubmissionID: sub.ID, NeedsClarification: needsClarification}
|
|
||||||
if !needsClarification {
|
|
||||||
extractionID := ext.ID
|
|
||||||
for _, f := range findings {
|
|
||||||
if _, err := s.store.CreateFinding(ctx, sub.ID, &extractionID, f.RuleID, f.RuleVersion, string(f.Severity), f.Title, f.Fix, f.Sources); err != nil {
|
|
||||||
http.Error(w, "Finding konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.Findings = append(data.Findings, findingView{
|
|
||||||
RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity),
|
|
||||||
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if err := s.store.SetSubmissionStatus(ctx, sub.ID, "checked"); err != nil {
|
|
||||||
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.CanArchive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "result", data); err != nil {
|
|
||||||
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type archivedData struct {
|
|
||||||
SubmissionID string
|
|
||||||
DossierURL string
|
|
||||||
TimestampedAt string
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleArchive markiert einen geprüften Beitrag als veröffentlicht und
|
|
||||||
// archiviert ihn: Hash der Metadaten, RFC-3161-Zeitstempel, PDF-Dossier.
|
|
||||||
// Setzt eine vorherige Prüfung voraus (Submission + Extraction +
|
|
||||||
// Findings müssen bereits existieren).
|
|
||||||
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submissionID := r.FormValue("submission_id")
|
|
||||||
if submissionID == "" {
|
|
||||||
http.Error(w, "submission_id ist Pflicht", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
sub, err := s.store.GetSubmission(ctx, submissionID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Mandantentrennung: ein Beitrag eines anderen Accounts wird wie ein
|
|
||||||
// nicht existierender behandelt, nicht mit einer 403 bestätigt —
|
|
||||||
// eine 403 würde einem anderen Mandanten verraten, dass die ID
|
|
||||||
// existiert.
|
|
||||||
if sub.AccountID != currentUser(r).AccountID {
|
|
||||||
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ext, err := s.store.GetLatestExtraction(ctx, submissionID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Extraktion nicht gefunden: "+err.Error(), http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
facts, err := extract.ParsePayload(ext.Payload, sub.Platform, "DE")
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "gespeicherte Extraktion konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
storeFindings, err := s.store.ListCurrentFindings(ctx, submissionID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Findings konnten nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dossierFindings := make([]rules.Finding, len(storeFindings))
|
|
||||||
for i, f := range storeFindings {
|
|
||||||
dossierFindings[i] = rules.Finding{
|
|
||||||
RuleID: f.RuleID, RuleVersion: f.RuleVersion, Severity: rules.Severity(f.Severity),
|
|
||||||
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataHash, err := evidence.HashMetadata(struct {
|
|
||||||
SubmissionID string
|
|
||||||
Platform string
|
|
||||||
PostType string
|
|
||||||
Caption string
|
|
||||||
Facts rules.Facts
|
|
||||||
Findings []rules.Finding
|
|
||||||
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timestampToken, err := s.timestamper.Timestamp(ctx, metadataHash)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Zeitstempel fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(s.dossierDir, 0o750); err != nil {
|
|
||||||
http.Error(w, "Dossier-Verzeichnis konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dossierPath := filepath.Join(s.dossierDir, submissionID+".pdf")
|
|
||||||
dossierFile, err := os.Create(dossierPath)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Dossier-Datei konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer dossierFile.Close()
|
|
||||||
|
|
||||||
genErr := dossier.Generate(dossierFile, dossier.Data{
|
|
||||||
Submission: dossier.Submission{
|
|
||||||
Platform: sub.Platform, PostType: sub.PostType, Caption: sub.Caption, CreatedAt: sub.CreatedAt,
|
|
||||||
},
|
|
||||||
Facts: facts,
|
|
||||||
Findings: dossierFindings,
|
|
||||||
MetadataHash: metadataHash,
|
|
||||||
TimestampToken: timestampToken,
|
|
||||||
GeneratedAt: time.Now(),
|
|
||||||
})
|
|
||||||
if genErr != nil {
|
|
||||||
http.Error(w, "Dossier konnte nicht erzeugt werden: "+genErr.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.store.CreateEvidencePackage(ctx, submissionID, dossierPath, hex.EncodeToString(metadataHash), timestampToken); err != nil {
|
|
||||||
http.Error(w, "Evidence-Package konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.SetSubmissionStatus(ctx, submissionID, "published"); err != nil {
|
|
||||||
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timestampedAt, err := evidence.TimestampTime(timestampToken)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Zeitstempel konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := archivedData{
|
|
||||||
SubmissionID: submissionID,
|
|
||||||
DossierURL: "/dossier/" + submissionID,
|
|
||||||
TimestampedAt: timestampedAt.Format("02.01.2006 15:04:05 MST"),
|
|
||||||
}
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "archived", data); err != nil {
|
|
||||||
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDossierDownload liefert das erzeugte PDF-Dossier eines
|
|
||||||
// archivierten Beitrags aus.
|
|
||||||
func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.PathValue("id")
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
sub, err := s.store.GetSubmission(ctx, id)
|
|
||||||
if err != nil || sub.AccountID != currentUser(r).AccountID {
|
|
||||||
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, err := s.store.GetLatestEvidencePackage(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/pdf")
|
|
||||||
http.ServeFile(w, r, pkg.DossierPath)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -68,19 +68,20 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireAdmin schützt den Admin-Bereich. Ohne Sitzung geht es wie bei
|
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
|
||||||
// requirePage zu /login; mit einer Sitzung, aber ohne Admin-Rolle, gibt
|
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
|
||||||
// es 404 statt 403 — sonst würde eine 403 einem angemeldeten, aber
|
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
|
||||||
// unprivilegierten Nutzer verraten, dass unter dieser URL überhaupt
|
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
|
||||||
// etwas existiert.
|
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
|
||||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||||
|
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := s.authenticate(r)
|
user, ok := s.authenticate(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user.Role != "admin" {
|
if user.Role != "betreiber" {
|
||||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -88,6 +89,21 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
|
||||||
|
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
|
||||||
|
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||||
|
// Plattform-Link gezeigt werden soll.
|
||||||
|
type navData struct {
|
||||||
|
IsBetreiber bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||||
|
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
|
||||||
|
// currentUser).
|
||||||
|
func navFor(r *http.Request) navData {
|
||||||
|
return navData{IsBetreiber: currentUser(r).Role == "betreiber"}
|
||||||
|
}
|
||||||
|
|
||||||
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
||||||
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
|
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
|
||||||
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
|
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
|
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
|
||||||
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
|
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
|
||||||
// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies
|
// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies
|
||||||
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
|
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
|
||||||
// Account — siehe middleware.go.
|
// Account — siehe middleware.go.
|
||||||
package web
|
package web
|
||||||
@@ -13,9 +13,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,42 +22,14 @@ var templatesFS embed.FS
|
|||||||
//go:embed static/*
|
//go:embed static/*
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
|
|
||||||
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
|
|
||||||
// Tests injizieren einen Fake, um Facts unabhängig von der echten
|
|
||||||
// Erkennungslogik vorzugeben.
|
|
||||||
type Extractor interface {
|
|
||||||
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
|
|
||||||
ModelVersion() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
|
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
|
||||||
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
|
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
|
||||||
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
|
|
||||||
GetSubmission(ctx context.Context, id string) (store.Submission, error)
|
|
||||||
SetSubmissionStatus(ctx context.Context, id, status string) error
|
|
||||||
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
|
|
||||||
GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error)
|
|
||||||
CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error)
|
|
||||||
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
|
|
||||||
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
|
|
||||||
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
|
|
||||||
ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error)
|
|
||||||
|
|
||||||
CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error)
|
|
||||||
ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error)
|
|
||||||
GetParticipant(ctx context.Context, id string) (store.Participant, error)
|
|
||||||
UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error)
|
|
||||||
DeleteParticipant(ctx context.Context, id string) error
|
|
||||||
|
|
||||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||||
ListAccounts(ctx context.Context) ([]store.Account, error)
|
ListAccounts(ctx context.Context) ([]store.Account, error)
|
||||||
ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error)
|
|
||||||
SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error)
|
|
||||||
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
||||||
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
||||||
GetUser(ctx context.Context, id string) (store.User, error)
|
GetUser(ctx context.Context, id string) (store.User, error)
|
||||||
@@ -70,34 +39,31 @@ type Store interface {
|
|||||||
DeleteSession(ctx context.Context, token string) error
|
DeleteSession(ctx context.Context, token string) error
|
||||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||||
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
||||||
|
|
||||||
|
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
|
||||||
|
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
|
||||||
|
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
|
||||||
|
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
||||||
|
SetAntragStatus(ctx context.Context, id, status string) error
|
||||||
|
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
extractor Extractor
|
|
||||||
ruleSet []rules.Rule
|
|
||||||
store Store
|
store Store
|
||||||
timestamper evidence.Timestamper
|
|
||||||
dossierDir string
|
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
// NewServer erstellt den Server.
|
||||||
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
|
func NewServer(st Store) (*Server, error) {
|
||||||
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden.
|
|
||||||
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) {
|
|
||||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
extractor: extractor,
|
|
||||||
ruleSet: ruleSet,
|
|
||||||
store: st,
|
store: st,
|
||||||
timestamper: timestamper,
|
|
||||||
dossierDir: dossierDir,
|
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,20 +75,14 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
|
|||||||
mux.HandleFunc("POST /login", s.handleLogin)
|
mux.HandleFunc("POST /login", s.handleLogin)
|
||||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||||
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
|
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
||||||
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
|
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
|
||||||
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
|
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
|
||||||
mux.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList))
|
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
|
||||||
mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail))
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||||
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
|
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||||
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList)
|
|
||||||
mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard))
|
|
||||||
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
|
|
||||||
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail))
|
|
||||||
mux.HandleFunc("POST /admin/accounts/{id}/verifizieren", s.requireAdmin(s.handleAdminSetVerified))
|
|
||||||
mux.HandleFunc("GET /admin/audit-log", s.requireAdmin(s.handleAdminAuditLog))
|
|
||||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
s.mux = mux
|
s.mux = mux
|
||||||
|
|
||||||
|
|||||||
@@ -2,57 +2,23 @@ package web_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/x509"
|
|
||||||
"crypto/x509/pkix"
|
|
||||||
"encoding/asn1"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/digitorus/timestamp"
|
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testSessionCookie = "deklarix_session"
|
const testSessionCookie = "deklarix_session"
|
||||||
|
|
||||||
// ─── Fakes ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type fakeExtractor struct {
|
|
||||||
facts rules.Facts
|
|
||||||
raw []byte
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (extract.Result, error) {
|
|
||||||
if f.err != nil {
|
|
||||||
return extract.Result{}, f.err
|
|
||||||
}
|
|
||||||
raw := f.raw
|
|
||||||
if raw == nil {
|
|
||||||
raw = []byte(`{}`)
|
|
||||||
}
|
|
||||||
return extract.Result{Facts: f.facts, RawJSON: raw}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeExtractor) ModelVersion() string { return "fake-model-v0" }
|
|
||||||
|
|
||||||
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
|
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
|
||||||
// Handler-Tests keine echte Postgres-Instanz brauchen.
|
// Handler-Tests keine echte Postgres-Instanz brauchen.
|
||||||
type fakeStore struct {
|
type fakeStore struct {
|
||||||
@@ -62,12 +28,9 @@ type fakeStore struct {
|
|||||||
users map[string]store.User
|
users map[string]store.User
|
||||||
usersByEmail map[string]string // email -> user id
|
usersByEmail map[string]string // email -> user id
|
||||||
sessions map[string]store.Session
|
sessions map[string]store.Session
|
||||||
submissions map[string]store.Submission
|
|
||||||
extractions map[string]store.Extraction
|
|
||||||
findings map[string][]store.Finding
|
|
||||||
evidencePkgs map[string]store.EvidencePackage
|
|
||||||
participants map[string]store.Participant
|
|
||||||
auditLog []store.AuditEntry
|
auditLog []store.AuditEntry
|
||||||
|
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
||||||
|
antraege map[string]store.Antrag
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeStore() *fakeStore {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -76,11 +39,8 @@ func newFakeStore() *fakeStore {
|
|||||||
users: map[string]store.User{},
|
users: map[string]store.User{},
|
||||||
usersByEmail: map[string]string{},
|
usersByEmail: map[string]string{},
|
||||||
sessions: map[string]store.Session{},
|
sessions: map[string]store.Session{},
|
||||||
submissions: map[string]store.Submission{},
|
abteilungen: map[string][]store.Abteilung{},
|
||||||
extractions: map[string]store.Extraction{},
|
antraege: map[string]store.Antrag{},
|
||||||
findings: map[string][]store.Finding{},
|
|
||||||
evidencePkgs: map[string]store.EvidencePackage{},
|
|
||||||
participants: map[string]store.Participant{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,78 +77,6 @@ func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
var out []store.Account
|
|
||||||
for _, acc := range f.accounts {
|
|
||||||
if !acc.Verified {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hasKanzlei := false
|
|
||||||
for _, u := range f.users {
|
|
||||||
if u.AccountID == acc.ID && u.Role == "kanzlei" {
|
|
||||||
hasKanzlei = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasKanzlei {
|
|
||||||
out = append(out, acc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
acc, ok := f.accounts[id]
|
|
||||||
if !ok {
|
|
||||||
return store.Account{}, store.ErrNotFound
|
|
||||||
}
|
|
||||||
acc.Verified = verified
|
|
||||||
f.accounts[id] = acc
|
|
||||||
return acc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
var out []store.User
|
|
||||||
for _, u := range f.users {
|
|
||||||
if u.AccountID == accountID {
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
e := store.AuditEntry{
|
|
||||||
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
|
|
||||||
TargetID: targetID, Details: details, CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
f.auditLog = append(f.auditLog, e)
|
|
||||||
return e, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
// Neueste zuerst, wie die echte Store-Implementierung (ORDER BY
|
|
||||||
// created_at DESC) — hier reicht eine Umkehrung der Einfuegereihenfolge.
|
|
||||||
out := make([]store.AuditEntry, len(f.auditLog))
|
|
||||||
for i, e := range f.auditLog {
|
|
||||||
out[len(f.auditLog)-1-i] = e
|
|
||||||
}
|
|
||||||
if len(out) > limit {
|
|
||||||
out = out[:limit]
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
|
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -224,6 +112,18 @@ func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error)
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.User
|
||||||
|
for _, u := range f.users {
|
||||||
|
if u.AccountID == accountID {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
|
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -249,271 +149,108 @@ func (f *fakeStore) DeleteSession(ctx context.Context, token string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error) {
|
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
sub := store.Submission{
|
e := store.AuditEntry{
|
||||||
ID: f.newID(), AccountID: accountID, Platform: platform, PostType: postType, Caption: caption,
|
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
|
||||||
Status: "draft", CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
TargetID: targetID, Details: details, CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
f.submissions[sub.ID] = sub
|
f.auditLog = append(f.auditLog, e)
|
||||||
return sub, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetSubmission(ctx context.Context, id string) (store.Submission, error) {
|
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
sub, ok := f.submissions[id]
|
out := make([]store.AuditEntry, len(f.auditLog))
|
||||||
if !ok {
|
for i, e := range f.auditLog {
|
||||||
return store.Submission{}, store.ErrNotFound
|
out[len(f.auditLog)-1-i] = e
|
||||||
}
|
}
|
||||||
return sub, nil
|
if len(out) > limit {
|
||||||
}
|
out = out[:limit]
|
||||||
|
|
||||||
func (f *fakeStore) SetSubmissionStatus(ctx context.Context, id, status string) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
sub, ok := f.submissions[id]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("fakeStore: submission %s nicht gefunden", id)
|
|
||||||
}
|
|
||||||
sub.Status = status
|
|
||||||
f.submissions[id] = sub
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
ext := store.Extraction{
|
|
||||||
ID: f.newID(), SubmissionID: submissionID, Payload: payload,
|
|
||||||
ModelVersion: modelVersion, PromptVersion: promptVersion, CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
f.extractions[submissionID] = ext
|
|
||||||
return ext, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
ext, ok := f.extractions[submissionID]
|
|
||||||
if !ok {
|
|
||||||
return store.Extraction{}, fmt.Errorf("fakeStore: keine Extraktion fuer %s", submissionID)
|
|
||||||
}
|
|
||||||
return ext, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
finding := store.Finding{
|
|
||||||
ID: f.newID(), SubmissionID: submissionID, ExtractionID: extractionID,
|
|
||||||
RuleID: ruleID, RuleVersion: ruleVersion, Severity: severity,
|
|
||||||
Title: title, Fix: fix, Sources: sources, CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
f.findings[submissionID] = append(f.findings[submissionID], finding)
|
|
||||||
return finding, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
return f.findings[submissionID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
pkg := store.EvidencePackage{
|
|
||||||
ID: f.newID(), SubmissionID: submissionID, DossierPath: dossierPath,
|
|
||||||
SHA256: sha256Hex, TimestampToken: timestampToken, CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
f.evidencePkgs[submissionID] = pkg
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
pkg, ok := f.evidencePkgs[submissionID]
|
|
||||||
if !ok {
|
|
||||||
return store.EvidencePackage{}, fmt.Errorf("fakeStore: kein evidence package fuer %s", submissionID)
|
|
||||||
}
|
|
||||||
return pkg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
var out []store.SubmissionSummary
|
|
||||||
for _, sub := range f.submissions {
|
|
||||||
if sub.AccountID != accountID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sum := store.SubmissionSummary{Submission: sub}
|
|
||||||
rank := 0
|
|
||||||
for _, finding := range f.findings[sub.ID] {
|
|
||||||
sum.FindingCount++
|
|
||||||
r := severityRank(finding.Severity)
|
|
||||||
if r > rank {
|
|
||||||
rank = r
|
|
||||||
sum.HighestSeverity = finding.Severity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, sum)
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func severityRank(severity string) int {
|
func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) {
|
||||||
switch severity {
|
|
||||||
case "hoch":
|
|
||||||
return 3
|
|
||||||
case "mittel":
|
|
||||||
return 2
|
|
||||||
case "niedrig":
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error) {
|
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
p := store.Participant{
|
return f.abteilungen[accountID], nil
|
||||||
ID: f.newID(), SubmissionID: submissionID, Role: role, Name: name,
|
|
||||||
Vorgegeben: vorgegeben, Freigegeben: freigegeben, CreatedAt: time.Now(),
|
|
||||||
}
|
|
||||||
if freigegeben {
|
|
||||||
now := time.Now()
|
|
||||||
p.ApprovedAt = &now
|
|
||||||
}
|
|
||||||
f.participants[p.ID] = p
|
|
||||||
return p, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error) {
|
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
var out []store.Participant
|
a := store.Antrag{
|
||||||
for _, p := range f.participants {
|
ID: f.newID(), AccountID: accountID, ErstellerUserID: erstellerUserID, AbteilungID: abteilungID,
|
||||||
if p.SubmissionID == submissionID {
|
Titel: titel, Status: "entwurf", Antworten: []byte(`{}`), CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
out = append(out, p)
|
|
||||||
}
|
}
|
||||||
}
|
f.antraege[a.ID] = a
|
||||||
return out, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) GetParticipant(ctx context.Context, id string) (store.Participant, error) {
|
func (f *fakeStore) GetAntrag(ctx context.Context, id string) (store.Antrag, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
p, ok := f.participants[id]
|
a, ok := f.antraege[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return store.Participant{}, store.ErrNotFound
|
return store.Antrag{}, store.ErrNotFound
|
||||||
}
|
}
|
||||||
return p, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error) {
|
func (f *fakeStore) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
p, ok := f.participants[id]
|
a, ok := f.antraege[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return store.Participant{}, store.ErrNotFound
|
return store.Antrag{}, store.ErrNotFound
|
||||||
}
|
}
|
||||||
p.Vorgegeben = vorgegeben
|
a.Titel, a.Beschreibung, a.Ergebnis, a.Haeufigkeit, a.Antworten = titel, beschreibung, ergebnis, haeufigkeit, antworten
|
||||||
p.Freigegeben = freigegeben
|
a.UpdatedAt = time.Now()
|
||||||
if freigegeben && p.ApprovedAt == nil {
|
f.antraege[id] = a
|
||||||
now := time.Now()
|
return a, nil
|
||||||
p.ApprovedAt = &now
|
|
||||||
}
|
|
||||||
f.participants[id] = p
|
|
||||||
return p, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) DeleteParticipant(ctx context.Context, id string) error {
|
func (f *fakeStore) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
if _, ok := f.participants[id]; !ok {
|
a, ok := f.antraege[id]
|
||||||
|
if !ok {
|
||||||
return store.ErrNotFound
|
return store.ErrNotFound
|
||||||
}
|
}
|
||||||
delete(f.participants, id)
|
a.Status = status
|
||||||
|
f.antraege[id] = a
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeTimestamper liefert einen offline erzeugten, strukturell gültigen
|
func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) {
|
||||||
// (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime
|
f.mu.Lock()
|
||||||
// ihn parsen kann, ohne eine echte TSA zu brauchen.
|
defer f.mu.Unlock()
|
||||||
type fakeTimestamper struct{}
|
var out []store.Antrag
|
||||||
|
for _, a := range f.antraege {
|
||||||
func (fakeTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) {
|
if a.ErstellerUserID == erstellerUserID {
|
||||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
out = append(out, a)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
now := time.Now()
|
|
||||||
certTemplate := &x509.Certificate{
|
|
||||||
SerialNumber: big.NewInt(1),
|
|
||||||
Subject: pkix.Name{CommonName: "deklarix-test-tsa"},
|
|
||||||
NotBefore: now.Add(-time.Hour),
|
|
||||||
NotAfter: now.Add(time.Hour),
|
|
||||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
||||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
|
|
||||||
}
|
}
|
||||||
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
|
return out, nil
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cert, err := x509.ParseCertificate(certDER)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ts := timestamp.Timestamp{
|
|
||||||
HashAlgorithm: crypto.SHA256,
|
|
||||||
HashedMessage: hash,
|
|
||||||
Time: now,
|
|
||||||
SerialNumber: big.NewInt(1),
|
|
||||||
Policy: asn1.ObjectIdentifier{1, 2, 3},
|
|
||||||
}
|
|
||||||
respDER, err := ts.CreateResponse(cert, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
parsed, err := timestamp.ParseResponse(respDER)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return parsed.RawToken, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Test-Setup ───────────────────────────────────────────────────────
|
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
func loadRealRules(t *testing.T) []rules.Rule {
|
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rs, err := rules.Load(os.DirFS("../../rules"))
|
s, err := web.NewServer(fs)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("rules.Load: %v", err)
|
|
||||||
}
|
|
||||||
return rs
|
|
||||||
}
|
|
||||||
|
|
||||||
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
|
|
||||||
t.Helper()
|
|
||||||
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedAccount legt direkt im fakeStore (ohne HTTP) einen Account, einen
|
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
||||||
// Nutzer und eine gültige Sitzung an und liefert das Session-Cookie.
|
// Account, einen Nutzer und eine gültige Sitzung an und liefert das
|
||||||
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
// Session-Cookie.
|
||||||
t.Helper()
|
|
||||||
return seedAccountWithRole(t, fs, accountName, email, "creator")
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -539,12 +276,15 @@ func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role s
|
|||||||
return &http.Cookie{Name: testSessionCookie, Value: token}
|
return &http.Cookie{Name: testSessionCookie, Value: token}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newAuthedTestServer ist der Standardfall für Tests, die sich nicht für
|
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
||||||
// Auth selbst interessieren: ein Server plus ein fertig angemeldeter Mandant.
|
t.Helper()
|
||||||
func newAuthedTestServer(t *testing.T, ex web.Extractor) (*web.Server, *fakeStore, *http.Cookie) {
|
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, ex, fs)
|
s := newServer(t, fs)
|
||||||
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
return s, fs, cookie
|
return s, fs, cookie
|
||||||
}
|
}
|
||||||
@@ -572,21 +312,10 @@ func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string
|
|||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkForm(extra ...string) url.Values {
|
|
||||||
v := url.Values{
|
|
||||||
"platform": {"instagram"}, "post_type": {"reel"},
|
|
||||||
"consideration": {"bezahlt"}, "caption": {"Werbung: ..."},
|
|
||||||
}
|
|
||||||
for i := 0; i+1 < len(extra); i += 2 {
|
|
||||||
v.Set(extra[i], extra[i+1])
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
|
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
|
||||||
|
|
||||||
func TestHandleHealth(t *testing.T) {
|
func TestHandleHealth(t *testing.T) {
|
||||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
s, _, _ := newAuthedTestServer(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -601,7 +330,7 @@ func TestHandleHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleStaticServesHTMX(t *testing.T) {
|
func TestHandleStaticServesHTMX(t *testing.T) {
|
||||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
s, _, _ := newAuthedTestServer(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
|
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -619,11 +348,11 @@ func TestHandleStaticServesHTMX(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
s := newServer(t, fs)
|
||||||
|
|
||||||
regResp := postForm(t, s, nil, "/register", url.Values{
|
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||||
"account_name": {"Meine Agentur"}, "email": {"neu@example.com"},
|
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
|
||||||
"password": {"ein-sicheres-passwort"}, "role": {"agentur"},
|
"password": {"ein-sicheres-passwort"},
|
||||||
})
|
})
|
||||||
if regResp.Code != http.StatusSeeOther {
|
if regResp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||||
@@ -633,19 +362,26 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
|||||||
t.Fatal("expected a session cookie to be set after registration")
|
t.Fatal("expected a session cookie to be set after registration")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mit dem gesetzten Cookie muss die geschützte Startseite erreichbar sein.
|
|
||||||
indexResp := getWithCookie(t, s, cookies[0], "/")
|
indexResp := getWithCookie(t, s, cookies[0], "/")
|
||||||
if indexResp.Code != http.StatusOK {
|
if indexResp.Code != http.StatusOK {
|
||||||
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
|
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var created store.User
|
||||||
|
for _, u := range fs.users {
|
||||||
|
created = u
|
||||||
|
}
|
||||||
|
if created.Role != "admin" {
|
||||||
|
t.Fatalf("expected the first user of a new company to have role admin, got %q", created.Role)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
s := newServer(t, fs)
|
||||||
form := url.Values{
|
form := url.Values{
|
||||||
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
||||||
"password": {"ein-sicheres-passwort"}, "role": {"creator"},
|
"password": {"ein-sicheres-passwort"},
|
||||||
}
|
}
|
||||||
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("first register status = %d, want 303", resp.Code)
|
t.Fatalf("first register status = %d, want 303", resp.Code)
|
||||||
@@ -661,7 +397,7 @@ func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoginWithCorrectPassword(t *testing.T) {
|
func TestLoginWithCorrectPassword(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
s := newServer(t, fs)
|
||||||
hash, err := auth.HashPassword("richtiges-passwort")
|
hash, err := auth.HashPassword("richtiges-passwort")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword: %v", err)
|
t.Fatalf("HashPassword: %v", err)
|
||||||
@@ -670,7 +406,7 @@ func TestLoginWithCorrectPassword(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "marke"); err != nil {
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "mitarbeiter"); err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,15 +419,39 @@ func TestLoginWithCorrectPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
hash, err := auth.HashPassword("admin-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "betreiber@example.com", hash, "betreiber"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"betreiber@example.com"}, "password": {"admin-passwort"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if loc := resp.Header().Get("Location"); loc != "/betreiber" {
|
||||||
|
t.Fatalf("Location = %q, want /betreiber", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginRejectsWrongPassword(t *testing.T) {
|
func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
s := newServer(t, fs)
|
||||||
hash, err := auth.HashPassword("richtiges-passwort")
|
hash, err := auth.HashPassword("richtiges-passwort")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("HashPassword: %v", err)
|
t.Fatalf("HashPassword: %v", err)
|
||||||
}
|
}
|
||||||
acc, _ := fs.CreateAccount(context.Background(), "X")
|
acc, _ := fs.CreateAccount(context.Background(), "X")
|
||||||
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "marke"); err != nil {
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
|
||||||
t.Fatalf("CreateUser: %v", err)
|
t.Fatalf("CreateUser: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,7 +466,7 @@ func TestLoginRejectsWrongPassword(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
|
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fakeExtractor{}, fs)
|
s := newServer(t, fs)
|
||||||
|
|
||||||
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
|
||||||
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
|
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
|
||||||
@@ -715,7 +475,7 @@ func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
|
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
|
||||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
s, _, _ := newAuthedTestServer(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -729,17 +489,8 @@ func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckRejectsRequestsWithoutSession(t *testing.T) {
|
|
||||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
|
||||||
|
|
||||||
w := postForm(t, s, nil, "/pruefen", checkForm())
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("status = %d, want 401 without a session", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogoutClearsSession(t *testing.T) {
|
func TestLogoutClearsSession(t *testing.T) {
|
||||||
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{})
|
s, fs, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
|
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
|
||||||
t.Fatalf("index before logout = %d, want 200", resp.Code)
|
t.Fatalf("index before logout = %d, want 200", resp.Code)
|
||||||
@@ -758,226 +509,106 @@ func TestLogoutClearsSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tests: Pre-Publish-Prüfung (angemeldet) ──────────────────────────
|
func TestHandleIndexRendersForMitarbeiter(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
func TestHandleIndexRendersForm(t *testing.T) {
|
|
||||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
||||||
|
|
||||||
resp := getWithCookie(t, s, cookie, "/")
|
resp := getWithCookie(t, s, cookie, "/")
|
||||||
if resp.Code != http.StatusOK {
|
if resp.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", resp.Code)
|
t.Fatalf("status = %d, want 200", resp.Code)
|
||||||
}
|
}
|
||||||
body := resp.Body.String()
|
}
|
||||||
for _, want := range []string{`name="platform"`, `name="post_type"`, `name="caption"`, `hx-post="/pruefen"`} {
|
|
||||||
if !strings.Contains(body, want) {
|
// ─── Tests: Plattform-Bereich (nur Betreiber) ─────────────────────────
|
||||||
t.Errorf("index body missing %q\nbody: %s", want, body)
|
|
||||||
|
func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} {
|
||||||
|
resp := getWithCookie(t, s, cookie, path)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleCheckRejectsMissingFields(t *testing.T) {
|
func TestBetreiberRoutesRedirectToLoginWithoutSession(t *testing.T) {
|
||||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
s, _, _ := newAuthedTestServer(t)
|
||||||
|
|
||||||
w := postForm(t, s, cookie, "/pruefen", url.Values{"platform": {"instagram"}})
|
resp := getWithCookie(t, s, nil, "/betreiber")
|
||||||
if w.Code != http.StatusBadRequest {
|
if resp.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("status = %d, want 400 for missing fields", w.Code)
|
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleCheckPropagatesExtractionError(t *testing.T) {
|
func TestBetreiberDashboardAccessibleForBetreiber(t *testing.T) {
|
||||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")})
|
|
||||||
|
|
||||||
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
|
||||||
if w.Code != http.StatusBadGateway {
|
|
||||||
t.Fatalf("status = %d, want 502 when extraction fails", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) {
|
|
||||||
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
|
||||||
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
|
||||||
}, raw: []byte(`{"gegenleistung":"bezahlt"}`)})
|
|
||||||
|
|
||||||
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(w.Body.String(), "WK-004") {
|
|
||||||
t.Fatalf("expected WK-004 in result, got: %s", w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fs.submissions) != 1 {
|
|
||||||
t.Fatalf("expected 1 persisted submission, got %d", len(fs.submissions))
|
|
||||||
}
|
|
||||||
var subID string
|
|
||||||
for id, sub := range fs.submissions {
|
|
||||||
subID = id
|
|
||||||
if sub.Status != "checked" {
|
|
||||||
t.Errorf("submission status = %q, want checked", sub.Status)
|
|
||||||
}
|
|
||||||
if sub.AccountID == "" {
|
|
||||||
t.Error("expected the submission to carry the current account's ID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := fs.extractions[subID]; !ok {
|
|
||||||
t.Error("expected an extraction to be persisted")
|
|
||||||
}
|
|
||||||
if len(fs.findings[subID]) != 1 || fs.findings[subID][0].RuleID != "WK-004" {
|
|
||||||
t.Errorf("expected exactly one persisted WK-004 finding, got %+v", fs.findings[subID])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T) {
|
|
||||||
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear,
|
|
||||||
}})
|
|
||||||
|
|
||||||
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
|
||||||
body := w.Body.String()
|
|
||||||
if strings.Contains(body, "WK-") {
|
|
||||||
t.Errorf("expected no rule findings for an unclear extraction, got: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "nicht sicher bestimmt") {
|
|
||||||
t.Errorf("expected a clarification message, got: %s", body)
|
|
||||||
}
|
|
||||||
if strings.Contains(body, "/veroeffentlichen") {
|
|
||||||
t.Errorf("expected no archive option when clarification is needed, got: %s", body)
|
|
||||||
}
|
|
||||||
for _, sub := range fs.submissions {
|
|
||||||
if sub.Status == "checked" {
|
|
||||||
t.Error("submission should not be marked checked when clarification is needed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) {
|
|
||||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
|
||||||
}})
|
|
||||||
|
|
||||||
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
body := w.Body.String()
|
|
||||||
if strings.Contains(body, "WK-") {
|
|
||||||
t.Errorf("expected no findings for an organic post, got: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "Keine Kennzeichnungsrisiken") {
|
|
||||||
t.Errorf("expected the no-findings message, got: %s", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "/veroeffentlichen") {
|
|
||||||
t.Errorf("expected an archive option even with no findings (still a real check), got: %s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tests: Archivierung + Mandantentrennung ──────────────────────────
|
|
||||||
|
|
||||||
func TestFullCheckThenArchiveFlow(t *testing.T) {
|
|
||||||
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
|
||||||
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
|
||||||
}, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)})
|
|
||||||
|
|
||||||
checkResp := postForm(t, s, cookie, "/pruefen", checkForm())
|
|
||||||
if checkResp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("check status = %d, body: %s", checkResp.Code, checkResp.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(checkResp.Body.String(), `name="submission_id"`) {
|
|
||||||
t.Fatalf("expected a hidden submission_id field in the result, got: %s", checkResp.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var subID string
|
|
||||||
for id := range fs.submissions {
|
|
||||||
subID = id
|
|
||||||
}
|
|
||||||
|
|
||||||
archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
|
||||||
if archiveResp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(archiveResp.Body.String(), "/dossier/"+subID) {
|
|
||||||
t.Fatalf("expected a dossier download link, got: %s", archiveResp.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, ok := fs.evidencePkgs[subID]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected an evidence package to be persisted")
|
|
||||||
}
|
|
||||||
if fs.submissions[subID].Status != "published" {
|
|
||||||
t.Errorf("submission status = %q, want published", fs.submissions[subID].Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil)
|
|
||||||
downloadReq.AddCookie(cookie)
|
|
||||||
downloadReq.SetPathValue("id", subID)
|
|
||||||
downloadW := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(downloadW, downloadReq)
|
|
||||||
if downloadW.Code != http.StatusOK {
|
|
||||||
t.Fatalf("download status = %d", downloadW.Code)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(downloadW.Body.String(), "%PDF-") {
|
|
||||||
t.Fatal("downloaded dossier does not start with the PDF header")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(pkg.DossierPath); err != nil {
|
|
||||||
t.Fatalf("dossier file does not exist on disk: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleArchiveRejectsUnknownSubmission(t *testing.T) {
|
|
||||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
|
||||||
|
|
||||||
w := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {"does-not-exist"}})
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("status = %d, want 404 for an unknown submission", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTenantIsolationArchiveAndDownload(t *testing.T) {
|
|
||||||
fs := newFakeStore()
|
fs := newFakeStore()
|
||||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
s := newServer(t, fs)
|
||||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)}, fs)
|
|
||||||
|
|
||||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber")
|
||||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||||
checkResp := postForm(t, s, cookieA, "/pruefen", checkForm())
|
|
||||||
if checkResp.Code != http.StatusOK {
|
|
||||||
t.Fatalf("check status = %d", checkResp.Code)
|
|
||||||
}
|
}
|
||||||
var subID string
|
|
||||||
for id, sub := range fs.submissions {
|
|
||||||
if sub.AccountID != "" {
|
|
||||||
subID = id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if subID == "" {
|
|
||||||
t.Fatal("expected a submission to have been created for Mandant A")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mandant B darf As Beitrag weder archivieren noch dessen Dossier
|
func TestBetreiberAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
|
||||||
// abrufen — beides muss wie "nicht gefunden" aussehen, nicht wie ein
|
fs := newFakeStore()
|
||||||
// expliziter Zugriffsfehler.
|
s := newServer(t, fs)
|
||||||
archiveResp := postForm(t, s, cookieB, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
if archiveResp.Code != http.StatusNotFound {
|
seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||||
t.Fatalf("cross-tenant archive status = %d, want 404", archiveResp.Code)
|
seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Betreiber"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("expected %q in the betreiber account list, got: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Damit A tatsächlich etwas zum Herunterladen hat: A archiviert selbst.
|
func TestBetreiberAccountDetailShowsUsers(t *testing.T) {
|
||||||
ownArchiveResp := postForm(t, s, cookieA, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
fs := newFakeStore()
|
||||||
if ownArchiveResp.Code != http.StatusOK {
|
s := newServer(t, fs)
|
||||||
t.Fatalf("own archive status = %d, body: %s", ownArchiveResp.Code, ownArchiveResp.Body.String())
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Mandant A", "mitarbeiter-a@example.com")
|
||||||
|
|
||||||
|
var accID string
|
||||||
|
for id, acc := range fs.accounts {
|
||||||
|
if acc.Name == "Mandant A" {
|
||||||
|
accID = id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil)
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+accID)
|
||||||
downloadReq.AddCookie(cookieB)
|
if resp.Code != http.StatusOK {
|
||||||
downloadReq.SetPathValue("id", subID)
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
downloadW := httptest.NewRecorder()
|
}
|
||||||
s.ServeHTTP(downloadW, downloadReq)
|
if !strings.Contains(resp.Body.String(), "mitarbeiter-a@example.com") {
|
||||||
if downloadW.Code != http.StatusNotFound {
|
t.Errorf("expected the user's email on the account detail page, got: %s", resp.Body.String())
|
||||||
t.Fatalf("cross-tenant download status = %d, want 404", downloadW.Code)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberAuditLogShowsEntries(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateAuditEntry(context.Background(), betreiber.ID, "werkzeug.aktualisiert", "werkzeug", "w1", "manuell geprüft"); err != nil {
|
||||||
|
t.Fatalf("CreateAuditEntry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/audit-log")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "werkzeug.aktualisiert") {
|
||||||
|
t.Errorf("expected the audit entry on the audit log page, got: %s", resp.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,6 +384,46 @@ button.entfernen {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fragebogen fieldset {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragebogen legend {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragebogen fieldset label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 4px 12px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragebogen fieldset input[type="radio"] {
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adaptive Folgefragen: rein clientseitig per :has() ein-/ausgeblendet,
|
||||||
|
kein JavaScript nötig. Siehe templates/antrag_neu.html — die "Ja"-
|
||||||
|
Antwort trägt eine eigene Klasse (c2-ja/c3-ja), damit sich die
|
||||||
|
jeweilige Folgefrage gezielt zuordnen lässt. */
|
||||||
|
.mit-folgefrage .folgefrage {
|
||||||
|
display: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mit-folgefrage:has(.c2-ja input:checked) .folgefrage,
|
||||||
|
.mit-folgefrage:has(.c3-ja input:checked) .folgefrage {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
||||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
{{define "admin-account-detail"}}<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>{{template "head" .}}</head>
|
|
||||||
<body>
|
|
||||||
{{template "nav" .}}
|
|
||||||
<div class="page">
|
|
||||||
<p><a href="/admin/accounts">← Alle Accounts</a></p>
|
|
||||||
<h1>{{.Name}}</h1>
|
|
||||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
|
||||||
|
|
||||||
<h2>Nutzer</h2>
|
|
||||||
<ul class="beteiligte">
|
|
||||||
{{range .Users}}
|
|
||||||
<li class="beteiligter">
|
|
||||||
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{{if .HasKanzlei}}
|
|
||||||
<h2>Kanzlei-Verzeichnis</h2>
|
|
||||||
{{if .Verified}}
|
|
||||||
<p>Status: <span class="status status-published">verifiziert — im öffentlichen Verzeichnis gelistet</span></p>
|
|
||||||
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
|
|
||||||
<input type="hidden" name="verified" value="false">
|
|
||||||
<button type="submit">Freigabe entziehen</button>
|
|
||||||
</form>
|
|
||||||
{{else}}
|
|
||||||
<p>Status: <span class="status status-mittel">nicht verifiziert — noch nicht gelistet</span></p>
|
|
||||||
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
|
|
||||||
<input type="hidden" name="verified" value="true">
|
|
||||||
<button type="submit">Fürs Verzeichnis freigeben</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{{define "admin-dashboard"}}<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>{{template "head" .}}</head>
|
|
||||||
<body>
|
|
||||||
{{template "nav" .}}
|
|
||||||
<div class="page">
|
|
||||||
<h1>Admin</h1>
|
|
||||||
<ul class="admin-kacheln">
|
|
||||||
<li><a href="/admin/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
|
|
||||||
<li><a href="/admin/accounts">Unverifizierte Kanzleien <span class="status status-mittel">{{.UnverifiedCount}}</span></a></li>
|
|
||||||
<li><a href="/admin/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
|
|
||||||
<li><a href="/kanzleien">Öffentliches Kanzlei-Verzeichnis ansehen</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
26
internal/web/templates/antraege_liste.html
Normal file
26
internal/web/templates/antraege_liste.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{{define "antraege-liste"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<h1>Meine Anträge</h1>
|
||||||
|
{{if not .Antraege}}
|
||||||
|
<p class="hinweis">Noch keine Anträge gestellt.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Antraege}}
|
||||||
|
<li>
|
||||||
|
<a href="/antraege/{{.ID}}">
|
||||||
|
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
|
||||||
|
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
24
internal/web/templates/antrag_detail.html
Normal file
24
internal/web/templates/antrag_detail.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{{define "antrag-detail"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/antraege">← Meine Anträge</a></p>
|
||||||
|
<h1>{{.Titel}}</h1>
|
||||||
|
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
||||||
|
|
||||||
|
<h2>Vorhaben</h2>
|
||||||
|
<p><strong>Was soll die KI tun?</strong><br>{{.Beschreibung}}</p>
|
||||||
|
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
|
||||||
|
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
|
||||||
|
|
||||||
|
<p class="hinweis">
|
||||||
|
Die Bewertung (Datenklasse, KI-VO-Einstufung, Werkzeugvorschlag) folgt
|
||||||
|
in einer späteren Ausbaustufe — dieser Antrag ist gespeichert und
|
||||||
|
eingereicht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
122
internal/web/templates/antrag_neu.html
Normal file
122
internal/web/templates/antrag_neu.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{{define "antrag-neu"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<h1>Antrag stellen</h1>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/antraege" class="fragebogen">
|
||||||
|
<h2>Vorhaben</h2>
|
||||||
|
<label for="titel">Kurzbezeichnung</label>
|
||||||
|
<input type="text" id="titel" name="titel" required>
|
||||||
|
|
||||||
|
<label for="beschreibung">Was soll die KI tun?</label>
|
||||||
|
<textarea id="beschreibung" name="beschreibung" rows="3" required></textarea>
|
||||||
|
|
||||||
|
<label for="ergebnis">Was soll herauskommen?</label>
|
||||||
|
<textarea id="ergebnis" name="ergebnis" rows="2" required></textarea>
|
||||||
|
|
||||||
|
<label for="haeufigkeit">Häufigkeit</label>
|
||||||
|
<select id="haeufigkeit" name="haeufigkeit" required>
|
||||||
|
<option value="einmalig">einmalig</option>
|
||||||
|
<option value="gelegentlich">gelegentlich</option>
|
||||||
|
<option value="taeglich">täglich</option>
|
||||||
|
<option value="automatisiert">automatisiert</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{{if .Abteilungen}}
|
||||||
|
<label for="abteilung_id">Abteilung</label>
|
||||||
|
<select id="abteilung_id" name="abteilung_id">
|
||||||
|
<option value="">— keine Angabe —</option>
|
||||||
|
{{range .Abteilungen}}
|
||||||
|
<option value="{{.ID}}">{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Daten</h2>
|
||||||
|
<p class="hinweis">Bei Unsicherheit "Unsicher" wählen — das wird wie "Ja" behandelt.</p>
|
||||||
|
{{range .Datenfragen}}
|
||||||
|
<fieldset class="datenfrage">
|
||||||
|
<legend>{{.Label}}</legend>
|
||||||
|
<label><input type="radio" name="{{.Key}}" value="ja" required> Ja</label>
|
||||||
|
<label><input type="radio" name="{{.Key}}" value="nein"> Nein</label>
|
||||||
|
<label><input type="radio" name="{{.Key}}" value="unsicher"> Unsicher</label>
|
||||||
|
</fieldset>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h2>Verwendung des Ergebnisses</h2>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Wird das Ergebnis unverändert nach außen gegeben?</legend>
|
||||||
|
<label><input type="radio" name="c1" value="ja" required> Ja</label>
|
||||||
|
<label><input type="radio" name="c1" value="nein"> Nein</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mit-folgefrage">
|
||||||
|
<legend>Beeinflusst es eine Entscheidung über einen Menschen?</legend>
|
||||||
|
<label class="c2-ja"><input type="radio" name="c2" value="ja" required> Ja</label>
|
||||||
|
<label><input type="radio" name="c2" value="nein"> Nein</label>
|
||||||
|
<div class="folgefrage">
|
||||||
|
<label for="c2_folge">Welche Art von Entscheidung?</label>
|
||||||
|
<select id="c2_folge" name="c2_folge">
|
||||||
|
<option value="beschaeftigung">Einstellung, Beförderung, Kündigung</option>
|
||||||
|
<option value="kreditwuerdigkeit">Kreditwürdigkeit</option>
|
||||||
|
<option value="leistungsbewilligung">Leistungsbewilligung</option>
|
||||||
|
<option value="bildung">Zugang zu Bildung</option>
|
||||||
|
<option value="gesundheit">medizinische Beurteilung</option>
|
||||||
|
<option value="keine">keins davon</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="mit-folgefrage">
|
||||||
|
<legend>Erkennt oder bewertet es Emotionen, Verhalten oder biometrische Merkmale?</legend>
|
||||||
|
<label class="c3-ja"><input type="radio" name="c3" value="ja" required> Ja</label>
|
||||||
|
<label><input type="radio" name="c3" value="nein"> Nein</label>
|
||||||
|
<div class="folgefrage">
|
||||||
|
<label for="c3_art">Welche Art?</label>
|
||||||
|
<select id="c3_art" name="c3_art">
|
||||||
|
<option value="emotionserkennung_arbeitsplatz">Emotionserkennung am Arbeitsplatz</option>
|
||||||
|
<option value="social_scoring">Social Scoring</option>
|
||||||
|
<option value="biometrische_kategorisierung">Biometrische Kategorisierung</option>
|
||||||
|
<option value="keine">etwas anderes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Läuft es ohne dass ein Mensch das Ergebnis prüft?</legend>
|
||||||
|
<label><input type="radio" name="c4" value="ja" required> Ja</label>
|
||||||
|
<label><input type="radio" name="c4" value="nein"> Nein</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Merkt der Empfänger, dass ein Ergebnis von einer KI stammt?</legend>
|
||||||
|
<label><input type="radio" name="c5" value="ja" required> Ja</label>
|
||||||
|
<label><input type="radio" name="c5" value="nein"> Nein</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h2>Werkzeug</h2>
|
||||||
|
<label for="d_werkzeug_freitext">Haben Sie schon ein Werkzeug im Sinn? (optional)</label>
|
||||||
|
<input type="text" id="d_werkzeug_freitext" name="d_werkzeug_freitext">
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Firmenkonto oder privater Zugang?</legend>
|
||||||
|
<label><input type="radio" name="d_zugang" value="firma" required> Firmenkonto</label>
|
||||||
|
<label><input type="radio" name="d_zugang" value="privat"> Privater Zugang</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Auf Firmengerät oder Privatgerät?</legend>
|
||||||
|
<label><input type="radio" name="d_geraet" value="firma" required> Firmengerät</label>
|
||||||
|
<label><input type="radio" name="d_geraet" value="privat"> Privatgerät</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit">Antrag einreichen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{{define "archived"}}
|
|
||||||
<p class="archiviert">
|
|
||||||
Beitrag archiviert und mit RFC-3161-Zeitstempel versehen
|
|
||||||
({{.TimestampedAt}}).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a>
|
|
||||||
</p>
|
|
||||||
<p class="disclaimer">
|
|
||||||
Dieses Dossier dokumentiert die durchgeführte Prüfung. Es ist keine
|
|
||||||
Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall.
|
|
||||||
</p>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{{define "beitraege"}}<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>{{template "head" .}}</head>
|
|
||||||
<body>
|
|
||||||
{{template "nav" .}}
|
|
||||||
<div class="page">
|
|
||||||
<h1>Beiträge</h1>
|
|
||||||
|
|
||||||
{{if not .Submissions}}
|
|
||||||
<p class="hinweis">Noch keine Beiträge geprüft.</p>
|
|
||||||
{{else}}
|
|
||||||
<ul class="beitraege-liste">
|
|
||||||
{{range .Submissions}}
|
|
||||||
<li>
|
|
||||||
<a href="/beitraege/{{.ID}}">
|
|
||||||
<strong>{{.Platform}}</strong> · {{.PostType}} · {{.CreatedAt}}
|
|
||||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
|
||||||
{{if .HighestSeverity}}
|
|
||||||
<span class="finding-{{.HighestSeverity}}">{{.FindingCount}} Finding(s), höchste: {{.HighestSeverity}}</span>
|
|
||||||
{{else}}
|
|
||||||
<span class="keine-findings">keine Findings</span>
|
|
||||||
{{end}}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p><a href="/">Neuen Beitrag prüfen</a></p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{{define "beitrag"}}<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>{{template "head" .}}</head>
|
|
||||||
<body>
|
|
||||||
{{template "nav" .}}
|
|
||||||
<div class="page">
|
|
||||||
<p><a href="/beitraege">← Alle Beiträge</a></p>
|
|
||||||
<h1>{{.Platform}} · {{.PostType}}</h1>
|
|
||||||
<p class="hinweis">Angelegt am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
|
|
||||||
<p>{{.Caption}}</p>
|
|
||||||
|
|
||||||
<h2>Findings</h2>
|
|
||||||
{{if .Findings}}
|
|
||||||
<ul class="findings">
|
|
||||||
{{range .Findings}}
|
|
||||||
<li class="finding finding-{{.Severity}}">
|
|
||||||
<strong>{{.RuleID}} v{{.Version}}</strong> ({{.Severity}}) — {{.Title}}
|
|
||||||
<p>Korrektur: {{.Fix}}</p>
|
|
||||||
{{if .Sources}}
|
|
||||||
<p>Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}</p>
|
|
||||||
{{end}}
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{else}}
|
|
||||||
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .CanArchive}}
|
|
||||||
<form hx-post="/veroeffentlichen" hx-target="#archiv-ergebnis" hx-swap="innerHTML">
|
|
||||||
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
|
|
||||||
<button type="submit">Als veröffentlicht markieren & archivieren</button>
|
|
||||||
</form>
|
|
||||||
<div id="archiv-ergebnis"></div>
|
|
||||||
{{end}}
|
|
||||||
{{if .IsPublished}}
|
|
||||||
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<h2>Verantwortungsmatrix</h2>
|
|
||||||
{{template "beteiligte-liste" .}}
|
|
||||||
|
|
||||||
<form hx-post="/beitraege/{{.SubmissionID}}/beteiligte" hx-target="#beteiligte" hx-swap="outerHTML">
|
|
||||||
<label for="role">Rolle</label>
|
|
||||||
<select id="role" name="role" required>
|
|
||||||
<option value="creator">Creator</option>
|
|
||||||
<option value="agentur">Agentur</option>
|
|
||||||
<option value="marke">Marke</option>
|
|
||||||
<option value="kanzlei">Kanzlei</option>
|
|
||||||
</select>
|
|
||||||
<label for="name">Name</label>
|
|
||||||
<input type="text" id="name" name="name" required>
|
|
||||||
<button type="submit">Beteiligten hinzufügen</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="disclaimer">
|
|
||||||
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
|
|
||||||
Prüfung im Einzelfall.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{{define "beteiligte-liste"}}
|
|
||||||
<div id="beteiligte">
|
|
||||||
{{if not .Participants}}
|
|
||||||
<p class="hinweis">Noch keine Beteiligten erfasst.</p>
|
|
||||||
{{else}}
|
|
||||||
<ul class="beteiligte">
|
|
||||||
{{range .Participants}}
|
|
||||||
<li class="beteiligter">
|
|
||||||
<div class="beteiligter-kopf">
|
|
||||||
<strong>{{.Name}}</strong> <span class="rolle">({{.Role}})</span>
|
|
||||||
</div>
|
|
||||||
<form class="beteiligter-form"
|
|
||||||
hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/aktualisieren"
|
|
||||||
hx-target="#beteiligte" hx-swap="outerHTML" hx-trigger="change">
|
|
||||||
<label><input type="checkbox" name="vorgegeben" {{if .Vorgegeben}}checked{{end}}> Vorgegeben</label>
|
|
||||||
<label><input type="checkbox" name="freigegeben" {{if .Freigegeben}}checked{{end}}> Freigegeben</label>
|
|
||||||
{{if .ApprovedAt}}<span class="hinweis">freigegeben am {{.ApprovedAt}}</span>{{end}}
|
|
||||||
</form>
|
|
||||||
<form hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/loeschen"
|
|
||||||
hx-target="#beteiligte" hx-swap="outerHTML" hx-confirm="Beteiligten wirklich entfernen?">
|
|
||||||
<button type="submit" class="entfernen">Entfernen</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
22
internal/web/templates/betreiber_account_detail.html
Normal file
22
internal/web/templates/betreiber_account_detail.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "betreiber-account-detail"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/betreiber/accounts">← Alle Accounts</a></p>
|
||||||
|
<h1>{{.Name}}</h1>
|
||||||
|
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||||
|
|
||||||
|
<h2>Nutzer</h2>
|
||||||
|
<ul class="beteiligte">
|
||||||
|
{{range .Users}}
|
||||||
|
<li class="beteiligter">
|
||||||
|
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin-accounts"}}<!doctype html>
|
{{define "betreiber-accounts"}}<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>{{template "head" .}}</head>
|
<head>{{template "head" .}}</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "nav" .}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/admin">← Admin</a></p>
|
<p><a href="/betreiber">← Plattform</a></p>
|
||||||
<h1>Accounts</h1>
|
<h1>Accounts</h1>
|
||||||
{{if not .Accounts}}
|
{{if not .Accounts}}
|
||||||
<p class="hinweis">Noch keine Accounts.</p>
|
<p class="hinweis">Noch keine Accounts.</p>
|
||||||
@@ -12,13 +12,8 @@
|
|||||||
<ul class="beitraege-liste">
|
<ul class="beitraege-liste">
|
||||||
{{range .Accounts}}
|
{{range .Accounts}}
|
||||||
<li>
|
<li>
|
||||||
<a href="/admin/accounts/{{.ID}}">
|
<a href="/betreiber/accounts/{{.ID}}">
|
||||||
<strong>{{.Name}}</strong> · {{.CreatedAt}}
|
<strong>{{.Name}}</strong> · {{.CreatedAt}}
|
||||||
{{if .Verified}}
|
|
||||||
<span class="status status-published">verifiziert</span>
|
|
||||||
{{else}}
|
|
||||||
<span class="status status-mittel">nicht verifiziert</span>
|
|
||||||
{{end}}
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin-audit-log"}}<!doctype html>
|
{{define "betreiber-audit-log"}}<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>{{template "head" .}}</head>
|
<head>{{template "head" .}}</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "nav" .}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/admin">← Admin</a></p>
|
<p><a href="/betreiber">← Plattform</a></p>
|
||||||
<h1>Audit-Log</h1>
|
<h1>Audit-Log</h1>
|
||||||
{{if not .Entries}}
|
{{if not .Entries}}
|
||||||
<p class="hinweis">Noch keine Einträge.</p>
|
<p class="hinweis">Noch keine Einträge.</p>
|
||||||
15
internal/web/templates/betreiber_dashboard.html
Normal file
15
internal/web/templates/betreiber_dashboard.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{{define "betreiber-dashboard"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<h1>Plattform</h1>
|
||||||
|
<ul class="admin-kacheln">
|
||||||
|
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
|
||||||
|
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -2,46 +2,13 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>{{template "head" .}}</head>
|
<head>{{template "head" .}}</head>
|
||||||
<body>
|
<body>
|
||||||
{{template "nav" .}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Pre-Publish-Prüfung</h1>
|
<h1>Willkommen bei Deklarix</h1>
|
||||||
<p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p>
|
<ul class="admin-kacheln">
|
||||||
|
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||||
<form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML">
|
<li><a href="/antraege">Meine Anträge</a></li>
|
||||||
<label for="platform">Plattform</label>
|
</ul>
|
||||||
<select id="platform" name="platform" required>
|
|
||||||
<option value="instagram">Instagram</option>
|
|
||||||
<option value="tiktok">TikTok</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="post_type">Beitragstyp</label>
|
|
||||||
<select id="post_type" name="post_type" required>
|
|
||||||
<option value="feed">Feed</option>
|
|
||||||
<option value="reel">Reel</option>
|
|
||||||
<option value="story">Story</option>
|
|
||||||
<option value="video">Video</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="consideration">Gegenleistung</label>
|
|
||||||
<select id="consideration" name="consideration" required>
|
|
||||||
<option value="">— bitte wählen —</option>
|
|
||||||
<option value="bezahlt">Bezahlt</option>
|
|
||||||
<option value="sachbezug">Sachbezug (Produkt, Einladung, ...)</option>
|
|
||||||
<option value="keine">Keine</option>
|
|
||||||
<option value="unklar">Unklar</option>
|
|
||||||
</select>
|
|
||||||
<p class="hinweis">
|
|
||||||
Das kann die Prüfung nicht aus der Caption erraten — nur wer
|
|
||||||
einreicht, weiß, ob eine Gegenleistung vorlag.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label for="caption">Caption</label>
|
|
||||||
<textarea id="caption" name="caption" rows="6" required></textarea>
|
|
||||||
|
|
||||||
<button type="submit">Prüfen</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="ergebnis"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{{define "kanzleien"}}<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>{{template "head" .}}</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<h1>Kanzlei-Verzeichnis</h1>
|
|
||||||
<p class="hinweis">
|
|
||||||
Kostenloses Verzeichnis auf Deklarix verifizierter Kanzleien. Keine
|
|
||||||
Vermittlung, keine Empfehlung — die Auswahl trifft allein der Nutzer.
|
|
||||||
</p>
|
|
||||||
{{if not .Kanzleien}}
|
|
||||||
<p class="hinweis">Noch keine Kanzlei gelistet.</p>
|
|
||||||
{{else}}
|
|
||||||
<ul class="beitraege-liste">
|
|
||||||
{{range .Kanzleien}}
|
|
||||||
<li><span class="kanzlei-eintrag">{{.Name}}</span></li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
|
|
||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Prüfen</a>
|
<a href="/">Start</a>
|
||||||
<a href="/beitraege">Beiträge</a>
|
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||||
|
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||||
<form method="post" action="/logout" style="display:inline">
|
<form method="post" action="/logout" style="display:inline">
|
||||||
<button type="submit">Abmelden</button>
|
<button type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,21 +3,17 @@
|
|||||||
<head>{{template "head" .}}</head>
|
<head>{{template "head" .}}</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Registrieren</h1>
|
<h1>Firma registrieren</h1>
|
||||||
|
<p class="hinweis">
|
||||||
|
Testzugang ist sofort aktiv. Der Bezahlbetrieb wird nach Prüfung durch
|
||||||
|
den Betreiber freigeschaltet.
|
||||||
|
</p>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
<form method="post" action="/register">
|
<form method="post" action="/register">
|
||||||
<label for="account_name">Name (Creator, Agentur, Marke oder Kanzlei)</label>
|
<label for="account_name">Firmenname</label>
|
||||||
<input type="text" id="account_name" name="account_name" required>
|
<input type="text" id="account_name" name="account_name" required>
|
||||||
|
|
||||||
<label for="role">Rolle</label>
|
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
|
||||||
<select id="role" name="role" required>
|
|
||||||
<option value="creator">Creator</option>
|
|
||||||
<option value="agentur">Agentur</option>
|
|
||||||
<option value="marke">Marke</option>
|
|
||||||
<option value="kanzlei">Kanzlei</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="email">E-Mail</label>
|
|
||||||
<input type="email" id="email" name="email" required>
|
<input type="email" id="email" name="email" required>
|
||||||
|
|
||||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
{{define "result"}}
|
|
||||||
{{if .NeedsClarification}}
|
|
||||||
<p class="rueckfrage">
|
|
||||||
Die Gegenleistung konnte nicht sicher bestimmt werden. Bitte Angaben
|
|
||||||
präzisieren (z. B. ob ein Produkt oder eine Zahlung im Zusammenhang mit
|
|
||||||
dem Beitrag stand) — es wurde bewusst keine Bewertung abgegeben.
|
|
||||||
</p>
|
|
||||||
{{else if .Findings}}
|
|
||||||
<ul class="findings">
|
|
||||||
{{range .Findings}}
|
|
||||||
<li class="finding finding-{{.Severity}}">
|
|
||||||
<strong>{{.RuleID}} v{{.Version}}</strong> ({{.Severity}}) — {{.Title}}
|
|
||||||
<p>Korrektur: {{.Fix}}</p>
|
|
||||||
{{if .Sources}}
|
|
||||||
<p>Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}</p>
|
|
||||||
{{end}}
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{else}}
|
|
||||||
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .CanArchive}}
|
|
||||||
<form hx-post="/veroeffentlichen" hx-target="#ergebnis" hx-swap="innerHTML">
|
|
||||||
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
|
|
||||||
<button type="submit">Als veröffentlicht markieren & archivieren</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p class="disclaimer">
|
|
||||||
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
|
|
||||||
Prüfung im Einzelfall.
|
|
||||||
</p>
|
|
||||||
{{end}}
|
|
||||||
@@ -5,19 +5,11 @@
|
|||||||
|
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
|
||||||
# Wo internal/rules die YAML-Regeln findet — vom .deb-Paket nach
|
# Wo internal/rules das Bewertungsregelwerk (Datenklasse, KI-VO-
|
||||||
|
# Einstufung, Anforderungsprofil) findet — vom .deb-Paket nach
|
||||||
# /usr/share/deklarix/rules installiert (siehe scripts/build.sh).
|
# /usr/share/deklarix/rules installiert (siehe scripts/build.sh).
|
||||||
RULES_DIR=/usr/share/deklarix/rules
|
RULES_DIR=/usr/share/deklarix/rules
|
||||||
|
|
||||||
# Wo erzeugte Nachweis-Dossiers (PDF) abgelegt werden. Wird beim
|
|
||||||
# Archivieren eines Beitrags automatisch angelegt.
|
|
||||||
DOSSIER_DIR=/var/lib/deklarix/dossiers
|
|
||||||
|
|
||||||
# RFC-3161-Zeitstempeldienst. Leer = FreeTSA.org (frei, aber NICHT
|
|
||||||
# eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem
|
|
||||||
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
|
|
||||||
#TSA_URL=https://freetsa.org/tsr
|
|
||||||
|
|
||||||
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
|
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
|
||||||
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
|
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
|
||||||
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
||||||
|
|||||||
101
rules/OPEN.md
101
rules/OPEN.md
@@ -1,62 +1,57 @@
|
|||||||
# Offene Rechtsfragen
|
# Offene fachliche Fragen zum Regelwerk
|
||||||
|
|
||||||
Fragen, bei denen sich eine Regel nicht auf eine handgeprüfte,
|
Diese Punkte wurden beim Anlegen der Regelwerk-YAML-Dateien (Phase 1)
|
||||||
eindeutig passende Fundstelle stützen lässt, ohne zu raten. Werden hier
|
bewusst nicht geraten, sondern hier notiert — vor Phase 3 (Ableitungen
|
||||||
notiert statt in einer Regel geraten beantwortet zu werden (siehe
|
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
|
||||||
CLAUDE.md, Arbeitsweise für Claude Code).
|
betrieblichen Datenschutzbeauftragten) klären.
|
||||||
|
|
||||||
## WK-004 — Fundstelle für "Kennzeichnung muss vor Kürzung sichtbar sein"
|
## 1. Rangfolge der Datenklassen
|
||||||
|
|
||||||
**Stand 2026-08-27, zweite Recherche:** Es gibt jetzt eine einschlägige
|
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
|
||||||
Entscheidung: **LG Köln, Urteil v. 12.05.2026** — Werbekennzeichnung muss
|
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell
|
||||||
bereits im Vorschaubild/Grid erkennbar sein, wenn der kommerzielle Zweck
|
diese Reihenfolge an (niedrigster zu höchstem Rang):
|
||||||
dort nicht auf den ersten Blick ersichtlich ist; eine erst in der
|
|
||||||
Caption oder erst im geöffneten Beitrag sichtbare Kennzeichnung kommt zu
|
|
||||||
spät. Das deckt sich direkt mit WK-004 und ist jetzt als Fundstelle in
|
|
||||||
der Regel hinterlegt. Zusätzlich bestätigt: OLG Celle hat ein bloßes
|
|
||||||
„#ad" als unzureichend zurückgewiesen, das Kammergericht Berlin ebenso
|
|
||||||
„#sponsoredby" — beides stützt die in WK-001/WK-004 empfohlene
|
|
||||||
Kennzeichnung mit „Werbung"/„Anzeige".
|
|
||||||
|
|
||||||
**Weiterhin offen:** Das LG-Köln-Urteil ist laut Quellenlage **noch
|
1. `oeffentlich`
|
||||||
nicht rechtskräftig** (Stand der Recherche). Vor Kundeneinsatz prüfen,
|
2. `intern`
|
||||||
ob es zwischenzeitlich in Berufung bestätigt oder aufgehoben wurde, und
|
3. `auftragsdaten`
|
||||||
ob es Aussagen zur reinen Caption-Kürzung (statt Vorschaubild) trifft —
|
4. `personenbezogen`
|
||||||
das Urteil betont primär das Vorschaubild/Grid, WK-004 bewertet aktuell
|
5. `berufsgeheimnis`
|
||||||
die Caption-Kürzung. Beides hängt zusammen (wenn schon das Vorschaubild
|
6. `besondere_kategorie`
|
||||||
fehlt, ist die Caption-Kürzung ohnehin nachrangig), sollte aber sauber
|
|
||||||
auseinandergehalten werden — evtl. braucht es eine eigene Regel für
|
|
||||||
"keine Kennzeichnung im Vorschaubild/ersten Frame", statt beides in
|
|
||||||
WK-004 zu vermischen.
|
|
||||||
|
|
||||||
## Ausnahme "kommerzieller Zweck ohnehin erkennbar" (BGH I ZR 90/20 u. a.)
|
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt
|
||||||
|
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB
|
||||||
|
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor
|
||||||
|
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter
|
||||||
|
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der
|
||||||
|
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht
|
||||||
|
bestätigt.**
|
||||||
|
|
||||||
**Stand 2026-08-27, zweite Recherche — deutlich präzisiert:** Der BGH
|
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten`
|
||||||
unterscheidet zwei Fälle:
|
|
||||||
|
|
||||||
1. **Eigenwerbung** (Influencer bewirbt das eigene Unternehmen/die eigene
|
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
|
||||||
Marke): Bei kommerziell genutzten Accounts wissen Follower laut BGH
|
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
||||||
"aus den Umständen", dass es sich um Eigenwerbung handelt — hier kann
|
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
|
||||||
die Kennzeichnungspflicht entfallen. Als Kriterium diente u. a. die
|
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit
|
||||||
Followerzahl (im konkreten Fall bei 600.000 bzw. 1,7 Mio. Followern
|
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
|
||||||
bejaht), aber der BGH hat **keine klare Grenze** gezogen. Das ist zu
|
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
|
||||||
vage für eine objektive, extrahierbare Bedingung — eine Regel, die
|
|
||||||
auf "Followerzahl > X" abstellt, würde eine Grenze erfinden, die es
|
|
||||||
so nicht gibt. Bleibt vorerst nicht automatisiert.
|
|
||||||
|
|
||||||
2. **Fremdwerbung** (Influencer bewirbt Produkte/Marken Dritter): Hier
|
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
||||||
ist die neue, klar extrahierbare Erkenntnis: **Ein Link auf die
|
|
||||||
Website des Herstellers/der Marke begründet laut BGH regelmäßig einen
|
|
||||||
"werblichen Überschuss"** — die Kennzeichnungspflicht lebt dann
|
|
||||||
unabhängig davon auf, ob der Post sonst wie Eigenwerbung wirkt. Das
|
|
||||||
ist ein objektives Extraktionsfeld (Link vorhanden ja/nein, Linkziel
|
|
||||||
Hersteller/Marke ja/nein), kein Werturteil — passt zum Kernprinzip
|
|
||||||
und ist ein guter Kandidat für eine kommende Regel WK-002
|
|
||||||
("Verlinkung auf Fremdprodukt ohne Kennzeichnung"), sobald das
|
|
||||||
Extraktionsschema `verlinkungen`/`affiliate_link` echte Daten liefert.
|
|
||||||
|
|
||||||
**Ergebnis:** Die "ohnehin erkennbar"-Ausnahme bleibt bewusst
|
~~Fragebogen-Abschnitt C stellt nur eine einzelne Frage...~~ Mit Phase 2
|
||||||
unautomatisiert (Kriterium zu vage, siehe oben) — WK-001/WK-004 lösen in
|
(Fragebogen, `internal/web/templates/antrag_neu.html`) gelöst: C3 hat
|
||||||
diesem seltenen Fall weiterhin ein potenzielles False Positive aus. Das
|
jetzt eine Folgefrage `c3_art` mit den Werten
|
||||||
ist der sicherere Fehler in diese Richtung (lieber eine unnötige
|
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
||||||
Rückfrage an den Nutzer als eine übersehene Kennzeichnungspflicht).
|
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
||||||
|
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
||||||
|
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
|
||||||
|
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"-
|
||||||
|
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung.
|
||||||
|
|
||||||
|
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||||
|
|
||||||
|
Die Spezifikation nennt `loeschfrist_max_tage` als Anforderung "aus
|
||||||
|
Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
|
||||||
|
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
||||||
|
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
||||||
|
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
||||||
|
Tage") müssen vor Phase 3 fachlich festgelegt werden.
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
id: WK-001
|
|
||||||
version: 1
|
|
||||||
land: DE
|
|
||||||
titel: Keine Kennzeichnung trotz Gegenleistung
|
|
||||||
bedingung:
|
|
||||||
gegenleistung: [bezahlt, sachbezug]
|
|
||||||
kennzeichnung_vorhanden: false
|
|
||||||
schwere: hoch
|
|
||||||
fundstelle:
|
|
||||||
- "§ 5a Abs. 4 UWG"
|
|
||||||
- "§ 22 Abs. 1 MStV"
|
|
||||||
korrektur: >
|
|
||||||
Beitrag mit "Werbung" oder "Anzeige" kennzeichnen. Die bloße
|
|
||||||
plattform-eigene Kennzeichnung (z. B. Instagrams "Bezahlte
|
|
||||||
Partnerschaft") reicht laut Leitfaden der Medienanstalten nicht als
|
|
||||||
alleinige Kennzeichnung aus.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
id: WK-004
|
|
||||||
version: 1
|
|
||||||
land: DE
|
|
||||||
titel: Kennzeichnung nicht auf den ersten Blick erkennbar (Kürzung durch "mehr anzeigen")
|
|
||||||
bedingung:
|
|
||||||
gegenleistung: [bezahlt, sachbezug]
|
|
||||||
kennzeichnung_vorhanden: true
|
|
||||||
kennzeichnung_vor_kuerzung: false
|
|
||||||
schwere: hoch
|
|
||||||
fundstelle:
|
|
||||||
- "§ 5a Abs. 4 UWG"
|
|
||||||
- "Leitfaden der Medienanstalten: Werbekennzeichnung bei Online-Medien"
|
|
||||||
- "LG Köln, Urteil v. 12.05.2026 (noch nicht rechtskräftig) — Werbekennzeichnung muss bereits im Vorschaubild/Grid erkennbar sein, eine erst in der Caption versteckte Kennzeichnung kommt zu spät"
|
|
||||||
korrektur: >
|
|
||||||
Kennzeichnung ("Werbung" oder "Anzeige") an den Anfang der Caption
|
|
||||||
setzen, sodass sie sichtbar ist, bevor Instagram/TikTok den Text
|
|
||||||
hinter "mehr anzeigen" kürzt.
|
|
||||||
32
rules/anforderungen.yaml
Normal file
32
rules/anforderungen.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Anforderungsprofil: aus Datenklasse und/oder KI-VO-Einstufung
|
||||||
|
# abgeleitete Menge von Anforderungen, die ein Werkzeug erfüllen muss.
|
||||||
|
#
|
||||||
|
# ACHTUNG: "kein_training_auf_eingabe aus intern, personenbezogen und
|
||||||
|
# höher" in der Produktspezifikation lässt offen, ob auftragsdaten
|
||||||
|
# (rang 3, zwischen intern und personenbezogen) eingeschlossen sein
|
||||||
|
# soll — hier bewusst explizit aufgeführt (siehe rules/OPEN.md), damit
|
||||||
|
# nichts über eine implizite Rangfolge erraten wird.
|
||||||
|
version: 1
|
||||||
|
anforderungen:
|
||||||
|
- id: avv_erforderlich
|
||||||
|
aus_datenklassen: [personenbezogen, besondere_kategorie, auftragsdaten]
|
||||||
|
beschreibung: "Auftragsverarbeitungsvertrag mit dem Werkzeug-Anbieter erforderlich"
|
||||||
|
- id: eu_verarbeitung
|
||||||
|
aus_datenklassen: [berufsgeheimnis, besondere_kategorie]
|
||||||
|
beschreibung: "Verarbeitung muss innerhalb der EU stattfinden"
|
||||||
|
- id: kein_training_auf_eingabe
|
||||||
|
aus_datenklassen: [intern, auftragsdaten, personenbezogen, berufsgeheimnis, besondere_kategorie]
|
||||||
|
beschreibung: "Eingaben dürfen nicht zum Training des Werkzeugs verwendet werden"
|
||||||
|
- id: loeschfrist_max_tage
|
||||||
|
aus_datenklassen: [oeffentlich, intern, auftragsdaten, personenbezogen, berufsgeheimnis, besondere_kategorie]
|
||||||
|
beschreibung: "maximale Aufbewahrungsdauer richtet sich nach der Datenklasse"
|
||||||
|
- id: menschliche_aufsicht
|
||||||
|
aus_einstufungen: [hochrisiko]
|
||||||
|
beschreibung: "ein Mensch muss das Ergebnis vor Wirksamkeit prüfen"
|
||||||
|
- id: kennzeichnungspflicht
|
||||||
|
aus_einstufungen: [transparenzpflicht]
|
||||||
|
beschreibung: "das Ergebnis muss als KI-generiert gekennzeichnet werden"
|
||||||
|
- id: dsfa_erforderlich
|
||||||
|
aus_datenklassen: [besondere_kategorie]
|
||||||
|
aus_einstufungen: [hochrisiko]
|
||||||
|
beschreibung: "Datenschutz-Folgenabschätzung erforderlich"
|
||||||
35
rules/datenklasse.yaml
Normal file
35
rules/datenklasse.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Datenklasse-Ableitung aus Fragebogen-Abschnitt B. "Höchste zutreffende
|
||||||
|
# Stufe gewinnt" — rang entscheidet, welche Stufe gewinnt, wenn mehrere
|
||||||
|
# Auslöser gleichzeitig zutreffen. "Unsicher" zählt wie "Ja" (siehe
|
||||||
|
# Fragebogen-Spezifikation) — das wird in der Auswertung (Phase 3)
|
||||||
|
# umgesetzt, nicht hier.
|
||||||
|
#
|
||||||
|
# ACHTUNG: Die Rangfolge unten ist eine Annahme (nicht explizit in der
|
||||||
|
# Produktspezifikation vorgegeben), siehe rules/OPEN.md — vor Phase 3
|
||||||
|
# mit dem fachlich Verantwortlichen bestätigen.
|
||||||
|
version: 1
|
||||||
|
stufen:
|
||||||
|
- id: oeffentlich
|
||||||
|
rang: 1
|
||||||
|
ausloeser: [b7]
|
||||||
|
folge: "keine besonderen Anforderungen"
|
||||||
|
- id: intern
|
||||||
|
rang: 2
|
||||||
|
ausloeser: [b6]
|
||||||
|
folge: "Geschäftsgeheimnis-Schutz"
|
||||||
|
- id: auftragsdaten
|
||||||
|
rang: 3
|
||||||
|
ausloeser: [b5]
|
||||||
|
folge: "Weitergabe nur wenn Haupt-AVV es zulässt"
|
||||||
|
- id: personenbezogen
|
||||||
|
rang: 4
|
||||||
|
ausloeser: [b1, b4]
|
||||||
|
folge: "AVV, EU-Verarbeitung, Löschfrist"
|
||||||
|
- id: berufsgeheimnis
|
||||||
|
rang: 5
|
||||||
|
ausloeser: [b3]
|
||||||
|
folge: "§ 203 StGB — externe Verarbeitung nur mit Verpflichtung"
|
||||||
|
- id: besondere_kategorie
|
||||||
|
rang: 6
|
||||||
|
ausloeser: [b2]
|
||||||
|
folge: "zusätzlich DSFA prüfen, Art. 9 DSGVO"
|
||||||
30
rules/kivo_einstufung.yaml
Normal file
30
rules/kivo_einstufung.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# KI-VO-Einstufung aus Fragebogen-Abschnitt C. Reihenfolge ist
|
||||||
|
# Prüfreihenfolge (erste zutreffende Stufe gewinnt) — "verboten" muss
|
||||||
|
# zuerst geprüft werden (K.-o.-Prüfung, siehe Bewertungslogik). Jede
|
||||||
|
# Variante ist eine UND-Verknüpfung von Fakten; mehrere Varianten pro
|
||||||
|
# Stufe sind ODER-verknüpft.
|
||||||
|
#
|
||||||
|
# ACHTUNG: c3_art (social_scoring / biometrische_kategorisierung /
|
||||||
|
# emotionserkennung_arbeitsplatz) und c2_folge sind Platzhalter-
|
||||||
|
# Feldnamen, bis Phase 2 (Fragebogen) die genauen Fakten-Schlüssel
|
||||||
|
# festlegt — siehe rules/OPEN.md.
|
||||||
|
version: 1
|
||||||
|
stufen:
|
||||||
|
- id: verboten
|
||||||
|
quelle: "Art. 5 KI-VO"
|
||||||
|
varianten:
|
||||||
|
- {c3: true, c3_art: emotionserkennung_arbeitsplatz}
|
||||||
|
- {c3: true, c3_art: social_scoring}
|
||||||
|
- {c3: true, c3_art: biometrische_kategorisierung}
|
||||||
|
- id: hochrisiko
|
||||||
|
varianten:
|
||||||
|
- {c2: true, c2_folge: beschaeftigung}
|
||||||
|
- {c2: true, c2_folge: kreditwuerdigkeit}
|
||||||
|
- {c2: true, c2_folge: leistungsbewilligung}
|
||||||
|
- {c2: true, c2_folge: bildung}
|
||||||
|
- {c2: true, c2_folge: gesundheit}
|
||||||
|
- id: transparenzpflicht
|
||||||
|
varianten:
|
||||||
|
- {c1: true, c5: false}
|
||||||
|
- id: minimal
|
||||||
|
varianten: []
|
||||||
12
testdata/golden/organic-no-consideration.json
vendored
12
testdata/golden/organic-no-consideration.json
vendored
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Organischer Post ohne Gegenleistung, keine Kennzeichnung noetig",
|
|
||||||
"facts": {
|
|
||||||
"platform": "instagram",
|
|
||||||
"jurisdiction": "DE",
|
|
||||||
"consideration": "keine",
|
|
||||||
"disclosure_present": false,
|
|
||||||
"disclosure_wording": "",
|
|
||||||
"disclosure_before_cut": false
|
|
||||||
},
|
|
||||||
"expected_findings": []
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Bezahlter Post, Kennzeichnung vorhanden aber hinter 'mehr anzeigen' versteckt",
|
|
||||||
"facts": {
|
|
||||||
"platform": "instagram",
|
|
||||||
"jurisdiction": "DE",
|
|
||||||
"consideration": "bezahlt",
|
|
||||||
"disclosure_present": true,
|
|
||||||
"disclosure_wording": "Werbung",
|
|
||||||
"disclosure_before_cut": false
|
|
||||||
},
|
|
||||||
"expected_findings": [
|
|
||||||
{ "rule_id": "WK-004", "rule_version": 1, "severity": "hoch" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
12
testdata/golden/paid-disclosure-visible.json
vendored
12
testdata/golden/paid-disclosure-visible.json
vendored
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Bezahlter Post, Kennzeichnung korrekt vor der Kürzung sichtbar",
|
|
||||||
"facts": {
|
|
||||||
"platform": "tiktok",
|
|
||||||
"jurisdiction": "DE",
|
|
||||||
"consideration": "sachbezug",
|
|
||||||
"disclosure_present": true,
|
|
||||||
"disclosure_wording": "Werbung",
|
|
||||||
"disclosure_before_cut": true
|
|
||||||
},
|
|
||||||
"expected_findings": []
|
|
||||||
}
|
|
||||||
14
testdata/golden/paid-no-disclosure.json
vendored
14
testdata/golden/paid-no-disclosure.json
vendored
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Bezahlter Post ohne jede Kennzeichnung",
|
|
||||||
"facts": {
|
|
||||||
"platform": "instagram",
|
|
||||||
"jurisdiction": "DE",
|
|
||||||
"consideration": "bezahlt",
|
|
||||||
"disclosure_present": false,
|
|
||||||
"disclosure_wording": "",
|
|
||||||
"disclosure_before_cut": false
|
|
||||||
},
|
|
||||||
"expected_findings": [
|
|
||||||
{ "rule_id": "WK-001", "rule_version": 1, "severity": "hoch" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Gegenleistung unklar - Rueckfrage statt Bewertung",
|
|
||||||
"facts": {
|
|
||||||
"platform": "instagram",
|
|
||||||
"jurisdiction": "DE",
|
|
||||||
"consideration": "unklar",
|
|
||||||
"disclosure_present": false,
|
|
||||||
"disclosure_wording": "",
|
|
||||||
"disclosure_before_cut": false
|
|
||||||
},
|
|
||||||
"expected_findings": [],
|
|
||||||
"expect_needs_clarification": true
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user