Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe68b16c9 | ||
|
|
7db4707bc5 | ||
|
|
fa5e68c892 | ||
|
|
d3b171f721 | ||
|
|
b0d6b00045 | ||
|
|
fe28278615 | ||
|
|
0fe29f8c80 | ||
|
|
eccf02050b | ||
|
|
8a8295dacd | ||
|
|
690660b655 | ||
|
|
f729e5ae48 | ||
|
|
9e2c7f92ff | ||
|
|
4c60603055 | ||
|
|
bc99858c94 | ||
|
|
3aecbb446c | ||
|
|
08c7721165 | ||
|
|
59e1bcc8f7 | ||
|
|
2e459f1dd1 | ||
|
|
d77b19f61f | ||
|
|
dbeaa45644 | ||
|
|
4358dbaa0d | ||
|
|
7397f70068 | ||
|
|
2736e2c0db | ||
|
|
9d2cb79424 | ||
|
|
4dd9bd88cb | ||
|
|
96ac5d6b59 | ||
|
|
29961f5657 | ||
|
|
3ec57f2786 | ||
|
|
6884f28109 | ||
|
|
b91a58ccba | ||
|
|
eb99891e38 | ||
|
|
36af1bf288 | ||
|
|
5f6502bb41 | ||
|
|
22f748d66c | ||
|
|
d52b325424 | ||
|
|
cff8c9eadf | ||
|
|
aad517ce23 | ||
|
|
d1e80dd19f | ||
|
|
e968cf9761 | ||
|
|
b1b121bb23 | ||
|
|
db9c5b7482 | ||
|
|
344a33805a | ||
|
|
b4d4ee8d3c | ||
|
|
6fd7831784 | ||
|
|
5813e6209c | ||
|
|
790ab20651 | ||
|
|
ae59e745c0 | ||
|
|
8e5d06aafb | ||
|
|
835ad9f0a7 | ||
|
|
c9d71b0d54 | ||
|
|
6df1d2961b | ||
|
|
77de1627c5 | ||
|
|
374f4ade31 |
@@ -6,8 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
"github.com/netcell-it/deklarix/internal/mail"
|
||||||
"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"
|
||||||
@@ -23,32 +22,59 @@ func main() {
|
|||||||
log.Fatalf("migrate: %v", err)
|
log.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := store.Open(context.Background(), databaseURL)
|
// Migrationen laufen immer über DATABASE_URL (braucht DDL-Rechte,
|
||||||
|
// z. B. CREATE TABLE/ALTER TABLE). Die laufende Anwendung verbindet
|
||||||
|
// sich dagegen möglichst über die eingeschränkte Row-Level-Security-
|
||||||
|
// Rolle "deklarix_app" (siehe Migration 0021) — DATABASE_URL_APP,
|
||||||
|
// falls gesetzt. Ohne DATABASE_URL_APP fällt sie auf DATABASE_URL
|
||||||
|
// zurück (bisheriges Verhalten, Postgres-Superuser umgeht RLS-
|
||||||
|
// Policies dann vollständig — kein Fehler, aber auch keine
|
||||||
|
// zusätzliche Isolation auf DB-Ebene, siehe CLAUDE.md).
|
||||||
|
appDatabaseURL := os.Getenv("DATABASE_URL_APP")
|
||||||
|
if appDatabaseURL == "" {
|
||||||
|
appDatabaseURL = databaseURL
|
||||||
|
}
|
||||||
|
db, err := store.Open(context.Background(), appDatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open store: %v", err)
|
log.Fatalf("open store: %v", err)
|
||||||
}
|
}
|
||||||
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)
|
mailer := mail.NewSMTPMailer(mail.Config{
|
||||||
|
Host: os.Getenv("SMTP_HOST"),
|
||||||
|
Port: os.Getenv("SMTP_PORT"),
|
||||||
|
User: os.Getenv("SMTP_USER"),
|
||||||
|
Password: os.Getenv("SMTP_PASSWORD"),
|
||||||
|
From: os.Getenv("SMTP_FROM"),
|
||||||
|
})
|
||||||
|
|
||||||
|
server, err := web.NewServer(db, web.Regelwerk{
|
||||||
|
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||||
|
}, mailer)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
78
internal/mail/mail.go
Normal file
78
internal/mail/mail.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// Package mail versendet E-Mails (aktuell: Passwort-Zurücksetzen-Links)
|
||||||
|
// über einen konfigurierten SMTP-Server. Reine Versandlogik, kein
|
||||||
|
// Template-Rendering — der Aufrufer (internal/web) baut Betreff/Text
|
||||||
|
// selbst zusammen, damit dieses Paket unabhängig vom Web-Layer bleibt.
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mailer ist die Schnittstelle, die internal/web nutzt — austauschbar
|
||||||
|
// gegen eine Test-Doppel (siehe FakeMailer), damit Tests keinen echten
|
||||||
|
// SMTP-Server brauchen.
|
||||||
|
type Mailer interface {
|
||||||
|
Send(to, subject, body string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config sind die SMTP-Zugangsdaten, aus Umgebungsvariablen gelesen
|
||||||
|
// (siehe cmd/deklarix/main.go) — kein neues Geheimnis im Code.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
From string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTPMailer versendet über einen echten SMTP-Server (STARTTLS, falls
|
||||||
|
// vom Server angeboten — das deckt den Standard-Submission-Port 587 ab;
|
||||||
|
// implizites TLS auf Port 465 wird von net/smtp nicht unterstützt).
|
||||||
|
type SMTPMailer struct {
|
||||||
|
cfg Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSMTPMailer(cfg Config) *SMTPMailer {
|
||||||
|
return &SMTPMailer{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send verschickt eine reine Text-E-Mail. Liefert einen Fehler, wenn
|
||||||
|
// kein SMTP-Host konfiguriert ist — bewusst kein stiller No-op, ein
|
||||||
|
// fehlgeschlagener Versand (z. B. Passwort-Reset) muss sichtbar
|
||||||
|
// scheitern statt eine E-Mail vorzutäuschen, die nie ankommt.
|
||||||
|
func (m *SMTPMailer) Send(to, subject, body string) error {
|
||||||
|
if m.cfg.Host == "" {
|
||||||
|
return fmt.Errorf("mail: SMTP nicht konfiguriert (SMTP_HOST fehlt)")
|
||||||
|
}
|
||||||
|
addr := m.cfg.Host + ":" + m.cfg.Port
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||||
|
m.cfg.From, to, subject, body,
|
||||||
|
)
|
||||||
|
|
||||||
|
var auth smtp.Auth
|
||||||
|
if m.cfg.User != "" {
|
||||||
|
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
|
||||||
|
}
|
||||||
|
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)); err != nil {
|
||||||
|
return fmt.Errorf("mail: senden an %s: %w", to, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SentMail protokolliert einen Versand für Tests.
|
||||||
|
type SentMail struct {
|
||||||
|
To, Subject, Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FakeMailer ist das Test-Doppel für Mailer — sammelt gesendete Mails
|
||||||
|
// statt sie zu verschicken.
|
||||||
|
type FakeMailer struct {
|
||||||
|
Sent []SentMail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FakeMailer) Send(to, subject, body string) error {
|
||||||
|
f.Sent = append(f.Sent, SentMail{To: to, Subject: subject, Body: body})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,42 +1,317 @@
|
|||||||
package rules
|
package rules
|
||||||
|
|
||||||
// Finding ist das Ergebnis einer einzelnen zutreffenden Regel.
|
import (
|
||||||
type Finding struct {
|
"encoding/json"
|
||||||
RuleID string
|
"fmt"
|
||||||
RuleVersion int
|
"sort"
|
||||||
Severity Severity
|
"strings"
|
||||||
Title string
|
)
|
||||||
Fix string
|
|
||||||
Sources []string
|
// Antworten ist die im Antrag gespeicherte Fragebogen-Antwort-Menge —
|
||||||
|
// dieselbe Struktur, die internal/web/antrag_handlers.go erzeugt
|
||||||
|
// (antwortenFromForm): ein flaches JSON-Objekt mit denselben
|
||||||
|
// Fakten-Schlüsseln wie in den Regelwerk-YAML-Dateien (b1..b7, c1..c5,
|
||||||
|
// c2_folge, c3_art).
|
||||||
|
type Antworten map[string]any
|
||||||
|
|
||||||
|
// ParseAntworten liest antrag.antworten (rohes JSON aus der Datenbank).
|
||||||
|
func ParseAntworten(raw []byte) (Antworten, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return Antworten{}, nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("rules: antworten parsen: %w", err)
|
||||||
|
}
|
||||||
|
return Antworten(m), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede
|
// istJaOderUnsicher prüft eine B-Frage. "Unsicher zählt wie Ja" ist eine
|
||||||
// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules.
|
// Auswertungsregel (siehe CLAUDE.md, Fragebogen-Abschnitt B) — deshalb
|
||||||
|
// hier und nicht schon beim Speichern des Antrags angewendet.
|
||||||
|
func (a Antworten) istJaOderUnsicher(key string) bool {
|
||||||
|
v, _ := a[key].(string)
|
||||||
|
return v == "ja" || v == "unsicher"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatenklasseErgebnis ist die abgeleitete Datenklasse mit Herleitung —
|
||||||
|
// "Jede Anforderung/Ableitung trägt ihre Herleitung, sonst ist das
|
||||||
|
// Ergebnis im Audit wertlos" (siehe CLAUDE.md).
|
||||||
|
type DatenklasseErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateDatenklasse leitet die Datenklasse aus den B-Antworten ab.
|
||||||
|
// "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft
|
||||||
|
// keine Stufe zu (z. B. wenn versehentlich auch B7 mit "nein"
|
||||||
|
// beantwortet wurde, obwohl keine andere Kategorie zutrifft — ein
|
||||||
|
// eigentlich widersprüchlicher Fragebogen-Zustand), wird konservativ
|
||||||
|
// "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht
|
||||||
|
// weniger. Siehe rules/OPEN.md, Punkt 5.
|
||||||
|
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis {
|
||||||
|
const fallback = "intern"
|
||||||
|
var gewinner *DatenklasseStufe
|
||||||
|
var treffer []string
|
||||||
|
for i := range regelwerk.Stufen {
|
||||||
|
st := ®elwerk.Stufen[i]
|
||||||
|
var stTreffer []string
|
||||||
|
for _, ausloeser := range st.Ausloeser {
|
||||||
|
if antworten.istJaOderUnsicher(ausloeser) {
|
||||||
|
stTreffer = append(stTreffer, ausloeser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
|
||||||
|
gewinner = st
|
||||||
|
treffer = stTreffer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gewinner == nil {
|
||||||
|
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
|
||||||
|
}
|
||||||
|
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
||||||
|
// den Antworten passen (UND-Verknüpfung innerhalb der Variante).
|
||||||
|
func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
|
||||||
|
for key, want := range variante {
|
||||||
|
got, ok := antworten[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch wantVal := want.(type) {
|
||||||
|
case bool:
|
||||||
|
gotBool, ok := got.(bool)
|
||||||
|
if !ok || gotBool != wantVal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
gotStr, ok := got.(string)
|
||||||
|
if !ok || gotStr != wantVal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// EinstufungErgebnis ist die abgeleitete KI-VO-Einstufung mit Herleitung.
|
||||||
|
type EinstufungErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Quelle string // z. B. "Art. 5 KI-VO", leer wenn die Stufe keine Quelle nennt
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatVariante liefert eine deterministische, lesbare Darstellung
|
||||||
|
// einer zutreffenden Variante, z. B. "c2=true, c2_folge=kreditwuerdigkeit".
|
||||||
|
func formatVariante(v EinstufungVariante) string {
|
||||||
|
keys := make([]string, 0, len(v))
|
||||||
|
for k := range v {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%v", k, v[k]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateEinstufung leitet die KI-VO-Einstufung aus den C-Antworten ab.
|
||||||
|
// Prüfreihenfolge: die erste zutreffende Stufe gewinnt (siehe
|
||||||
|
// Bewertungslogik, K.-o.-Prüfung) — deshalb steht "verboten" in
|
||||||
|
// rules/kivo_einstufung.yaml an erster Stelle. Liefert einen Fehler nur,
|
||||||
|
// wenn das Regelwerk selbst keine Auffangregel definiert (sollte durch
|
||||||
|
// LoadEinstufung bereits verhindert sein).
|
||||||
|
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (EinstufungErgebnis, error) {
|
||||||
|
for _, st := range regelwerk.Stufen {
|
||||||
|
if len(st.Varianten) == 0 {
|
||||||
|
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: "Auffangregel (keine speziellere Stufe traf zu)"}, nil
|
||||||
|
}
|
||||||
|
for _, variante := range st.Varianten {
|
||||||
|
if variantePasst(variante, antworten) {
|
||||||
|
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EinstufungErgebnis{}, fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IstVerboten prüft die K.-o.-Bedingung (Art. 5 KI-VO): bei "verboten"
|
||||||
|
// erfolgt sofortige Ablehnung, keine Werkzeugsuche.
|
||||||
|
func IstVerboten(einstufungID string) bool {
|
||||||
|
return einstufungID == "verboten"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnforderungErgebnis ist eine abgeleitete Anforderung mit Herleitung.
|
||||||
|
type AnforderungErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Beschreibung string
|
||||||
|
Herleitung string // z. B. "aus Datenklasse personenbezogen"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveAnforderungen leitet aus Datenklasse und Einstufung die Menge
|
||||||
|
// der Anforderungen ab, die ein Werkzeug erfüllen muss.
|
||||||
|
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis {
|
||||||
|
var out []AnforderungErgebnis
|
||||||
|
for _, a := range regelwerk.Anforderungen {
|
||||||
|
var gruende []string
|
||||||
|
if containsString(a.AusDatenklassen, datenklasseID) {
|
||||||
|
gruende = append(gruende, "Datenklasse "+datenklasseID)
|
||||||
|
}
|
||||||
|
if containsString(a.AusEinstufungen, einstufungID) {
|
||||||
|
gruende = append(gruende, "Einstufung "+einstufungID)
|
||||||
|
}
|
||||||
|
if len(gruende) > 0 {
|
||||||
|
out = append(out, AnforderungErgebnis{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: "aus " + strings.Join(gruende, " und ")})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(list []string, v string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugEigenschaften sind die für die harte Filterung relevanten
|
||||||
|
// Felder eines Katalogeintrags — bewusst ein eigener, schlanker Typ
|
||||||
|
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
||||||
|
// unabhängig von internal/store und für sich allein testbar.
|
||||||
|
type WerkzeugEigenschaften struct {
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
// Verarbeitungslaender nennt die tatsächlichen Länder statt eines
|
||||||
|
// groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
|
||||||
|
// eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
|
||||||
|
// genannten Länder EU/EWR-Mitgliedstaaten sind.
|
||||||
|
Verarbeitungslaender []string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
// AufbewahrungTage ist nil, wenn der Anbieter keine Aufbewahrungsdauer
|
||||||
|
// beziffert — das erfüllt eine gesetzte Löschfrist-Anforderung NICHT
|
||||||
|
// (fail closed), analog zur leeren Länderliste bei eu_verarbeitung.
|
||||||
|
AufbewahrungTage *int
|
||||||
|
}
|
||||||
|
|
||||||
|
// euEwrLaender sind die Staaten, für die eine Verarbeitung nicht als
|
||||||
|
// Drittlandtransfer gilt (Art. 44 ff. DSGVO) — die 27 EU-Mitgliedstaaten
|
||||||
|
// plus die drei über den EWR-Vertrag gleichgestellten Staaten. Die USA
|
||||||
|
// gehören bewusst NICHT dazu — sie sind ein Drittland wie jedes andere,
|
||||||
|
// unabhängig vom EU-US Data Privacy Framework (das mildert die
|
||||||
|
// Transfer-Grundlage, macht die USA aber nicht zum EU/EWR-Gebiet).
|
||||||
|
var euEwrLaender = map[string]bool{
|
||||||
|
"Belgien": true, "Bulgarien": true, "Dänemark": true, "Deutschland": true,
|
||||||
|
"Estland": true, "Finnland": true, "Frankreich": true, "Griechenland": true,
|
||||||
|
"Irland": true, "Italien": true, "Kroatien": true, "Lettland": true,
|
||||||
|
"Litauen": true, "Luxemburg": true, "Malta": true, "Niederlande": true,
|
||||||
|
"Österreich": true, "Polen": true, "Portugal": true, "Rumänien": true,
|
||||||
|
"Schweden": true, "Slowakei": true, "Slowenien": true, "Spanien": true,
|
||||||
|
"Tschechien": true, "Ungarn": true, "Zypern": true,
|
||||||
|
"Island": true, "Liechtenstein": true, "Norwegen": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// alleLaenderInEUEWR prüft, ob jedes genannte Land EU/EWR ist. Eine
|
||||||
|
// leere Liste (kein Land benannt) gilt als NICHT erfüllt — "wissen wir
|
||||||
|
// nicht" darf nie stillschweigend als "ist okay" durchgehen.
|
||||||
|
func alleLaenderInEUEWR(laender []string) bool {
|
||||||
|
if len(laender) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range laender {
|
||||||
|
if !euEwrLaender[l] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
||||||
|
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
||||||
|
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||||
|
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||||
|
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||||
|
// Werkzeug selbst.
|
||||||
//
|
//
|
||||||
// Ist f.Consideration "unklar", wird KEINE Bewertung abgegeben —
|
// loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
|
||||||
// needsClarification ist dann true und findings ist immer leer. Das ist
|
// konfigurierte Frist (siehe store.LoeschfristEinstellung) — nil
|
||||||
// Absicht (Kernprinzip): eine unsichere Extraktion erzeugt eine
|
// bedeutet "für diesen Mandanten/diese Datenklasse nicht konfiguriert"
|
||||||
// Rückfrage an den Nutzer, niemals eine stille "keine Findings"-
|
// und wird NICHT hart gefiltert (Rückwärtskompatibilität, siehe
|
||||||
// Bewertung, die wie "alles in Ordnung" aussähe.
|
// rules/OPEN.md, Punkt 4); ist eine Frist gesetzt, erfüllt ein Werkzeug
|
||||||
func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification bool) {
|
// ohne bezifferte Aufbewahrungsdauer sie NICHT (fail closed).
|
||||||
if f.Consideration == ConsiderationUnclear {
|
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften, loeschfristMaxTage *int) bool {
|
||||||
return nil, true
|
switch anforderungID {
|
||||||
}
|
case "avv_erforderlich":
|
||||||
|
return w.AVVVerfuegbar
|
||||||
for _, r := range rules {
|
case "eu_verarbeitung":
|
||||||
if r.Jurisdiction != f.Jurisdiction {
|
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||||
continue
|
case "kein_training_auf_eingabe":
|
||||||
|
return w.TrainingStandard
|
||||||
|
case "loeschfrist_max_tage":
|
||||||
|
if loeschfristMaxTage == nil {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
if r.Condition.Matches(f) {
|
if w.AufbewahrungTage == nil {
|
||||||
findings = append(findings, Finding{
|
return false
|
||||||
RuleID: r.ID,
|
|
||||||
RuleVersion: r.Version,
|
|
||||||
Severity: r.Severity,
|
|
||||||
Title: r.Title,
|
|
||||||
Fix: r.Fix,
|
|
||||||
Sources: r.Sources,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return *w.AufbewahrungTage <= *loeschfristMaxTage
|
||||||
|
default:
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return findings, false
|
}
|
||||||
|
|
||||||
|
// AusschlussGrund hält fest, warum ein Werkzeug aussortiert wurde —
|
||||||
|
// auch aussortierte Werkzeuge werden im Ergebnis gezeigt (siehe
|
||||||
|
// Bewertungslogik), nie stillschweigend weggelassen.
|
||||||
|
type AusschlussGrund struct {
|
||||||
|
WerkzeugID string
|
||||||
|
NichtErfuellt []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugKandidat ist ein Katalogeintrag im harten Filter.
|
||||||
|
type WerkzeugKandidat struct {
|
||||||
|
ID string
|
||||||
|
Eigenschaften WerkzeugEigenschaften
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||||
|
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||||
|
// Werkzeug fest, welche Anforderungen fehlten. loeschfristMaxTage siehe
|
||||||
|
// ErfuelltAnforderung.
|
||||||
|
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||||
|
for _, k := range kandidaten {
|
||||||
|
var fehlend []string
|
||||||
|
for _, reqID := range anforderungIDs {
|
||||||
|
if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
|
||||||
|
fehlend = append(fehlend, reqID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fehlend) == 0 {
|
||||||
|
zulaessig = append(zulaessig, k.ID)
|
||||||
|
} else {
|
||||||
|
ausgeschlossen = append(ausgeschlossen, AusschlussGrund{WerkzeugID: k.ID, NichtErfuellt: fehlend})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zulaessig, ausgeschlossen
|
||||||
|
}
|
||||||
|
|
||||||
|
// String liefert eine lesbare Begründung, z. B. für die
|
||||||
|
// Ergebnisdarstellung.
|
||||||
|
func (a AusschlussGrund) String() string {
|
||||||
|
return fmt.Sprintf("%s: erfüllt nicht %s", a.WerkzeugID, strings.Join(a.NichtErfuellt, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegelwerkVersion liefert eine reproduzierbare Kennung des geladenen
|
||||||
|
// Regelwerk-Stands — wird mit jeder Bewertung eingefroren, damit im
|
||||||
|
// Audit nachvollziehbar bleibt, mit welcher Regelwerk-Version ein
|
||||||
|
// Vorschlag erzeugt wurde (analog zu store.CurrentKatalogVersion für
|
||||||
|
// den Werkzeugkatalog).
|
||||||
|
func RegelwerkVersion(dk DatenklasseRegelwerk, ei EinstufungRegelwerk, an AnforderungsRegelwerk) string {
|
||||||
|
return fmt.Sprintf("dk%d.ei%d.an%d", dk.Version, ei.Version, an.Version)
|
||||||
}
|
}
|
||||||
|
|||||||
247
internal/rules/evaluate_test.go
Normal file
247
internal/rules/evaluate_test.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package rules_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadRealRegelwerke(t *testing.T) (rules.DatenklasseRegelwerk, rules.EinstufungRegelwerk, rules.AnforderungsRegelwerk) {
|
||||||
|
t.Helper()
|
||||||
|
fsys := realRulesFS(t)
|
||||||
|
dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDatenklasse: %v", err)
|
||||||
|
}
|
||||||
|
ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnforderungen: %v", err)
|
||||||
|
}
|
||||||
|
return dk, ei, an
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseOnlyOeffentlich(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "ja"}
|
||||||
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
|
if got.ID != "oeffentlich" {
|
||||||
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want oeffentlich", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseHighestWins(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
// b1 (personenbezogen) UND b2 (besondere_kategorie) beide ja ->
|
||||||
|
// besondere_kategorie hat den hoeheren Rang und muss gewinnen.
|
||||||
|
antworten := rules.Antworten{"b1": "ja", "b2": "ja", "b7": "nein"}
|
||||||
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
|
if got.ID != "besondere_kategorie" {
|
||||||
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want besondere_kategorie", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseUnsicherZaehltWieJa(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"b3": "unsicher"}
|
||||||
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
|
if got.ID != "berufsgeheimnis" {
|
||||||
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseFallsBackToInternWhenNothingMatches(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "nein"}
|
||||||
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
|
if got.ID != "intern" {
|
||||||
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want intern (konservativer Fallback)", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungVerboten(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c3": true, "c3_art": "social_scoring"}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "verboten" {
|
||||||
|
t.Fatalf("EvaluateEinstufung.ID = %q, want verboten", got.ID)
|
||||||
|
}
|
||||||
|
if !rules.IstVerboten(got.ID) {
|
||||||
|
t.Error("IstVerboten sollte true liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungHochrisiko(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c2": true, "c2_folge": "kreditwuerdigkeit", "c3": false}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "hochrisiko" {
|
||||||
|
t.Fatalf("EvaluateEinstufung.ID = %q, want hochrisiko", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c1": true, "c2": false, "c3": false, "c5": false}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "transparenzpflicht" {
|
||||||
|
t.Fatalf("EvaluateEinstufung.ID = %q, want transparenzpflicht", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungMinimalFallback(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c1": false, "c2": false, "c3": false, "c4": false, "c5": true}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "minimal" {
|
||||||
|
t.Fatalf("EvaluateEinstufung.ID = %q, want minimal", got.ID)
|
||||||
|
}
|
||||||
|
if rules.IstVerboten(got.ID) {
|
||||||
|
t.Error("IstVerboten sollte fuer minimal false liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAnforderungenPersonenbezogen(t *testing.T) {
|
||||||
|
_, _, an := loadRealRegelwerke(t)
|
||||||
|
got := rules.DeriveAnforderungen(an, "personenbezogen", "minimal")
|
||||||
|
want := map[string]bool{"avv_erforderlich": true, "kein_training_auf_eingabe": true, "loeschfrist_max_tage": true}
|
||||||
|
gotSet := map[string]bool{}
|
||||||
|
for _, a := range got {
|
||||||
|
gotSet[a.ID] = true
|
||||||
|
if a.Herleitung == "" {
|
||||||
|
t.Errorf("Anforderung %q hat keine Herleitung", a.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range want {
|
||||||
|
if !gotSet[id] {
|
||||||
|
t.Errorf("expected Anforderung %q for personenbezogen/minimal, got %v", id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotSet["eu_verarbeitung"] {
|
||||||
|
t.Error("eu_verarbeitung sollte fuer personenbezogen (ohne berufsgeheimnis/besondere_kategorie) nicht ausgeloest werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
||||||
|
_, _, an := loadRealRegelwerke(t)
|
||||||
|
got := rules.DeriveAnforderungen(an, "oeffentlich", "hochrisiko")
|
||||||
|
gotSet := map[string]bool{}
|
||||||
|
for _, a := range got {
|
||||||
|
gotSet[a.ID] = true
|
||||||
|
}
|
||||||
|
if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] {
|
||||||
|
t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungAVV(t *testing.T) {
|
||||||
|
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}, nil) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||||
|
}
|
||||||
|
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}, nil) {
|
||||||
|
t.Error("erwartet: erfuellt mit AVV")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}, nil) {
|
||||||
|
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}, nil) {
|
||||||
|
t.Error("erwartet: nicht erfuellt bei USA - die USA sind ein Drittland wie jedes andere")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}, nil) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}, nil) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungLoeschfrist(t *testing.T) {
|
||||||
|
dreissig := 30
|
||||||
|
neunzig := 90
|
||||||
|
// Keine Frist konfiguriert -> nicht hart gefiltert (Rückwärtskompatibilität).
|
||||||
|
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, nil) {
|
||||||
|
t.Error("erwartet: erfuellt ohne konfigurierte Frist")
|
||||||
|
}
|
||||||
|
// Frist gesetzt, Werkzeug bleibt innerhalb -> erfuellt.
|
||||||
|
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &dreissig}, &neunzig) {
|
||||||
|
t.Error("erwartet: erfuellt, wenn Aufbewahrung <= Frist")
|
||||||
|
}
|
||||||
|
// Frist gesetzt, Werkzeug überschreitet -> nicht erfuellt.
|
||||||
|
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &neunzig}, &dreissig) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrung > Frist")
|
||||||
|
}
|
||||||
|
// Frist gesetzt, Werkzeug beziffert Aufbewahrung nicht -> fail closed.
|
||||||
|
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, &dreissig) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrungsdauer unbekannt ist ('wissen wir nicht' darf nicht als 'ist okay' durchgehen)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}, nil) {
|
||||||
|
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||||
|
kandidaten := []rules.WerkzeugKandidat{
|
||||||
|
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||||
|
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
|
||||||
|
}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}, nil)
|
||||||
|
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||||
|
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||||
|
}
|
||||||
|
if len(ausgeschlossen) != 1 || ausgeschlossen[0].WerkzeugID != "ohne-avv" {
|
||||||
|
t.Fatalf("ausgeschlossen = %v, want genau ohne-avv", ausgeschlossen)
|
||||||
|
}
|
||||||
|
if len(ausgeschlossen[0].NichtErfuellt) != 1 || ausgeschlossen[0].NichtErfuellt[0] != "avv_erforderlich" {
|
||||||
|
t.Fatalf("NichtErfuellt = %v, want [avv_erforderlich]", ausgeschlossen[0].NichtErfuellt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||||
|
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil, nil)
|
||||||
|
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||||
|
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAntwortenRoundTrip(t *testing.T) {
|
||||||
|
raw := []byte(`{"b1":"ja","c2":true,"c2_folge":"bildung"}`)
|
||||||
|
a, err := rules.ParseAntworten(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAntworten: %v", err)
|
||||||
|
}
|
||||||
|
if a["b1"] != "ja" || a["c2_folge"] != "bildung" {
|
||||||
|
t.Fatalf("ParseAntworten = %+v, unerwartete Werte", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAntwortenEmpty(t *testing.T) {
|
||||||
|
a, err := rules.ParseAntworten(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAntworten: %v", err)
|
||||||
|
}
|
||||||
|
if len(a) != 0 {
|
||||||
|
t.Fatalf("ParseAntworten(nil) = %+v, want empty", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
seen := make(map[string]bool)
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
|
||||||
var result []Rule
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := fs.ReadFile(fsys, entry.Name())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("rules: read %s: %w", entry.Name(), err)
|
|
||||||
}
|
|
||||||
var r Rule
|
|
||||||
if err := yaml.Unmarshal(data, &r); err != nil {
|
|
||||||
return nil, fmt.Errorf("rules: parse %s: %w", entry.Name(), err)
|
|
||||||
}
|
|
||||||
if r.ID == "" {
|
|
||||||
return nil, fmt.Errorf("rules: %s: missing id", entry.Name())
|
|
||||||
}
|
|
||||||
if r.Version == 0 {
|
|
||||||
return nil, fmt.Errorf("rules: %s: missing version", entry.Name())
|
|
||||||
}
|
|
||||||
if r.Jurisdiction == "" {
|
|
||||||
return nil, fmt.Errorf("rules: %s: missing land (jurisdiction)", entry.Name())
|
|
||||||
}
|
|
||||||
if seen[r.ID] {
|
|
||||||
return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), r.ID)
|
|
||||||
}
|
|
||||||
seen[r.ID] = true
|
|
||||||
result = append(result, r)
|
|
||||||
}
|
}
|
||||||
return result, nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadEinstufung lädt und validiert die KI-VO-Einstufungstabelle.
|
||||||
|
func LoadEinstufung(fsys fs.FS, path string) (EinstufungRegelwerk, error) {
|
||||||
|
var rw EinstufungRegelwerk
|
||||||
|
data, err := fs.ReadFile(fsys, path)
|
||||||
|
if err != nil {
|
||||||
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung lesen: %w", err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||||
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung parsen: %w", err)
|
||||||
|
}
|
||||||
|
if rw.Version < 1 {
|
||||||
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: version fehlt oder ungültig")
|
||||||
|
}
|
||||||
|
if len(rw.Stufen) == 0 {
|
||||||
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: keine stufen definiert")
|
||||||
|
}
|
||||||
|
seenIDs := map[string]bool{}
|
||||||
|
for i, st := range rw.Stufen {
|
||||||
|
if st.ID == "" {
|
||||||
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe ohne id")
|
||||||
|
}
|
||||||
|
if seenIDs[st.ID] {
|
||||||
|
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: doppelte stufe-id %q", st.ID)
|
||||||
|
}
|
||||||
|
seenIDs[st.ID] = true
|
||||||
|
// 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 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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,39 +2,172 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"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. EinladungToken ist
|
||||||
|
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
|
||||||
|
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
|
||||||
|
// Strasse/PLZ/Ort/Land/UStID/Rechnungsemail sind die Firmen- und
|
||||||
|
// Abrechnungsdaten (Migration 0023) — bei bestehenden, vor dieser
|
||||||
|
// Migration angelegten Accounts können sie leer sein, siehe dort.
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
CreatedAt time.Time
|
EinladungToken string
|
||||||
|
Strasse string
|
||||||
|
PLZ string
|
||||||
|
Ort string
|
||||||
|
Land string
|
||||||
|
UStID string
|
||||||
|
Rechnungsemail string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateAccount legt einen neuen Mandanten an.
|
// AccountInput bündelt die Firmendaten für CreateAccount/
|
||||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
// UpdateAccountDetails. Name ist die einzige Pflichtangabe auf
|
||||||
|
// Store-Ebene — welche der übrigen Felder ein Formular tatsächlich
|
||||||
|
// verlangt (z. B. Adresse bei Neuanlage), entscheidet die Web-Schicht,
|
||||||
|
// nicht der Store (Tests legen Accounts oft ohne vollständige
|
||||||
|
// Firmendaten an, das ist auf Store-Ebene kein Fehler).
|
||||||
|
type AccountInput struct {
|
||||||
|
Name string
|
||||||
|
Strasse string
|
||||||
|
PLZ string
|
||||||
|
Ort string
|
||||||
|
Land string
|
||||||
|
UStID string
|
||||||
|
Rechnungsemail string
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountColumns = `id, name, einladung_token, strasse, plz, ort, land, ust_id, rechnungsemail, created_at`
|
||||||
|
|
||||||
|
func scanAccount(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Account, error) {
|
||||||
var a Account
|
var a Account
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.Strasse, &a.PLZ, &a.Ort, &a.Land, &a.UStID, &a.Rechnungsemail, &a.CreatedAt)
|
||||||
INSERT INTO account (name) VALUES ($1)
|
return a, err
|
||||||
RETURNING id, name, created_at
|
}
|
||||||
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
|
||||||
|
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
||||||
|
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
||||||
|
func (s *Store) CreateAccount(ctx context.Context, in AccountInput) (Account, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO account (name, strasse, plz, ort, land, ust_id, rechnungsemail)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING `+accountColumns,
|
||||||
|
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
|
||||||
|
)
|
||||||
|
a, err := scanAccount(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Account{}, fmt.Errorf("store: create account: %w", err)
|
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||||
|
// Betreiber-gestützten Anlage korrigieren) — ändert bewusst nur den
|
||||||
|
// Namen, nicht die übrigen Firmendaten, siehe UpdateAccountDetails.
|
||||||
|
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: update account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAccountDetails aktualisiert Name und Firmen-/Abrechnungsdaten
|
||||||
|
// gemeinsam — genutzt von der Firmendaten-Seite (Ebene 4, admin), auf
|
||||||
|
// der ein Mandant seine eigenen Angaben pflegt/nachträgt.
|
||||||
|
func (s *Store) UpdateAccountDetails(ctx context.Context, id string, in AccountInput) (Account, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
UPDATE account SET name = $2, strasse = $3, plz = $4, ort = $5, land = $6, ust_id = $7, rechnungsemail = $8
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+accountColumns,
|
||||||
|
id, in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
|
||||||
|
)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: update account details: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||||
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
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
a, err := scanAccount(row)
|
||||||
SELECT id, name, created_at FROM account WHERE id = $1
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Account{}, fmt.Errorf("store: get account: %w", err)
|
return Account{}, fmt.Errorf("store: get account: %w", err)
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccountByEinladungToken liest den Mandanten zu einem
|
||||||
|
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
|
||||||
|
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
|
||||||
|
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
||||||
|
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
|
||||||
|
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
|
||||||
|
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
||||||
|
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: regenerate einladung token: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
||||||
|
// Admin-Bereich (Accounts-Verwaltung).
|
||||||
|
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Account
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAccount(rows)
|
||||||
|
if 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 accounts: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
140
internal/store/admin_test.go
Normal file
140
internal/store/admin_test.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser mit role=betreiber: %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != "betreiber" {
|
||||||
|
t.Fatalf("Role = %q, want betreiber", u.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppUserRoleRejectsUnknownRole(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.CreateUser(ctx, accID, "unbekannt@example.com", "hash", "kanzlei"); err == nil {
|
||||||
|
t.Fatal("expected the old role 'kanzlei' to be rejected after the product pivot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAccounts(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before, err := s.ListAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAccounts: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Neuer Mandant fuer ListAccounts"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := s.ListAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAccounts: %v", err)
|
||||||
|
}
|
||||||
|
if len(after) != len(before)+1 {
|
||||||
|
t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after))
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, a := range after {
|
||||||
|
if a.ID == acc.ID {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected the newly created account in ListAccounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListUsersForAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
otherAccID := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "mitarbeiter"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "verantwortlicher"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "mitarbeiter"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListUsersForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUsersForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditLogCreateAndList(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "manuell geprüft")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAuditEntry: %v", err)
|
||||||
|
}
|
||||||
|
if entry.ActorUserID != admin.ID {
|
||||||
|
t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListAuditLog(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAuditLog: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
t.Fatal("expected at least one audit entry")
|
||||||
|
}
|
||||||
|
if list[0].ID != entry.ID {
|
||||||
|
t.Fatalf("expected the newest entry first, got %+v", list[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuditLogIsAppendOnly(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAuditEntry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
_, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
internal/store/auditlog.go
Normal file
61
internal/store/auditlog.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
|
||||||
|
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
|
||||||
|
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
|
||||||
|
type AuditEntry struct {
|
||||||
|
ID string
|
||||||
|
ActorUserID string
|
||||||
|
Action string
|
||||||
|
TargetType string
|
||||||
|
TargetID string
|
||||||
|
Details string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||||
|
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||||
|
var e AuditEntry
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
||||||
|
`, actorUserID, action, targetType, targetID, details).Scan(
|
||||||
|
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
||||||
|
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
||||||
|
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||||
|
`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []AuditEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e AuditEntry
|
||||||
|
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan audit entry: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ func TestAccountCRUD(t *testing.T) {
|
|||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAccount: %v", err)
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,142 @@ func TestAccountCRUD(t *testing.T) {
|
|||||||
if got.Name != "Beispiel Agentur GmbH" {
|
if got.Name != "Beispiel Agentur GmbH" {
|
||||||
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
|
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
|
||||||
}
|
}
|
||||||
|
if got.EinladungToken == "" {
|
||||||
|
t.Error("expected a newly created account to have a non-empty EinladungToken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAccountMitFirmendaten(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{
|
||||||
|
Name: "Vollstaendig GmbH", Strasse: "Musterstraße 1", PLZ: "12345", Ort: "Musterstadt",
|
||||||
|
Land: "Deutschland", UStID: "DE123456789", Rechnungsemail: "rechnung@vollstaendig.example.com",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if acc.Strasse != "Musterstraße 1" || acc.PLZ != "12345" || acc.Ort != "Musterstadt" ||
|
||||||
|
acc.Land != "Deutschland" || acc.UStID != "DE123456789" || acc.Rechnungsemail != "rechnung@vollstaendig.example.com" {
|
||||||
|
t.Fatalf("Account = %+v, Firmendaten unvollständig gespeichert", acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Rechnungsemail != acc.Rechnungsemail {
|
||||||
|
t.Fatalf("Rechnungsemail nach GetAccount = %q, want %q", got.Rechnungsemail, acc.Rechnungsemail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccountDetails(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alte Firma", Ort: "Altstadt"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.UpdateAccountDetails(ctx, acc.ID, store.AccountInput{
|
||||||
|
Name: "Neue Firma", Strasse: "Neue Straße 2", PLZ: "54321", Ort: "Neustadt",
|
||||||
|
Land: "Österreich", UStID: "", Rechnungsemail: "buchhaltung@neue-firma.example.com",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateAccountDetails: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Neue Firma" || updated.Ort != "Neustadt" || updated.Land != "Österreich" {
|
||||||
|
t.Fatalf("Account nach Update = %+v, nicht wie erwartet aktualisiert", updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccountDetailsNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := s.UpdateAccountDetails(ctx, "00000000-0000-0000-0000-000000000000", store.AccountInput{Name: "X"})
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alter Name GmbH"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Neuer Name GmbH" {
|
||||||
|
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Neuer Name GmbH" {
|
||||||
|
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAccountNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountByEinladungToken: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != acc.ID {
|
||||||
|
t.Fatalf("GetAccountByEinladungToken returned a different account")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateEinladungToken(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
alterToken := acc.EinladungToken
|
||||||
|
|
||||||
|
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
|
||||||
|
t.Fatalf("RegenerateEinladungToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("expected the old token to be invalid, got err = %v", err)
|
||||||
|
}
|
||||||
|
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != acc.ID {
|
||||||
|
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUserCRUD(t *testing.T) {
|
func TestUserCRUD(t *testing.T) {
|
||||||
@@ -32,7 +168,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)
|
||||||
}
|
}
|
||||||
@@ -52,6 +188,49 @@ func TestUserCRUD(t *testing.T) {
|
|||||||
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||||
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
||||||
}
|
}
|
||||||
|
if !byID.Active {
|
||||||
|
t.Error("expected a newly created user to be Active by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUserActive(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetUserActive(ctx, user.ID, false); err != nil {
|
||||||
|
t.Fatalf("SetUserActive (false): %v", err)
|
||||||
|
}
|
||||||
|
deaktiviert, err := s.GetUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if deaktiviert.Active {
|
||||||
|
t.Fatal("expected the user to be inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetUserActive(ctx, user.ID, true); err != nil {
|
||||||
|
t.Fatalf("SetUserActive (true): %v", err)
|
||||||
|
}
|
||||||
|
reaktiviert, err := s.GetUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if !reaktiviert.Active {
|
||||||
|
t.Fatal("expected the user to be active again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUserActiveNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetUserByEmailNotFound(t *testing.T) {
|
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||||
@@ -67,10 +246,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 +259,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)
|
||||||
}
|
}
|
||||||
@@ -98,6 +277,9 @@ func TestSessionCRUD(t *testing.T) {
|
|||||||
if got.UserID != user.ID {
|
if got.UserID != user.ID {
|
||||||
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||||
}
|
}
|
||||||
|
if got.ImpersonatedByUserID != nil {
|
||||||
|
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||||
t.Fatalf("DeleteSession: %v", err)
|
t.Fatalf("DeleteSession: %v", err)
|
||||||
@@ -114,3 +296,38 @@ func TestGetSessionNotFound(t *testing.T) {
|
|||||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateImpersonatedSession(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
kundenAcc := testAccountID(t, s)
|
||||||
|
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Kunde): %v", err)
|
||||||
|
}
|
||||||
|
betreiberAcc := testAccountID(t, s)
|
||||||
|
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser (Betreiber): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||||
|
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateImpersonatedSession: %v", err)
|
||||||
|
}
|
||||||
|
if sess.UserID != kundenNutzer.ID {
|
||||||
|
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
|
||||||
|
}
|
||||||
|
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetSession(ctx, sess.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
|
||||||
|
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
139
internal/store/bewertung.go
Normal file
139
internal/store/bewertung.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BewertungAnforderung ist eine abgeleitete Anforderung mit Herleitung,
|
||||||
|
// wie sie in bewertung.anforderungen (JSONB) gespeichert wird.
|
||||||
|
type BewertungAnforderung struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Beschreibung string `json:"beschreibung"`
|
||||||
|
Herleitung string `json:"herleitung"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BewertungAusschluss hält fest, warum ein Werkzeug für eine Bewertung
|
||||||
|
// ausgeschlossen wurde.
|
||||||
|
type BewertungAusschluss struct {
|
||||||
|
WerkzeugID string `json:"werkzeug_id"`
|
||||||
|
NichtErfuellt []string `json:"nicht_erfuellt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewertung ist der berechnete Vorschlag für einen Antrag. Append-only
|
||||||
|
// — siehe Migration.
|
||||||
|
type Bewertung struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []BewertungAnforderung
|
||||||
|
ZulaessigeWerkzeuge []string
|
||||||
|
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const bewertungColumns = `id, antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||||
|
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version, created_at`
|
||||||
|
|
||||||
|
func scanBewertung(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Bewertung, error) {
|
||||||
|
var b Bewertung
|
||||||
|
var anforderungenRaw, ausschlussRaw []byte
|
||||||
|
err := row.Scan(
|
||||||
|
&b.ID, &b.AntragID, &b.Datenklasse, &b.DatenklasseHerleitung, &b.Einstufung, &b.EinstufungHerleitung,
|
||||||
|
&b.Verboten, &anforderungenRaw, &b.ZulaessigeWerkzeuge, &ausschlussRaw, &b.RegelwerkVersion, &b.KatalogVersion, &b.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(anforderungenRaw, &b.Anforderungen); err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: anforderungen unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ausschlussRaw, &b.AusgeschlosseneWerkzeuge); err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BewertungInput bündelt die Felder einer neuen Bewertung — bei elf
|
||||||
|
// Werten lesbarer als eine positionale Parameterliste (gleiches Muster
|
||||||
|
// wie WerkzeugInput).
|
||||||
|
type BewertungInput struct {
|
||||||
|
AntragID string
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []BewertungAnforderung
|
||||||
|
ZulaessigeWerkzeuge []string
|
||||||
|
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBewertung speichert eine berechnete Bewertung für einen Antrag.
|
||||||
|
func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertung, error) {
|
||||||
|
if in.ZulaessigeWerkzeuge == nil {
|
||||||
|
in.ZulaessigeWerkzeuge = []string{}
|
||||||
|
}
|
||||||
|
if in.Anforderungen == nil {
|
||||||
|
in.Anforderungen = []BewertungAnforderung{}
|
||||||
|
}
|
||||||
|
if in.AusgeschlosseneWerkzeuge == nil {
|
||||||
|
in.AusgeschlosseneWerkzeuge = []BewertungAusschluss{}
|
||||||
|
}
|
||||||
|
anforderungenJSON, err := json.Marshal(in.Anforderungen)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: anforderungen marshal: %w", err)
|
||||||
|
}
|
||||||
|
ausschlussJSON, err := json.Marshal(in.AusgeschlosseneWerkzeuge)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO bewertung (
|
||||||
|
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||||
|
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING `+bewertungColumns,
|
||||||
|
in.AntragID, in.Datenklasse, in.DatenklasseHerleitung, in.Einstufung, in.EinstufungHerleitung,
|
||||||
|
in.Verboten, anforderungenJSON, in.ZulaessigeWerkzeuge, ausschlussJSON, in.RegelwerkVersion, in.KatalogVersion,
|
||||||
|
)
|
||||||
|
b, err := scanBewertung(row)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: create bewertung: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestBewertungForAntrag liefert die zuletzt berechnete Bewertung
|
||||||
|
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
||||||
|
// existiert.
|
||||||
|
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT `+bewertungColumns+`
|
||||||
|
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
|
`, antragID)
|
||||||
|
b, err := scanBewertung(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Bewertung{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: get latest bewertung: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
126
internal/store/bewertung_test.go
Normal file
126
internal/store/bewertung_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBewertungInput(antragID string) store.BewertungInput {
|
||||||
|
return store.BewertungInput{
|
||||||
|
AntragID: antragID, Datenklasse: "personenbezogen", DatenklasseHerleitung: "ausgelöst durch b1",
|
||||||
|
Einstufung: "minimal", EinstufungHerleitung: "Auffangregel", Verboten: false,
|
||||||
|
Anforderungen: []store.BewertungAnforderung{
|
||||||
|
{ID: "avv_erforderlich", Beschreibung: "AVV nötig", Herleitung: "aus Datenklasse personenbezogen"},
|
||||||
|
},
|
||||||
|
ZulaessigeWerkzeuge: []string{"werkzeug-a"},
|
||||||
|
AusgeschlosseneWerkzeuge: []store.BewertungAusschluss{
|
||||||
|
{WerkzeugID: "werkzeug-b", NichtErfuellt: []string{"avv_erforderlich"}},
|
||||||
|
},
|
||||||
|
RegelwerkVersion: "dk1.ei1.an1", KatalogVersion: "3-1735300000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungCreateAndGetLatest(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Testantrag")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung: %v", err)
|
||||||
|
}
|
||||||
|
if b.Datenklasse != "personenbezogen" || len(b.Anforderungen) != 1 {
|
||||||
|
t.Fatalf("CreateBewertung = %+v, unerwartete Werte", b)
|
||||||
|
}
|
||||||
|
if b.Anforderungen[0].ID != "avv_erforderlich" {
|
||||||
|
t.Fatalf("Anforderungen[0] = %+v, unerwartet", b.Anforderungen[0])
|
||||||
|
}
|
||||||
|
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-b" {
|
||||||
|
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, unerwartet", b.AusgeschlosseneWerkzeuge)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != b.ID {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag returned a different row than CreateBewertung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestBewertungForAntragNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Bewertung")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungReturnsNewestWhenMultiple(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Mehrfach bewertet")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID)); err != nil {
|
||||||
|
t.Fatalf("CreateBewertung (1): %v", err)
|
||||||
|
}
|
||||||
|
second := testBewertungInput(antrag.ID)
|
||||||
|
second.Datenklasse = "besondere_kategorie"
|
||||||
|
newest, err := s.CreateBewertung(ctx, second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung (2): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != newest.ID || got.Datenklasse != "besondere_kategorie" {
|
||||||
|
t.Fatalf("expected the newest bewertung, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungIsAppendOnly(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Append-only Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE bewertung SET datenklasse = 'geaendert' WHERE id = $1`, b.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on bewertung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
_, err = s.Pool.Exec(ctx, `DELETE FROM bewertung WHERE id = $1`, b.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected DELETE on bewertung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
116
internal/store/email_vorlage.go
Normal file
116
internal/store/email_vorlage.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
|
||||||
|
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
|
||||||
|
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
|
||||||
|
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailVorlage struct {
|
||||||
|
ID string
|
||||||
|
AccountID *string
|
||||||
|
Typ string
|
||||||
|
Betreff string
|
||||||
|
Text string
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
|
||||||
|
// accountID nil schreibt den plattformweiten Standard (nur für den
|
||||||
|
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
|
||||||
|
//
|
||||||
|
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
|
||||||
|
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
|
||||||
|
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
|
||||||
|
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
|
||||||
|
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
|
||||||
|
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
|
||||||
|
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
|
||||||
|
var v EmailVorlage
|
||||||
|
var err error
|
||||||
|
if accountID == nil {
|
||||||
|
err = s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||||
|
VALUES (NULL, $1, $2, $3)
|
||||||
|
ON CONFLICT (typ) WHERE account_id IS NULL
|
||||||
|
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
|
||||||
|
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||||
|
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||||
|
} else {
|
||||||
|
err = s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO email_vorlage (account_id, typ, betreff, text)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
|
||||||
|
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
|
||||||
|
RETURNING id, account_id, typ, betreff, text, updated_at
|
||||||
|
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
|
||||||
|
// accountID nil sucht den plattformweiten Standard.
|
||||||
|
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
|
||||||
|
var v EmailVorlage
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||||
|
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||||
|
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return EmailVorlage{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
|
||||||
|
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
|
||||||
|
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
|
||||||
|
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
|
||||||
|
// Plattform-Standard wird per Migration angelegt).
|
||||||
|
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
|
||||||
|
var v EmailVorlage
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
|
||||||
|
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
|
||||||
|
ORDER BY account_id NULLS LAST
|
||||||
|
LIMIT 1
|
||||||
|
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return EmailVorlage{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
|
||||||
|
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
|
||||||
|
// wieder auf den plattformweiten Standard zurück), beim Betreiber
|
||||||
|
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
|
||||||
|
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
|
||||||
|
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `
|
||||||
|
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
|
||||||
|
`, accountID, typ)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete email vorlage: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
117
internal/store/email_vorlage_test.go
Normal file
117
internal/store/email_vorlage_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEmailVorlage (Plattform): %v", err)
|
||||||
|
}
|
||||||
|
if v.AccountID != nil {
|
||||||
|
t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
|
||||||
|
}
|
||||||
|
if v.Betreff == "" || v.Text == "" {
|
||||||
|
t.Fatalf("Plattform-Standard unvollständig: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
// Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
|
||||||
|
resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
|
||||||
|
}
|
||||||
|
if resolved.AccountID != nil {
|
||||||
|
t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
|
||||||
|
eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertEmailVorlage: %v", err)
|
||||||
|
}
|
||||||
|
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
|
||||||
|
}
|
||||||
|
if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
|
||||||
|
t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
|
||||||
|
if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
|
||||||
|
t.Fatalf("DeleteEmailVorlage: %v", err)
|
||||||
|
}
|
||||||
|
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
|
||||||
|
}
|
||||||
|
if resolved.AccountID != nil {
|
||||||
|
t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
|
||||||
|
// Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
|
||||||
|
// naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
|
||||||
|
// Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
|
||||||
|
// Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
|
||||||
|
// aktualisieren (genau das ist beim Live-Verifizieren passiert).
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertEmailVorlage (1): %v", err)
|
||||||
|
}
|
||||||
|
second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertEmailVorlage (2): %v", err)
|
||||||
|
}
|
||||||
|
if first.ID != second.ID {
|
||||||
|
t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetEmailVorlage: %v", err)
|
||||||
|
}
|
||||||
|
if got.Betreff != "Zweiter Betreff" {
|
||||||
|
t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accA := testAccountID(t, s)
|
||||||
|
accB := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
|
||||||
|
t.Fatalf("UpsertEmailVorlage A: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveEmailVorlage B: %v", err)
|
||||||
|
}
|
||||||
|
if resolvedB.Betreff == "Vorlage A" {
|
||||||
|
t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
internal/store/entscheidung.go
Normal file
146
internal/store/entscheidung.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über
|
||||||
|
// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only —
|
||||||
|
// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige
|
||||||
|
// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein
|
||||||
|
// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer
|
||||||
|
// Katalog-Wandel darf nicht rückwirkend verändern, worauf die
|
||||||
|
// Entscheidung beruhte.
|
||||||
|
type Entscheidung struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
BewertungID string
|
||||||
|
EntscheiderUserID string
|
||||||
|
Entscheidung string
|
||||||
|
WerkzeugID *string
|
||||||
|
WerkzeugSnapshot *Werkzeug
|
||||||
|
Begruendung string
|
||||||
|
GueltigBis *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung,
|
||||||
|
werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at`
|
||||||
|
|
||||||
|
func scanEntscheidung(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Entscheidung, error) {
|
||||||
|
var e Entscheidung
|
||||||
|
var snapshotRaw []byte
|
||||||
|
err := row.Scan(
|
||||||
|
&e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung,
|
||||||
|
&e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Entscheidung{}, err
|
||||||
|
}
|
||||||
|
if snapshotRaw != nil {
|
||||||
|
var w Werkzeug
|
||||||
|
if err := json.Unmarshal(snapshotRaw, &w); err != nil {
|
||||||
|
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
e.WerkzeugSnapshot = &w
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntscheidungInput bündelt die Felder einer neuen Entscheidung.
|
||||||
|
type EntscheidungInput struct {
|
||||||
|
AntragID string
|
||||||
|
BewertungID string
|
||||||
|
EntscheiderUserID string
|
||||||
|
Entscheidung string
|
||||||
|
WerkzeugID *string
|
||||||
|
WerkzeugSnapshot *Werkzeug
|
||||||
|
Begruendung string
|
||||||
|
GueltigBis *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEntscheidung speichert eine Entscheidung über einen Antrag.
|
||||||
|
func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) {
|
||||||
|
var snapshotJSON []byte
|
||||||
|
if in.WerkzeugSnapshot != nil {
|
||||||
|
var err error
|
||||||
|
snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot)
|
||||||
|
if err != nil {
|
||||||
|
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO entscheidung (
|
||||||
|
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING `+entscheidungColumns,
|
||||||
|
in.AntragID, in.BewertungID, in.EntscheiderUserID, in.Entscheidung, in.WerkzeugID, snapshotJSON, in.Begruendung, in.GueltigBis,
|
||||||
|
)
|
||||||
|
e, err := scanEntscheidung(row)
|
||||||
|
if err != nil {
|
||||||
|
return Entscheidung{}, fmt.Errorf("store: create entscheidung: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestEntscheidungForAntrag liefert die zuletzt getroffene
|
||||||
|
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
|
||||||
|
// Entscheidung existiert.
|
||||||
|
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT `+entscheidungColumns+`
|
||||||
|
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
|
`, antragID)
|
||||||
|
e, err := scanEntscheidung(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Entscheidung{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAktiveGenehmigungenForAccount liefert alle Genehmigungen
|
||||||
|
// ("genehmigt"/"genehmigt_mit_auflagen") eines Mandanten — Grundlage
|
||||||
|
// für die Wiedervorlage (Schritt 7): abgelaufene/bald ablaufende
|
||||||
|
// Genehmigungen und solche, deren zugrunde liegendes Werkzeug sich seit
|
||||||
|
// der Entscheidung im Katalog geändert hat, muss der/die Verantwortliche
|
||||||
|
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
|
||||||
|
// zum Mandanten läuft über den zugehörigen antrag.
|
||||||
|
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
|
||||||
|
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
||||||
|
FROM entscheidung e
|
||||||
|
JOIN antrag a ON a.id = e.antrag_id
|
||||||
|
WHERE a.account_id = $1 AND e.entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen')
|
||||||
|
ORDER BY e.created_at DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Entscheidung
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanEntscheidung(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan entscheidung: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
153
internal/store/entscheidung_test.go
Normal file
153
internal/store/entscheidung_test.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testAntragMitBewertung(t *testing.T, s *store.Store, accountID, userID string) (store.Antrag, store.Bewertung) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accountID, userID, nil, "Testantrag")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung: %v", err)
|
||||||
|
}
|
||||||
|
return antrag, b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntscheidungCreateAndGetLatest(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "ChatGPT Enterprise"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug: %v", err)
|
||||||
|
}
|
||||||
|
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||||
|
|
||||||
|
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
|
||||||
|
Entscheidung: "genehmigt", WerkzeugID: &w.ID, WerkzeugSnapshot: &w,
|
||||||
|
Begruendung: "", GueltigBis: &gueltigBis,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung: %v", err)
|
||||||
|
}
|
||||||
|
if e.Entscheidung != "genehmigt" || e.WerkzeugSnapshot == nil || e.WerkzeugSnapshot.Name != "ChatGPT Enterprise" {
|
||||||
|
t.Fatalf("CreateEntscheidung = %+v, unerwartete Werte", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestEntscheidungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != e.ID {
|
||||||
|
t.Fatalf("GetLatestEntscheidungForAntrag returned a different row than CreateEntscheidung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntscheidungOhneWerkzeugHatKeinenSnapshot(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
|
||||||
|
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
|
||||||
|
Entscheidung: "abgelehnt", Begruendung: "Kein geeignetes Werkzeug im Katalog",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung: %v", err)
|
||||||
|
}
|
||||||
|
if e.WerkzeugID != nil || e.WerkzeugSnapshot != nil {
|
||||||
|
t.Fatalf("expected no werkzeug/snapshot for an abgelehnt Entscheidung, got %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestEntscheidungForAntragNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Entscheidung")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntscheidungIsAppendOnly(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, b := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, Entscheidung: "rueckfrage",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE entscheidung SET entscheidung = 'genehmigt' WHERE id = $1`, e.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on entscheidung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
_, err = s.Pool.Exec(ctx, `DELETE FROM entscheidung WHERE id = $1`, e.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAktiveGenehmigungenForAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
|
||||||
|
antragGenehmigt, b1 := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antragGenehmigt.ID, BewertungID: b1.ID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung (genehmigt): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
antragAbgelehnt, b2 := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||||
|
AntragID: antragAbgelehnt.ID, BewertungID: b2.ID, EntscheiderUserID: userID, Entscheidung: "abgelehnt",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung (abgelehnt): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListAktiveGenehmigungenForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAktiveGenehmigungenForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].AntragID != antragGenehmigt.ID {
|
||||||
|
t.Fatalf("ListAktiveGenehmigungenForAccount = %+v, want exactly the genehmigt entscheidung", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherAcc := testAccountID(t, s)
|
||||||
|
otherList, err := s.ListAktiveGenehmigungenForAccount(ctx, otherAcc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAktiveGenehmigungenForAccount (other): %v", err)
|
||||||
|
}
|
||||||
|
if len(otherList) != 0 {
|
||||||
|
t.Fatalf("expected no genehmigungen for a different tenant, got %+v", otherList)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,74 +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.
|
|
||||||
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
388
internal/store/freigabe.go
Normal file
388
internal/store/freigabe.go
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||||
|
// und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion
|
||||||
|
// (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von
|
||||||
|
// app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich
|
||||||
|
// sein oder keines von beidem.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenehmigerRolle struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Name string
|
||||||
|
Beschreibung string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
|
||||||
|
// Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
|
||||||
|
// Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine
|
||||||
|
// Bedingung, das bleibt Sache von freigabe_regel.
|
||||||
|
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, account_id, name, beschreibung, created_at
|
||||||
|
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||||
|
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
|
||||||
|
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return GenehmigerRolle{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||||
|
// Mandanten.
|
||||||
|
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
|
||||||
|
WHERE account_id = $1 ORDER BY name
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list genehmiger rollen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerRolle
|
||||||
|
for rows.Next() {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen
|
||||||
|
// (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln
|
||||||
|
// hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher
|
||||||
|
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||||
|
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||||
|
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||||
|
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
_, err := s.db(ctx).Exec(ctx, `
|
||||||
|
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||||
|
`, userID, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||||
|
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
_, err := s.db(ctx).Exec(ctx, `
|
||||||
|
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||||
|
`, userID, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||||
|
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||||
|
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||||
|
FROM genehmiger_rolle gr
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
||||||
|
WHERE ngr.app_user_id = $1
|
||||||
|
ORDER BY gr.name
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerRolle
|
||||||
|
for rows.Next() {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle
|
||||||
|
// innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite.
|
||||||
|
type GenehmigerMitglied struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||||
|
// bestimmten Genehmiger-Rolle.
|
||||||
|
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT u.id, u.email
|
||||||
|
FROM app_user u
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
||||||
|
WHERE ngr.genehmiger_rolle_id = $1
|
||||||
|
ORDER BY u.email
|
||||||
|
`, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerMitglied
|
||||||
|
for rows.Next() {
|
||||||
|
var m GenehmigerMitglied
|
||||||
|
if err := rows.Scan(&m.UserID, &m.Email); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine
|
||||||
|
// Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von
|
||||||
|
// "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige
|
||||||
|
// ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko").
|
||||||
|
type FreigabeRegel struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
BedingungTyp string
|
||||||
|
BedingungWert string
|
||||||
|
GenehmigerRolleID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||||
|
var f FreigabeRegel
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
`, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan(
|
||||||
|
&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list freigabe regeln: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []FreigabeRegel
|
||||||
|
for rows.Next() {
|
||||||
|
var f FreigabeRegel
|
||||||
|
if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um
|
||||||
|
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||||
|
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||||
|
var f FreigabeRegel
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
FROM freigabe_regel WHERE id = $1
|
||||||
|
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return FreigabeRegel{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freigabeschritt ist eine einzelne, durch eine FreigabeRegel
|
||||||
|
// ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht
|
||||||
|
// append-only — ein Schritt geht von "ausstehend" in genau einen
|
||||||
|
// Endzustand über, keine Historie mehrerer Entscheidungen zum selben
|
||||||
|
// Schritt.
|
||||||
|
type Freigabeschritt struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
GenehmigerRolleID string
|
||||||
|
Status string // ausstehend/genehmigt/abgelehnt
|
||||||
|
EntschiedenVon *string
|
||||||
|
EntschiedenAm *time.Time
|
||||||
|
Kommentar string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||||
|
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
`, antragID, genehmigerRolleID).Scan(
|
||||||
|
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
FROM freigabeschritt WHERE id = $1
|
||||||
|
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Freigabeschritt{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||||
|
`, antragID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Freigabeschritt
|
||||||
|
for rows.Next() {
|
||||||
|
var f Freigabeschritt
|
||||||
|
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAusstehendeFreigabeschritteForUser liefert alle offenen
|
||||||
|
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||||
|
// innehat — Grundlage für "Meine Freigaben".
|
||||||
|
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
|
||||||
|
FROM freigabeschritt f
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
||||||
|
WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend'
|
||||||
|
ORDER BY f.created_at
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Freigabeschritt
|
||||||
|
for rows.Next() {
|
||||||
|
var f Freigabeschritt
|
||||||
|
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntscheideFreigabeschritt trägt die Entscheidung einer Person zu
|
||||||
|
// einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus
|
||||||
|
// erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits
|
||||||
|
// entschiedenen Schritten wäre irreführend, deshalb hier ein
|
||||||
|
// generischer Fehler statt eines Sentinels).
|
||||||
|
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
UPDATE freigabeschritt
|
||||||
|
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||||
|
WHERE id = $1 AND status = 'ausstehend'
|
||||||
|
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
`, id, status, entschiedenVon, kommentar).Scan(
|
||||||
|
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Freigabeschritt{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte
|
||||||
|
// eines Antrags automatisch ab, nachdem ein anderer Schritt desselben
|
||||||
|
// Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den
|
||||||
|
// gesamten Antrag, die übrigen offenen Freigaben werden dadurch
|
||||||
|
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||||
|
// hängen zu bleiben.
|
||||||
|
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||||
|
_, err := s.db(ctx).Exec(ctx, `
|
||||||
|
UPDATE freigabeschritt
|
||||||
|
SET status = 'abgelehnt', entschieden_am = now(),
|
||||||
|
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||||
|
WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2
|
||||||
|
`, antragID, ausloesenderSchrittID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: kaskadiere ablehnung: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
174
internal/store/freigabe_test.go
Normal file
174
internal/store/freigabe_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
if g.AccountID != accID || g.Name != "Datenschutzbeauftragter" {
|
||||||
|
t.Fatalf("GenehmigerRolle = %+v, unerwartete Werte", g)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetGenehmigerRolle(ctx, g.ID)
|
||||||
|
if err != nil || got.Name != "Datenschutzbeauftragter" {
|
||||||
|
t.Fatalf("GetGenehmigerRolle = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListGenehmigerRollenForAccount(ctx, accID)
|
||||||
|
if err != nil || len(liste) != 1 {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForAccount = %+v, err=%v", liste, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := testUserID(t, s, accID)
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
// Erneutes Hinzufügen derselben Zuordnung ist ein No-op (ON CONFLICT),
|
||||||
|
// kein Fehler.
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle (erneut): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rollenDesNutzers, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||||
|
if err != nil || len(rollenDesNutzers) != 1 || rollenDesNutzers[0].ID != g.ID {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForUser = %+v, err=%v", rollenDesNutzers, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mitglieder, err := s.ListNutzerForGenehmigerRolle(ctx, g.ID)
|
||||||
|
if err != nil || len(mitglieder) != 1 || mitglieder[0].UserID != user {
|
||||||
|
t.Fatalf("ListNutzerForGenehmigerRolle = %+v, err=%v", mitglieder, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.RemoveNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("RemoveNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
rollenNachEntfernen, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||||
|
if err != nil || len(rollenNachEntfernen) != 0 {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForUser nach Entfernen = %+v, err=%v", rollenNachEntfernen, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteGenehmigerRolle(ctx, g.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetGenehmigerRolle(ctx, g.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeRegelCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
regel, err := s.CreateFreigabeRegel(ctx, accID, "einstufung", "hochrisiko", g.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||||
|
}
|
||||||
|
if regel.BedingungTyp != "einstufung" || regel.BedingungWert != "hochrisiko" {
|
||||||
|
t.Fatalf("FreigabeRegel = %+v, unerwartete Werte", regel)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListFreigabeRegelnForAccount(ctx, accID)
|
||||||
|
if err != nil || len(liste) != 1 {
|
||||||
|
t.Fatalf("ListFreigabeRegelnForAccount = %+v, err=%v", liste, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetFreigabeRegel(ctx, regel.ID)
|
||||||
|
if err != nil || got.ID != regel.ID {
|
||||||
|
t.Fatalf("GetFreigabeRegel = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteFreigabeRegel(ctx, regel.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteFreigabeRegel: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetFreigabeRegel(ctx, regel.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
user := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, user, nil, "Test-Antrag für Freigabeschritte")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
antragID := antrag.ID
|
||||||
|
|
||||||
|
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
|
||||||
|
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
|
||||||
|
|
||||||
|
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if schrittDSB.Status != "ausstehend" {
|
||||||
|
t.Fatalf("Status = %q, want ausstehend", schrittDSB.Status)
|
||||||
|
}
|
||||||
|
schrittGF, err := s.CreateFreigabeschritt(ctx, antragID, gf.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alle, err := s.ListFreigabeschritteForAntrag(ctx, antragID)
|
||||||
|
if err != nil || len(alle) != 2 {
|
||||||
|
t.Fatalf("ListFreigabeschritteForAntrag = %+v, err=%v", alle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ausstehend, err := s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAusstehendeFreigabeschritteForUser: %v", err)
|
||||||
|
}
|
||||||
|
if len(ausstehend) != 0 {
|
||||||
|
t.Fatalf("erwartet 0 ausstehende Freigaben ohne Genehmiger-Rollen-Zuordnung, got %d", len(ausstehend))
|
||||||
|
}
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, dsb.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
ausstehend, err = s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||||
|
if err != nil || len(ausstehend) != 1 || ausstehend[0].ID != schrittDSB.ID {
|
||||||
|
t.Fatalf("ListAusstehendeFreigabeschritteForUser = %+v, err=%v", ausstehend, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entschieden, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "abgelehnt", user, "nicht ausreichend")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntscheideFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if entschieden.Status != "abgelehnt" || entschieden.EntschiedenVon == nil || *entschieden.EntschiedenVon != user {
|
||||||
|
t.Fatalf("Freigabeschritt nach Entscheidung = %+v, unerwartete Werte", entschieden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein bereits entschiedener Schritt kann nicht erneut entschieden werden.
|
||||||
|
if _, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "genehmigt", user, ""); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("erneute Entscheidung err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.KaskadiereAblehnung(ctx, antragID, schrittDSB.ID); err != nil {
|
||||||
|
t.Fatalf("KaskadiereAblehnung: %v", err)
|
||||||
|
}
|
||||||
|
nachKaskade, err := s.GetFreigabeschritt(ctx, schrittGF.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if nachKaskade.Status != "abgelehnt" {
|
||||||
|
t.Fatalf("Status nach Kaskade = %q, want abgelehnt", nachKaskade.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/store/loeschfrist.go
Normal file
69
internal/store/loeschfrist.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Löschfristen je Datenklasse (Migration 0020) — schließt die in
|
||||||
|
// rules/OPEN.md, Punkt 4 dokumentierte Lücke: loeschfrist_max_tage war
|
||||||
|
// bisher nur als Anforderung "vorhanden", ohne Tageswerte, und wurde
|
||||||
|
// deshalb nicht hart gefiltert. Die konkreten Tageswerte sind KEINE
|
||||||
|
// gesetzliche Vorgabe (die DSGVO nennt keine festen Fristen, nur den
|
||||||
|
// Grundsatz "so lange wie für den Zweck nötig", Art. 5 Abs. 1 lit. e) —
|
||||||
|
// deshalb pro Mandant einstellbar statt hartkodiert, vom
|
||||||
|
// Datenschutzbeauftragten hinterlegt. Bei Firmenanlage werden
|
||||||
|
// risikogestaffelte Vorschlagswerte vorbelegt (siehe
|
||||||
|
// internal/web/loeschfrist_handlers.go), frei editierbar.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoeschfristEinstellung struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
DatenklasseID string
|
||||||
|
MaxTage int
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertLoeschfristEinstellung legt eine Einstellung an oder
|
||||||
|
// aktualisiert sie — ein Mandant hat höchstens eine Frist je
|
||||||
|
// Datenklasse (UNIQUE-Constraint).
|
||||||
|
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
|
||||||
|
var e LoeschfristEinstellung
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (account_id, datenklasse_id)
|
||||||
|
DO UPDATE SET max_tage = $3, updated_at = now()
|
||||||
|
RETURNING id, account_id, datenklasse_id, max_tage, updated_at
|
||||||
|
`, accountID, datenklasseID, maxTage).Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return LoeschfristEinstellung{}, fmt.Errorf("store: upsert loeschfrist einstellung: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
|
||||||
|
// Fristen eines Mandanten.
|
||||||
|
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT id, account_id, datenklasse_id, max_tage, updated_at
|
||||||
|
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []LoeschfristEinstellung
|
||||||
|
for rows.Next() {
|
||||||
|
var e LoeschfristEinstellung
|
||||||
|
if err := rows.Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan loeschfrist einstellung: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
41
internal/store/loeschfrist_test.go
Normal file
41
internal/store/loeschfrist_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoeschfristEinstellungUpsert(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
e, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 90)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||||
|
}
|
||||||
|
if e.MaxTage != 90 {
|
||||||
|
t.Fatalf("MaxTage = %d, want 90", e.MaxTage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erneutes Upsert für dieselbe Datenklasse überschreibt statt zu duplizieren.
|
||||||
|
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 30); err != nil {
|
||||||
|
t.Fatalf("UpsertLoeschfristEinstellung (Update): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "besondere_kategorie", 14); err != nil {
|
||||||
|
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListLoeschfristEinstellungenForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListLoeschfristEinstellungenForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(liste) != 2 {
|
||||||
|
t.Fatalf("got %d Einstellungen, want 2 (Upsert darf nicht duplizieren)", len(liste))
|
||||||
|
}
|
||||||
|
for _, e := range liste {
|
||||||
|
if e.DatenklasseID == "personenbezogen" && e.MaxTage != 30 {
|
||||||
|
t.Fatalf("personenbezogen MaxTage = %d, want 30 nach Update", e.MaxTage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal/store/migrations/0004_admin.down.sql
Normal file
6
internal/store/migrations/0004_admin.down.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
DROP TRIGGER audit_log_append_only ON audit_log;
|
||||||
|
DROP TABLE audit_log;
|
||||||
|
ALTER TABLE account DROP COLUMN verified;
|
||||||
|
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'));
|
||||||
36
internal/store/migrations/0004_admin.up.sql
Normal file
36
internal/store/migrations/0004_admin.up.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT),
|
||||||
|
-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei)
|
||||||
|
-- gebunden, sondern zuständig für die Plattform selbst (Accounts,
|
||||||
|
-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld
|
||||||
|
-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user
|
||||||
|
-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine
|
||||||
|
-- Selbstregistrierung für "admin" über /register (siehe internal/web) —
|
||||||
|
-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md).
|
||||||
|
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'));
|
||||||
|
|
||||||
|
-- verified markiert eine Kanzlei-Account als für das öffentliche,
|
||||||
|
-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b
|
||||||
|
-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist
|
||||||
|
-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit
|
||||||
|
-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die
|
||||||
|
-- Anwendungsschicht, nicht die Datenbank.
|
||||||
|
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie
|
||||||
|
-- finding/extraction/evidence_package — ein Protokoll, das man ändern
|
||||||
|
-- kann, ist kein Nachweis mehr.
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
actor_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
details TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER audit_log_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON audit_log
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
@@ -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
internal/store/migrations/0009_bewertung.down.sql
Normal file
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE bewertung;
|
||||||
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Bewertung: der berechnete Vorschlag für einen Antrag (Datenklasse,
|
||||||
|
-- KI-VO-Einstufung, Anforderungsprofil, zulässige/ausgeschlossene
|
||||||
|
-- Werkzeuge mit Herleitung). Append-only wie finding/extraction im
|
||||||
|
-- alten Produkt — eine neue Bewertung (z. B. nach Änderung der
|
||||||
|
-- Antworten oder des Katalogs) ergänzt die alte, ersetzt sie nicht;
|
||||||
|
-- die Historie bleibt für den Audit erhalten. regelwerk_version und
|
||||||
|
-- katalog_version werden zum Berechnungszeitpunkt eingefroren.
|
||||||
|
CREATE TABLE bewertung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
datenklasse TEXT NOT NULL,
|
||||||
|
datenklasse_herleitung TEXT NOT NULL,
|
||||||
|
einstufung TEXT NOT NULL,
|
||||||
|
einstufung_herleitung TEXT NOT NULL,
|
||||||
|
verboten BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
anforderungen JSONB NOT NULL DEFAULT '[]',
|
||||||
|
zulaessige_werkzeuge TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
ausgeschlossene_werkzeuge JSONB NOT NULL DEFAULT '[]',
|
||||||
|
regelwerk_version TEXT NOT NULL,
|
||||||
|
katalog_version TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER bewertung_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON bewertung
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE entscheidung;
|
||||||
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Entscheidung: der Mensch entscheidet, das System bereitet nur vor
|
||||||
|
-- (siehe CLAUDE.md, Grundregel). Eine Entscheidung bezieht sich auf
|
||||||
|
-- genau eine Bewertung (den Vorschlag, auf dessen Basis entschieden
|
||||||
|
-- wurde) und friert optional ein gewähltes Werkzeug als vollständigen
|
||||||
|
-- Datensatz ein (werkzeug_snapshot) — ein späterer Katalog-Wandel darf
|
||||||
|
-- nicht rückwirkend verändern, worauf eine Entscheidung beruhte.
|
||||||
|
-- Append-only wie bewertung/audit_log: eine Entscheidung wird nicht
|
||||||
|
-- korrigiert, sondern durch eine neue Entscheidung auf Basis einer
|
||||||
|
-- neuen Bewertung ersetzt (Neubewertung + erneute Entscheidung).
|
||||||
|
CREATE TABLE entscheidung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
bewertung_id UUID NOT NULL REFERENCES bewertung (id),
|
||||||
|
entscheider_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
entscheidung TEXT NOT NULL CHECK (entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen', 'abgelehnt', 'rueckfrage')),
|
||||||
|
werkzeug_id UUID REFERENCES werkzeug (id),
|
||||||
|
werkzeug_snapshot JSONB,
|
||||||
|
begruendung TEXT NOT NULL DEFAULT '',
|
||||||
|
gueltig_bis TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER entscheidung_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON entscheidung
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE registereintrag;
|
||||||
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- Registereintrag: wird automatisch bei jeder Genehmigung erzeugt
|
||||||
|
-- (genehmigt / genehmigt_mit_auflagen), siehe CLAUDE.md, Entscheidung/
|
||||||
|
-- Register/Wiedervorlage. Bewusst denormalisiert (eigene Textspalten
|
||||||
|
-- statt Joins über bewertung/entscheidung/werkzeug) — ein Registereintrag
|
||||||
|
-- ist ein für sich lesbarer, exportierbarer Nachweis, der nicht von
|
||||||
|
-- einem späteren Katalog- oder Abteilungs-Wandel abhängen darf.
|
||||||
|
-- Append-only wie bewertung/entscheidung.
|
||||||
|
CREATE TABLE registereintrag (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
entscheidung_id UUID NOT NULL REFERENCES entscheidung (id),
|
||||||
|
zweck TEXT NOT NULL,
|
||||||
|
abteilung TEXT NOT NULL DEFAULT '',
|
||||||
|
werkzeug TEXT NOT NULL,
|
||||||
|
datenklasse TEXT NOT NULL,
|
||||||
|
einstufung TEXT NOT NULL,
|
||||||
|
auflagen TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
verantwortlicher TEXT NOT NULL,
|
||||||
|
entschieden_am TIMESTAMPTZ NOT NULL,
|
||||||
|
gueltig_bis TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER registereintrag_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON registereintrag
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE app_user DROP COLUMN active;
|
||||||
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Nutzer können nicht gelöscht werden (app_user wird von antrag,
|
||||||
|
-- session, bewertung [über antrag], entscheidung, audit_log,
|
||||||
|
-- registereintrag [über antrag] per Foreign Key referenziert — ein
|
||||||
|
-- Hard-Delete würde die Historie zerstören). Stattdessen: deaktivieren.
|
||||||
|
-- Ein deaktivierter Nutzer kann sich nicht mehr anmelden, bleibt aber
|
||||||
|
-- als Akteur in Anträgen/Entscheidungen/Audit-Log nachvollziehbar.
|
||||||
|
ALTER TABLE app_user ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE account DROP COLUMN einladung_token;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
|
||||||
|
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
|
||||||
|
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
|
||||||
|
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
|
||||||
|
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
|
||||||
|
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
|
||||||
|
-- dass store.CreateAccount etwas ändern muss.
|
||||||
|
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE session DROP COLUMN impersonated_by_user_id;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
|
||||||
|
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
|
||||||
|
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
|
||||||
|
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
|
||||||
|
-- — die authenticate-Middleware liest das, um in der Nav einen
|
||||||
|
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
|
||||||
|
-- er/sie gerade im Kontext eines fremden Kontos handelt.
|
||||||
|
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);
|
||||||
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
5
internal/store/migrations/0015_werkzeug_details.down.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE werkzeug DROP COLUMN subprozessoren;
|
||||||
|
|
||||||
|
UPDATE werkzeug SET aufbewahrung_tage = 0 WHERE aufbewahrung_tage IS NULL;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET DEFAULT 0;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET NOT NULL;
|
||||||
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
12
internal/store/migrations/0015_werkzeug_details.up.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- aufbewahrung_tage war bisher NOT NULL DEFAULT 0 — das machte "0" doppeldeutig:
|
||||||
|
-- mal eine echte, belegte Zusicherung (z. B. DeepL: sofortige Löschung),
|
||||||
|
-- mal ein Platzhalter für "vom Anbieter nicht beziffert". NULL trennt das
|
||||||
|
-- sauber: "unbekannt" statt "angeblich sofort gelöscht".
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP NOT NULL;
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP DEFAULT;
|
||||||
|
|
||||||
|
-- Subprozessoren (z. B. Anthropic als Unterauftragsverarbeiter bei
|
||||||
|
-- Microsoft 365 Copilot, AWS bei DeepL) verändern die EU/USA-Einstufung
|
||||||
|
-- eines Werkzeugs materiell — bisher nur als Freitext in
|
||||||
|
-- "einschraenkungen" erfasst, jetzt als eigenes, durchsuchbares Feld.
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN subprozessoren TEXT[] NOT NULL DEFAULT '{}';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE werkzeug ADD COLUMN verarbeitungsort TEXT;
|
||||||
|
|
||||||
|
-- Ein Rückbau von Freitext-Ländern auf den alten Vier-Werte-Eimer ist
|
||||||
|
-- verlustbehaftet (welches Land genau "gemischt" war, geht verloren) —
|
||||||
|
-- bestmögliche Näherung statt Datenverlust durch NULL.
|
||||||
|
UPDATE werkzeug SET verarbeitungsort =
|
||||||
|
CASE
|
||||||
|
WHEN verarbeitungslaender = ARRAY['On-Premise (selbst gehostet)'] THEN 'on-prem'
|
||||||
|
WHEN verarbeitungslaender = ARRAY['USA'] THEN 'USA'
|
||||||
|
WHEN array_length(verarbeitungslaender, 1) IS NULL OR array_length(verarbeitungslaender, 1) = 0 THEN 'gemischt'
|
||||||
|
WHEN 'USA' = ANY(verarbeitungslaender) THEN 'gemischt'
|
||||||
|
ELSE 'EU'
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug ALTER COLUMN verarbeitungsort SET NOT NULL;
|
||||||
|
ALTER TABLE werkzeug ADD CONSTRAINT werkzeug_verarbeitungsort_check
|
||||||
|
CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem'));
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN verarbeitungslaender;
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN dpf_zertifiziert;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- "EU"/"USA"/"gemischt"/"on-prem" als grobe Eimer verschleierten, dass
|
||||||
|
-- auch die USA bereits ein Drittland sind (DSGVO Art. 44 ff. kennt nur
|
||||||
|
-- EU/EWR vs. Drittland) und dass "gemischt" nichts über die tatsächlich
|
||||||
|
-- beteiligten Länder aussagt (z. B. Irland+USA bei Microsoft 365
|
||||||
|
-- Copilot vs. Frankreich bei Mistral) — beides macht einen Unterschied
|
||||||
|
-- für die Einschätzung, nicht nur für die Anzeige. Ersetzt durch eine
|
||||||
|
-- Liste tatsächlicher Länder (oder einer Region, wenn der Anbieter
|
||||||
|
-- selbst keinen einzelnen Staat benennt, z. B. "EU-Region" bei
|
||||||
|
-- OpenAI/Microsoft).
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN verarbeitungslaender TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP CONSTRAINT werkzeug_verarbeitungsort_check;
|
||||||
|
|
||||||
|
UPDATE werkzeug SET verarbeitungslaender =
|
||||||
|
CASE verarbeitungsort
|
||||||
|
WHEN 'EU' THEN ARRAY['EU-Region (Land nicht migriert, bitte prüfen)']
|
||||||
|
WHEN 'USA' THEN ARRAY['USA']
|
||||||
|
WHEN 'on-prem' THEN ARRAY['On-Premise (selbst gehostet)']
|
||||||
|
ELSE ARRAY['nicht spezifiziert (bitte prüfen)']
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug DROP COLUMN verarbeitungsort;
|
||||||
|
|
||||||
|
-- Ob ein US-Anbieter unter dem EU-US Data Privacy Framework zertifiziert
|
||||||
|
-- ist (schmalere, für Schrems-Nachfolgeverfahren anfällige Rechtsgrundlage)
|
||||||
|
-- oder sich rein auf Standardvertragsklauseln stützt, stand bisher nur als
|
||||||
|
-- Fließtext in "einschraenkungen" — jetzt strukturiert und damit künftig
|
||||||
|
-- filterbar.
|
||||||
|
ALTER TABLE werkzeug ADD COLUMN dpf_zertifiziert BOOLEAN NOT NULL DEFAULT false;
|
||||||
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||||
|
UPDATE antrag SET status = 'eingereicht' WHERE status = 'wartet_auf_freigabe';
|
||||||
|
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||||
|
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden'));
|
||||||
|
|
||||||
|
DROP TABLE freigabeschritt;
|
||||||
|
DROP TABLE freigabe_regel;
|
||||||
|
DROP TABLE nutzer_genehmiger_rolle;
|
||||||
|
DROP TABLE genehmiger_rolle;
|
||||||
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
-- Konfigurierbarer Mehrfach-Freigabe-Workflow (2026-08-31): manche
|
||||||
|
-- Anträge brauchen zusätzlich zur normalen Fachebene-Entscheidung eine
|
||||||
|
-- Freigabe durch bestimmte Personen (z. B. Datenschutzbeauftragte bei
|
||||||
|
-- dsfa_erforderlich, Geschäftsführung bei hochrisiko). Bewusst additiv
|
||||||
|
-- zur bestehenden app_user.role (Zugriffskontrolle) — eine
|
||||||
|
-- Genehmiger-Rolle ist eine reine Freigabe-Funktion, keine Berechtigung,
|
||||||
|
-- und eine Person kann mehrere davon gleichzeitig innehaben.
|
||||||
|
|
||||||
|
-- Stammdaten wie abteilung, pro Mandant frei benennbar.
|
||||||
|
CREATE TABLE genehmiger_rolle (
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Welche Personen eine Genehmiger-Rolle innehaben (n:m).
|
||||||
|
CREATE TABLE nutzer_genehmiger_rolle (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (app_user_id, genehmiger_rolle_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
|
||||||
|
-- Genehmiger-Rolle Y nötig." Bedingung ist bewusst eine der bereits vom
|
||||||
|
-- Regelwerk abgeleiteten Größen (Anforderungs-ID, Einstufungs-ID,
|
||||||
|
-- Datenklasse-ID) — kein freier Regel-Editor, keine beliebige Logik.
|
||||||
|
CREATE TABLE freigabe_regel (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
bedingung_typ TEXT NOT NULL CHECK (bedingung_typ IN ('anforderung', 'einstufung', 'datenklasse')),
|
||||||
|
bedingung_wert TEXT NOT NULL,
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pro Antrag ein Eintrag je durch eine Regel ausgelöster nötiger
|
||||||
|
-- Freigabe. Nicht append-only (wie werkzeug) — ein Freigabeschritt ist
|
||||||
|
-- eine einzelne Aufgabe, die von ausstehend in einen Endzustand
|
||||||
|
-- übergeht, keine Historie mehrerer Entscheidungen zum selben Schritt.
|
||||||
|
CREATE TABLE freigabeschritt (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'ausstehend' CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')),
|
||||||
|
entschieden_von UUID REFERENCES app_user (id),
|
||||||
|
entschieden_am TIMESTAMPTZ,
|
||||||
|
kommentar TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Antrag, der auf zusätzliche Freigaben wartet, ist weder "nur
|
||||||
|
-- eingereicht" noch schon "entschieden" — eigener Zwischenzustand.
|
||||||
|
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||||
|
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||||
|
CHECK (status IN ('entwurf', 'eingereicht', 'wartet_auf_freigabe', 'entschieden'));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE password_reset_token;
|
||||||
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
10
internal/store/migrations/0019_password_reset_token.up.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE password_reset_token (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES app_user(id),
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX password_reset_token_user_id_idx ON password_reset_token(user_id);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE loeschfrist_einstellung;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE loeschfrist_einstellung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account(id),
|
||||||
|
datenklasse_id TEXT NOT NULL,
|
||||||
|
max_tage INTEGER NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, datenklasse_id)
|
||||||
|
);
|
||||||
58
internal/store/migrations/0021_row_level_security.down.sql
Normal file
58
internal/store/migrations/0021_row_level_security.down.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
ALTER TABLE antrag NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE antrag DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON antrag;
|
||||||
|
|
||||||
|
ALTER TABLE registereintrag NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE registereintrag DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON registereintrag;
|
||||||
|
|
||||||
|
ALTER TABLE abteilung NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE abteilung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON abteilung;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug_sperre NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE werkzeug_sperre DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON werkzeug_sperre;
|
||||||
|
|
||||||
|
ALTER TABLE genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE genehmiger_rolle DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON genehmiger_rolle;
|
||||||
|
|
||||||
|
ALTER TABLE freigabe_regel NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE freigabe_regel DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON freigabe_regel;
|
||||||
|
|
||||||
|
ALTER TABLE loeschfrist_einstellung NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE loeschfrist_einstellung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON loeschfrist_einstellung;
|
||||||
|
|
||||||
|
ALTER TABLE werkzeug NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE werkzeug DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON werkzeug;
|
||||||
|
|
||||||
|
ALTER TABLE bewertung NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE bewertung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON bewertung;
|
||||||
|
|
||||||
|
ALTER TABLE entscheidung NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE entscheidung DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON entscheidung;
|
||||||
|
|
||||||
|
ALTER TABLE freigabeschritt NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE freigabeschritt DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON freigabeschritt;
|
||||||
|
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON nutzer_genehmiger_rolle;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
|
||||||
|
EXECUTE 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM deklarix_app';
|
||||||
|
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM deklarix_app';
|
||||||
|
EXECUTE 'REVOKE USAGE ON SCHEMA public FROM deklarix_app';
|
||||||
|
DROP ROLE deklarix_app;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
187
internal/store/migrations/0021_row_level_security.up.sql
Normal file
187
internal/store/migrations/0021_row_level_security.up.sql
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
-- Mandantenisolation auf Datenbankebene (Postgres Row-Level Security).
|
||||||
|
--
|
||||||
|
-- WICHTIG, per Incident am 2026-09-01 gelernt: RLS-Policies wirken NIE
|
||||||
|
-- bei Postgres-Superusern, und NIE beim Tabellenbesitzer ohne FORCE ROW
|
||||||
|
-- LEVEL SECURITY. Welcher Fall zutrifft, hängt von der Umgebung ab:
|
||||||
|
-- - Produktion verbindet als eigene, nicht-privilegierte Rolle (z. B.
|
||||||
|
-- "deklarix"), die zugleich Eigentümerin der Tabellen ist (sie hat
|
||||||
|
-- sie über die Migrationen selbst angelegt) — für sie reicht FORCE
|
||||||
|
-- ROW LEVEL SECURITY völlig aus, keine weitere Rolle nötig.
|
||||||
|
-- - Manche Entwicklungs-/Testumgebungen verbinden dagegen als
|
||||||
|
-- echter Postgres-Superuser (z. B. lokales Docker-Postgres mit
|
||||||
|
-- "postgres") — für den wirkt FORCE nicht (Superuser sind davon
|
||||||
|
-- laut Postgres-Dokumentation ausdrücklich ausgenommen). Dort kann
|
||||||
|
-- zusätzlich eine eingeschränkte Rolle "deklarix_app" angelegt
|
||||||
|
-- werden, für die die Policies unabhängig von FORCE gelten.
|
||||||
|
--
|
||||||
|
-- Diese Migration deckt BEIDE Fälle ab, ohne bei fehlendem CREATEROLE
|
||||||
|
-- fehlzuschlagen (das brachte den Dienst am 2026-09-01 für ~3 Minuten
|
||||||
|
-- zum Absturz, siehe CLAUDE.md) — das Anlegen von "deklarix_app" ist
|
||||||
|
-- rein optional und wird übersprungen, wenn die aktuelle Rolle dafür
|
||||||
|
-- keine Berechtigung hat.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND rolcreaterole) THEN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
|
||||||
|
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
|
||||||
|
END IF;
|
||||||
|
EXECUTE 'GRANT USAGE ON SCHEMA public TO deklarix_app';
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app';
|
||||||
|
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle % hat kein CREATEROLE — deklarix_app wird übersprungen, FORCE ROW LEVEL SECURITY schützt stattdessen direkt die bestehende (Tabellenbesitzer-)Rolle.', current_user;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Login-Fähigkeit + Passwort für deklarix_app (falls angelegt) werden
|
||||||
|
-- einmalig manuell je Umgebung gesetzt, NIE in einer versionierten
|
||||||
|
-- Migration (Klartext-Secret gehört nicht ins Repo):
|
||||||
|
-- ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<generiertes Secret>';
|
||||||
|
-- Danach optional DATABASE_URL_APP in der jeweiligen deklarix.env
|
||||||
|
-- eintragen. Für Umgebungen, in denen die Anwendung bereits als
|
||||||
|
-- Tabellenbesitzer (nicht-Superuser) verbindet, ist das NICHT nötig —
|
||||||
|
-- FORCE ROW LEVEL SECURITY unten reicht dort aus.
|
||||||
|
|
||||||
|
-- ─── Tabellen MIT direkter account_id-Spalte ───────────────────────────
|
||||||
|
-- Eine einzelne Policy (FOR ALL) pro Tabelle deckt SELECT/UPDATE/DELETE
|
||||||
|
-- (USING) und INSERT/UPDATE (WITH CHECK) ab. NULLIF(..., '')::uuid
|
||||||
|
-- verhindert einen harten Cast-Fehler, wenn app.account_id nie gesetzt
|
||||||
|
-- oder auf '' steht (z. B. vor der eigentlichen Anmeldung) — die
|
||||||
|
-- Bedingung wird dann einfach UNKNOWN/false statt eines SQL-Fehlers.
|
||||||
|
CREATE POLICY tenant_isolation ON antrag FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE antrag ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE antrag FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE registereintrag ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE registereintrag FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE abteilung ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE abteilung FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE werkzeug_sperre ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE werkzeug_sperre FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE genehmiger_rolle ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE genehmiger_rolle FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE freigabe_regel ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE freigabe_regel FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
);
|
||||||
|
ALTER TABLE loeschfrist_einstellung ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE loeschfrist_einstellung FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── werkzeug: account_id NULLABLE (NULL = zentraler Katalog) ──────────
|
||||||
|
-- Lesen: jeder sieht zentrale (NULL) Einträge plus die eigenen. NUR der
|
||||||
|
-- Betreiber darf einen zentralen (NULL) Eintrag anlegen/ändern, ein
|
||||||
|
-- Mandant nur seine eigenen — sonst könnte ein Mandant über einen
|
||||||
|
-- vergessenen Anwendungscheck einen zentralen Katalogeintrag verändern.
|
||||||
|
CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING (
|
||||||
|
account_id IS NULL
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
);
|
||||||
|
ALTER TABLE werkzeug ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE werkzeug FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── Tabellen OHNE eigene account_id, über Fremdschlüssel abgeleitet ───
|
||||||
|
-- antrag/genehmiger_rolle sind selbst schon RLS-geschützt (s. o.) — eine
|
||||||
|
-- Unterabfrage gegen sie erbt in derselben Sitzung automatisch dieselbe
|
||||||
|
-- Mandantengrenze, ohne die Bedingung hier zu duplizieren.
|
||||||
|
CREATE POLICY tenant_isolation ON bewertung FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE bewertung ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE bewertung FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE entscheidung ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE entscheidung FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
) WITH CHECK (
|
||||||
|
antrag_id IN (SELECT id FROM antrag)
|
||||||
|
);
|
||||||
|
ALTER TABLE freigabeschritt ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE freigabeschritt FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
|
||||||
|
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
|
||||||
|
) WITH CHECK (
|
||||||
|
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
|
||||||
|
);
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE nutzer_genehmiger_rolle FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- ─── Bewusst OHNE RLS ───────────────────────────────────────────────────
|
||||||
|
-- account: hat keine account_id-Spalte (ist selbst der Mandant) und
|
||||||
|
-- muss bei der Registrierung uneingeschränkt INSERT erlauben, bevor
|
||||||
|
-- die neue ID überhaupt bekannt ist.
|
||||||
|
-- app_user: Login/Passwort-Zurücksetzen suchen per E-Mail über ALLE
|
||||||
|
-- Mandanten hinweg (die Ziel-account_id ist zu diesem Zeitpunkt noch
|
||||||
|
-- nicht bekannt) — kein Datenleck, da E-Mail-Adressen exakt und nicht
|
||||||
|
-- in Bulk abgefragt werden, kein sequentiell erratbarer Schlüssel.
|
||||||
|
-- session, password_reset_token: werden ausschließlich über einen
|
||||||
|
-- kryptographisch zufälligen, praktisch unerratbaren Token gesucht,
|
||||||
|
-- nicht über eine sequentielle ID — dieselbe Begründung wie app_user.
|
||||||
|
-- audit_log: plattformweites Protokoll, wird ausschließlich vom
|
||||||
|
-- Betreiber (Ebene 5, sieht ohnehin alle Mandanten) gelesen, hat keine
|
||||||
|
-- eigene account_id-Spalte.
|
||||||
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
4
internal/store/migrations/0022_email_vorlage.down.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
|
||||||
|
DROP TABLE email_vorlage;
|
||||||
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
55
internal/store/migrations/0022_email_vorlage.up.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
|
||||||
|
-- account_id NULL = plattformweiter Standard (nur vom Betreiber
|
||||||
|
-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
|
||||||
|
-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
|
||||||
|
-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
|
||||||
|
-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
|
||||||
|
-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
|
||||||
|
-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
|
||||||
|
-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
|
||||||
|
-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
|
||||||
|
-- internal/web/email_vorlage_handlers.go).
|
||||||
|
CREATE TABLE email_vorlage (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID REFERENCES account(id),
|
||||||
|
typ TEXT NOT NULL,
|
||||||
|
betreff TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
|
||||||
|
-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
|
||||||
|
-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
|
||||||
|
-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
|
||||||
|
-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
|
||||||
|
-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
|
||||||
|
CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
|
||||||
|
CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Plattformweiter Standard für die einzige aktuell existierende
|
||||||
|
-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
|
||||||
|
-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
|
||||||
|
INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
|
||||||
|
NULL,
|
||||||
|
'passwort_zuruecksetzen',
|
||||||
|
'Deklarix — Passwort zurücksetzen',
|
||||||
|
'Hallo,' || E'\n\n' ||
|
||||||
|
'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
|
||||||
|
'{{link}}' || E'\n\n' ||
|
||||||
|
'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
|
||||||
|
-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
|
||||||
|
-- account_id-Zeile anlegen/ändern.
|
||||||
|
CREATE POLICY tenant_isolation ON email_vorlage FOR ALL USING (
|
||||||
|
account_id IS NULL
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
OR current_setting('app.is_betreiber', true) = 'true'
|
||||||
|
) WITH CHECK (
|
||||||
|
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
|
||||||
|
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
|
||||||
|
);
|
||||||
|
ALTER TABLE email_vorlage ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE account DROP COLUMN strasse;
|
||||||
|
ALTER TABLE account DROP COLUMN plz;
|
||||||
|
ALTER TABLE account DROP COLUMN ort;
|
||||||
|
ALTER TABLE account DROP COLUMN land;
|
||||||
|
ALTER TABLE account DROP COLUMN ust_id;
|
||||||
|
ALTER TABLE account DROP COLUMN rechnungsemail;
|
||||||
20
internal/store/migrations/0023_account_firmendaten.up.sql
Normal file
20
internal/store/migrations/0023_account_firmendaten.up.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Adress- und Abrechnungsdaten der Firma — bisher trug account nur den
|
||||||
|
-- Namen. NOT NULL DEFAULT '' statt einer harten NOT-NULL-Pflicht ohne
|
||||||
|
-- Default: bestehende Accounts (vor dieser Migration angelegt) haben
|
||||||
|
-- diese Daten schlicht noch nicht, das darf die Migration nicht
|
||||||
|
-- blockieren. Die Anwendungsschicht erzwingt Pflichtfelder nur für NEU
|
||||||
|
-- angelegte Firmen (Registrierung, Betreiber-Firmenanlage); bestehende
|
||||||
|
-- Firmen werden nicht rückwirkend gezwungen, sie können es über die
|
||||||
|
-- neue Firmendaten-Seite (Ebene 4) nachtragen.
|
||||||
|
ALTER TABLE account ADD COLUMN strasse TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE account ADD COLUMN plz TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE account ADD COLUMN ort TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE account ADD COLUMN land TEXT NOT NULL DEFAULT '';
|
||||||
|
-- Umsatzsteuer-ID ist bewusst optional (NOT NULL DEFAULT '', keine
|
||||||
|
-- Pflicht auch bei Neuanlage) — Kleinunternehmer nach §19 UStG haben
|
||||||
|
-- keine.
|
||||||
|
ALTER TABLE account ADD COLUMN ust_id TEXT NOT NULL DEFAULT '';
|
||||||
|
-- Rechnungsemail kann von der E-Mail des ersten (admin-)Logins
|
||||||
|
-- abweichen (z. B. eine buchhaltung@-Adresse) — eigenes Feld statt
|
||||||
|
-- Wiederverwendung der Login-E-Mail.
|
||||||
|
ALTER TABLE account ADD COLUMN rechnungsemail TEXT NOT NULL DEFAULT '';
|
||||||
77
internal/store/password_reset.go
Normal file
77
internal/store/password_reset.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Passwort-Zurücksetzen-Tokens (Migration 0019). Ein Token ist einmal
|
||||||
|
// verwendbar (used_at) und läuft ab (expires_at) — GetValidPasswordResetToken
|
||||||
|
// liefert ErrNotFound für "nicht gefunden", "abgelaufen" und "schon
|
||||||
|
// verwendet" gleichermaßen, damit ein Angreifer über die Fehlermeldung
|
||||||
|
// nichts über den Zustand eines geratenen Tokens lernt.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordResetTokenDuration ist die Gültigkeitsdauer eines frisch
|
||||||
|
// erzeugten Zurücksetzen-Links.
|
||||||
|
const PasswordResetTokenDuration = 1 * time.Hour
|
||||||
|
|
||||||
|
type PasswordResetToken struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
UsedAt *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePasswordResetToken legt einen neuen Zurücksetzen-Token für
|
||||||
|
// einen Nutzer an. token muss bereits kryptographisch zufällig erzeugt
|
||||||
|
// sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst.
|
||||||
|
func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) {
|
||||||
|
var t PasswordResetToken
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO password_reset_token (user_id, token, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, user_id, token, expires_at, used_at, created_at
|
||||||
|
`, userID, token, time.Now().Add(PasswordResetTokenDuration)).Scan(
|
||||||
|
&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordResetToken{}, fmt.Errorf("store: create password reset token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValidPasswordResetToken liest einen Token nur, wenn er existiert,
|
||||||
|
// noch nicht abgelaufen und noch nicht verwendet ist.
|
||||||
|
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
|
||||||
|
var t PasswordResetToken
|
||||||
|
err := s.db(ctx).QueryRow(ctx, `
|
||||||
|
SELECT id, user_id, token, expires_at, used_at, created_at
|
||||||
|
FROM password_reset_token
|
||||||
|
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
|
`, token).Scan(&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return PasswordResetToken{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PasswordResetToken{}, fmt.Errorf("store: get valid password reset token: %w", err)
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe
|
||||||
|
// Link kein zweites Mal ein Passwort setzen kann.
|
||||||
|
func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: mark password reset token used: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
58
internal/store/password_reset_test.go
Normal file
58
internal/store/password_reset_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordResetTokenLifecycle(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
|
||||||
|
tok, err := s.CreatePasswordResetToken(ctx, userID, "test-token-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePasswordResetToken: %v", err)
|
||||||
|
}
|
||||||
|
if tok.UserID != userID || tok.Token != "test-token-123" {
|
||||||
|
t.Fatalf("PasswordResetToken = %+v, unerwartete Werte", tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetValidPasswordResetToken(ctx, "test-token-123")
|
||||||
|
if err != nil || got.ID != tok.ID {
|
||||||
|
t.Fatalf("GetValidPasswordResetToken = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.MarkPasswordResetTokenUsed(ctx, tok.ID); err != nil {
|
||||||
|
t.Fatalf("MarkPasswordResetTokenUsed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein verbrauchter Token gilt nicht mehr als gültig.
|
||||||
|
if _, err := s.GetValidPasswordResetToken(ctx, "test-token-123"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Verbrauch = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein unbekannter Token ist von Anfang an ErrNotFound.
|
||||||
|
if _, err := s.GetValidPasswordResetToken(ctx, "existiert-nicht"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err für unbekannten Token = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetUserPassword(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
|
||||||
|
if err := s.SetUserPassword(ctx, userID, "neuer-hash"); err != nil {
|
||||||
|
t.Fatalf("SetUserPassword: %v", err)
|
||||||
|
}
|
||||||
|
got, err := s.GetUser(ctx, userID)
|
||||||
|
if err != nil || got.PasswordHash != "neuer-hash" {
|
||||||
|
t.Fatalf("GetUser nach SetUserPassword = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/store/registereintrag.go
Normal file
106
internal/store/registereintrag.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registereintrag wird automatisch bei jeder Genehmigung erzeugt
|
||||||
|
// (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). Append-only
|
||||||
|
// — siehe Migration. Bewusst denormalisiert: eigene Textspalten statt
|
||||||
|
// Joins über bewertung/entscheidung/werkzeug, damit ein einmal
|
||||||
|
// erzeugter Eintrag nicht von einem späteren Katalog- oder Abteilungs-
|
||||||
|
// Wandel abhängt.
|
||||||
|
type Registereintrag struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
AntragID string
|
||||||
|
EntscheidungID string
|
||||||
|
Zweck string
|
||||||
|
Abteilung string
|
||||||
|
Werkzeug string
|
||||||
|
Datenklasse string
|
||||||
|
Einstufung string
|
||||||
|
Auflagen []string
|
||||||
|
Verantwortlicher string
|
||||||
|
EntschiedenAm time.Time
|
||||||
|
GueltigBis *time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const registereintragColumns = `id, account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||||
|
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis, created_at`
|
||||||
|
|
||||||
|
func scanRegistereintrag(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Registereintrag, error) {
|
||||||
|
var e Registereintrag
|
||||||
|
err := row.Scan(
|
||||||
|
&e.ID, &e.AccountID, &e.AntragID, &e.EntscheidungID, &e.Zweck, &e.Abteilung, &e.Werkzeug,
|
||||||
|
&e.Datenklasse, &e.Einstufung, &e.Auflagen, &e.Verantwortlicher, &e.EntschiedenAm, &e.GueltigBis, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistereintragInput bündelt die Felder eines neuen Registereintrags.
|
||||||
|
type RegistereintragInput struct {
|
||||||
|
AccountID string
|
||||||
|
AntragID string
|
||||||
|
EntscheidungID string
|
||||||
|
Zweck string
|
||||||
|
Abteilung string
|
||||||
|
Werkzeug string
|
||||||
|
Datenklasse string
|
||||||
|
Einstufung string
|
||||||
|
Auflagen []string
|
||||||
|
Verantwortlicher string
|
||||||
|
EntschiedenAm time.Time
|
||||||
|
GueltigBis *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistereintrag speichert einen Registereintrag.
|
||||||
|
func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInput) (Registereintrag, error) {
|
||||||
|
if in.Auflagen == nil {
|
||||||
|
in.Auflagen = []string{}
|
||||||
|
}
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO registereintrag (
|
||||||
|
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||||
|
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING `+registereintragColumns,
|
||||||
|
in.AccountID, in.AntragID, in.EntscheidungID, in.Zweck, in.Abteilung, in.Werkzeug,
|
||||||
|
in.Datenklasse, in.Einstufung, in.Auflagen, in.Verantwortlicher, in.EntschiedenAm, in.GueltigBis,
|
||||||
|
)
|
||||||
|
e, err := scanRegistereintrag(row)
|
||||||
|
if err != nil {
|
||||||
|
return Registereintrag{}, fmt.Errorf("store: create registereintrag: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
||||||
|
// Mandanten, neueste zuerst.
|
||||||
|
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Registereintrag
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanRegistereintrag(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan registereintrag: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
100
internal/store/registereintrag_test.go
Normal file
100
internal/store/registereintrag_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testRegistereintragInput(accountID, antragID, entscheidungID string) store.RegistereintragInput {
|
||||||
|
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||||
|
return store.RegistereintragInput{
|
||||||
|
AccountID: accountID, AntragID: antragID, EntscheidungID: entscheidungID,
|
||||||
|
Zweck: "Angebotstexte generieren", Abteilung: "Vertrieb", Werkzeug: "ChatGPT Enterprise",
|
||||||
|
Datenklasse: "personenbezogen", Einstufung: "minimal",
|
||||||
|
Auflagen: []string{"AVV erforderlich", "kein Training auf Eingaben"},
|
||||||
|
Verantwortlicher: "verantwortlicher@example.com", EntschiedenAm: time.Now().Truncate(time.Millisecond),
|
||||||
|
GueltigBis: &gueltigBis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEntscheidungID(t *testing.T, s *store.Store, antragID, bewertungID, userID string) string {
|
||||||
|
t.Helper()
|
||||||
|
e, err := s.CreateEntscheidung(context.Background(), store.EntscheidungInput{
|
||||||
|
AntragID: antragID, BewertungID: bewertungID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEntscheidung: %v", err)
|
||||||
|
}
|
||||||
|
return e.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistereintragCreateAndList(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||||
|
|
||||||
|
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||||
|
}
|
||||||
|
if e.Werkzeug != "ChatGPT Enterprise" || len(e.Auflagen) != 2 {
|
||||||
|
t.Fatalf("CreateRegistereintrag = %+v, unerwartete Werte", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListRegistereintraegeForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRegistereintraegeForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].ID != e.ID {
|
||||||
|
t.Fatalf("ListRegistereintraegeForAccount = %+v, want exactly one entry", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistereintragIsolatesTenants(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accA := testAccountID(t, s)
|
||||||
|
userA := testUserID(t, s, accA)
|
||||||
|
antragA, bewertungA := testAntragMitBewertung(t, s, accA, userA)
|
||||||
|
entscheidungA := testEntscheidungID(t, s, antragA.ID, bewertungA.ID, userA)
|
||||||
|
if _, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accA, antragA.ID, entscheidungA)); err != nil {
|
||||||
|
t.Fatalf("CreateRegistereintrag (A): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accB := testAccountID(t, s)
|
||||||
|
list, err := s.ListRegistereintraegeForAccount(ctx, accB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRegistereintraegeForAccount (B): %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("expected no registereintraege for a different tenant, got %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistereintragIsAppendOnly(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
userID := testUserID(t, s, accID)
|
||||||
|
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||||
|
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||||
|
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE registereintrag SET werkzeug = 'geaendert' WHERE id = $1`, e.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on registereintrag to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
_, err = s.Pool.Exec(ctx, `DELETE FROM registereintrag WHERE id = $1`, e.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected DELETE on registereintrag to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,38 +12,70 @@ import (
|
|||||||
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||||
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||||
// eine Session wird immer über ihren Token nachgeschlagen.
|
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||||
|
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
|
||||||
|
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
|
||||||
|
// statt durch den regulären Login des Nutzers selbst.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Token string
|
Token string
|
||||||
UserID string
|
UserID string
|
||||||
ExpiresAt time.Time
|
ImpersonatedByUserID *string
|
||||||
CreatedAt time.Time
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
|
||||||
|
|
||||||
|
func scanSession(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Session, error) {
|
||||||
|
var sess Session
|
||||||
|
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||||
|
return sess, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||||
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||||
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||||
var sess Session
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO session (token, user_id, expires_at)
|
INSERT INTO session (token, user_id, expires_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING token, user_id, expires_at, created_at
|
RETURNING `+sessionColumns,
|
||||||
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
token, userID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Session{}, fmt.Errorf("store: create session: %w", err)
|
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||||
}
|
}
|
||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
|
||||||
|
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
|
||||||
|
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
|
||||||
|
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
|
||||||
|
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
|
||||||
|
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING `+sessionColumns,
|
||||||
|
token, userID, impersonatedByUserID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: create impersonated session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||||
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||||
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||||
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||||
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||||
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
var sess Session
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
sess, err := scanSession(row)
|
||||||
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
|
||||||
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Session{}, ErrNotFound
|
return Session{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -55,7 +87,7 @@ func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
|||||||
|
|
||||||
// DeleteSession beendet eine Sitzung (Logout).
|
// DeleteSession beendet eine Sitzung (Logout).
|
||||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||||
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
if _, err := s.db(ctx).Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
||||||
return fmt.Errorf("store: delete session: %w", err)
|
return fmt.Errorf("store: delete session: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -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(), store.AccountInput{Name: "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,79 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
104
internal/store/tenant_scope.go
Normal file
104
internal/store/tenant_scope.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// Row-Level-Security-Unterstützung. Isolation zwischen Mandanten lief
|
||||||
|
// bisher ausschließlich anwendungsseitig (Handler vergleichen AccountID,
|
||||||
|
// siehe CLAUDE.md) — das ist die einzige Stelle, die einen vergessenen
|
||||||
|
// WHERE-account_id-Filter in einer neuen Store-Methode nicht auffängt.
|
||||||
|
// WithTenantScope öffnet für die Dauer eines Requests eine Transaktion
|
||||||
|
// und setzt zwei Postgres-Sitzungsvariablen (SET LOCAL, per set_config
|
||||||
|
// mit Parameterbindung statt String-Interpolation — SQL-Injection-frei
|
||||||
|
// und automatisch auf die Transaktion begrenzt, kein manuelles Zurück-
|
||||||
|
// setzen nötig):
|
||||||
|
//
|
||||||
|
// - app.account_id — der Mandant, für den dieser Request angemeldet ist
|
||||||
|
// - app.is_betreiber — "true" für Ebene-5-Zugriff (sieht alle Mandanten)
|
||||||
|
//
|
||||||
|
// Migration 0021 aktiviert FORCE ROW LEVEL SECURITY auf den Tabellen mit
|
||||||
|
// echten Mandanten-Geschäftsdaten und legt Policies an, die genau diese
|
||||||
|
// beiden Variablen auswerten. Bewusst NICHT auf account/app_user/
|
||||||
|
// session/password_reset_token (siehe Migration 0021 für die Begründung
|
||||||
|
// — diese vier brauchen unmandantierte Lookups, z. B. Login per E-Mail).
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pgxIface wird sowohl von *pgxpool.Pool als auch von pgx.Tx erfüllt —
|
||||||
|
// db(ctx) kann so transparent zwischen "kein Tenant-Kontext" (Pool,
|
||||||
|
// z. B. in Store-Tests ohne WithTenantScope) und "innerhalb eines
|
||||||
|
// Requests" (Tx mit gesetzten Sitzungsvariablen) wählen, ohne dass jede
|
||||||
|
// einzelne Store-Methode das selbst unterscheiden müsste.
|
||||||
|
type pgxIface interface {
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantTxKey struct{}
|
||||||
|
|
||||||
|
// db liefert die für ctx passende Ausführungsschnittstelle: die aktive
|
||||||
|
// Transaktion, falls WithTenantScope sie gesetzt hat, sonst den Pool
|
||||||
|
// direkt (z. B. für Store-Tests, die ohne Tenant-Kontext laufen — diese
|
||||||
|
// Tabellen sind dann nicht durch RLS geschützt, was für White-Box-Tests
|
||||||
|
// der Store-Logik selbst unkritisch ist, siehe dedizierte RLS-Tests in
|
||||||
|
// tenant_scope_test.go für den tatsächlichen Isolationsnachweis).
|
||||||
|
func (s *Store) db(ctx context.Context) pgxIface {
|
||||||
|
if tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx); ok {
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
return s.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTenantScope führt fn in einer Transaktion aus, die die Sitzungs-
|
||||||
|
// variablen für RLS setzt. accountID kann leer sein (z. B. während der
|
||||||
|
// Registrierung, bevor der neue Account existiert) — SetTenantScope
|
||||||
|
// erlaubt, die Variable mitten in derselben Transaktion nachträglich zu
|
||||||
|
// setzen, sobald die ID bekannt ist.
|
||||||
|
func (s *Store) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
|
||||||
|
tx, err := s.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: begin tenant scope: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op nach erfolgreichem Commit
|
||||||
|
|
||||||
|
scopedCtx := context.WithValue(ctx, tenantTxKey{}, tx)
|
||||||
|
if err := setTenantSessionVars(scopedCtx, tx, accountID, isBetreiber); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fn(scopedCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("store: commit tenant scope: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTenantScope aktualisiert die Sitzungsvariablen innerhalb einer
|
||||||
|
// bereits laufenden WithTenantScope-Transaktion — nötig, wenn eine neue
|
||||||
|
// Firma erst mitten im Request entsteht (die account_id ist vorher
|
||||||
|
// nicht bekannt, siehe handleRegister/handleBetreiberAccountCreate).
|
||||||
|
// Ruft man es außerhalb von WithTenantScope auf, ist es ein No-op ohne
|
||||||
|
// Effekt (kein Tx im Context) — daher immer den Rückgabewert prüfen,
|
||||||
|
// falls das je außerhalb eines Handlers genutzt wird.
|
||||||
|
func (s *Store) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
|
||||||
|
tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("store: SetTenantScope außerhalb von WithTenantScope aufgerufen")
|
||||||
|
}
|
||||||
|
return setTenantSessionVars(ctx, tx, accountID, isBetreiber)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTenantSessionVars(ctx context.Context, tx pgx.Tx, accountID string, isBetreiber bool) error {
|
||||||
|
betreiberFlag := "false"
|
||||||
|
if isBetreiber {
|
||||||
|
betreiberFlag = "true"
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `SELECT set_config('app.account_id', $1, true), set_config('app.is_betreiber', $2, true)`, accountID, betreiberFlag); err != nil {
|
||||||
|
return fmt.Errorf("store: set tenant scope: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
152
internal/store/tenant_scope_test.go
Normal file
152
internal/store/tenant_scope_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// Beweis, dass Row-Level-Security tatsächlich greift — nicht nur, dass
|
||||||
|
// die Store-Methoden mit einer Transaktion statt dem Pool funktionieren
|
||||||
|
// (das würde auch mit einer Superuser-Verbindung "bestehen", ohne dass
|
||||||
|
// RLS irgendetwas tut, siehe CLAUDE.md/Migration 0021: Superuser
|
||||||
|
// umgehen RLS-Policies immer). Diese Tests laufen deshalb NICHT gegen
|
||||||
|
// DATABASE_URL (Superuser, für Migrationen), sondern gegen
|
||||||
|
// DATABASE_URL_APP — die eingeschränkte Rolle "deklarix_app", für die
|
||||||
|
// die Policies tatsächlich wirken. Ohne DATABASE_URL_APP werden sie
|
||||||
|
// übersprungen (die Rolle existiert erst nach manuellem Passwort-Setup,
|
||||||
|
// siehe Migration 0021 und CLAUDE.md).
|
||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openRestrictedTestStore öffnet eine zweite Verbindung über
|
||||||
|
// DATABASE_URL_APP (die eingeschränkte Rolle) — Migrationen und
|
||||||
|
// Fixture-Aufbau laufen weiterhin über die normale, privilegierte
|
||||||
|
// openTestStore-Verbindung.
|
||||||
|
func openRestrictedTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("DATABASE_URL_APP")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("DATABASE_URL_APP nicht gesetzt, überspringe RLS-Test (siehe Migration 0021)")
|
||||||
|
}
|
||||||
|
s, err := store.Open(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open (restricted): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRLSAbteilungIsoliertZwischenMandanten(t *testing.T) {
|
||||||
|
privileged := openTestStore(t)
|
||||||
|
restricted := openRestrictedTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accA := testAccountID(t, privileged)
|
||||||
|
accB := testAccountID(t, privileged)
|
||||||
|
abtA, err := privileged.CreateAbteilung(ctx, accA, "Abteilung-A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAbteilung A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := privileged.CreateAbteilung(ctx, accB, "Abteilung-B"); err != nil {
|
||||||
|
t.Fatalf("CreateAbteilung B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit Tenant-Kontext A: nur die eigene Abteilung ist sichtbar, auch
|
||||||
|
// wenn wir explizit nach IDs von B fragen würden — hier geprüft über
|
||||||
|
// die Listen-Methode, die auf accA gefiltert nach RLS zusätzlich
|
||||||
|
// noch mal (redundant) accountID=accA übergibt; der Beweis liegt in
|
||||||
|
// TestRLSVerweigertFremdenAccountAuchBeiFalscherAccountID unten, wo
|
||||||
|
// die Anwendungsschicht bewusst "falsch" fragt.
|
||||||
|
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||||
|
liste, err := restricted.ListAbteilungenForAccount(scoped, accA)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(liste) != 1 || liste[0].Name != "Abteilung-A" {
|
||||||
|
t.Fatalf("liste = %+v, want genau [Abteilung-A]", liste)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithTenantScope A: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der eigentliche RLS-Beweis: im Tenant-Kontext A nach ListAbteilungenForAccount(B)
|
||||||
|
// fragen — ein Programmierfehler, der die AccountID nicht prüft, wäre
|
||||||
|
// ohne RLS ein echtes Datenleck. Mit RLS liefert die DB trotzdem 0 Zeilen,
|
||||||
|
// weil die Sitzungsvariable (Kontext A) nicht zu den B-Zeilen passt.
|
||||||
|
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||||
|
liste, err := restricted.ListAbteilungenForAccount(scoped, accB)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(liste) != 0 {
|
||||||
|
t.Fatalf("RLS-LECK: Kontext A sieht %d Zeilen von Account B, want 0", len(liste))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithTenantScope A->B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAbteilung(abtA.ID) im Kontext B muss ErrNotFound liefern — RLS
|
||||||
|
// versteckt die fremde Zeile, unabhängig davon, ob die Anwendung die
|
||||||
|
// AccountID selbst vergleicht.
|
||||||
|
err = restricted.WithTenantScope(ctx, accB, false, func(scoped context.Context) error {
|
||||||
|
_, err := restricted.GetAbteilung(scoped, abtA.ID)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("RLS-LECK: GetAbteilung(A) im Kontext B err=%v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithTenantScope B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Betreiber-Kontext sieht beide.
|
||||||
|
err = restricted.WithTenantScope(ctx, "", true, func(scoped context.Context) error {
|
||||||
|
listeA, err := restricted.ListAbteilungenForAccount(scoped, accA)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listeB, err := restricted.ListAbteilungenForAccount(scoped, accB)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(listeA) != 1 || len(listeB) != 1 {
|
||||||
|
t.Fatalf("Betreiber-Kontext: listeA=%+v listeB=%+v, want je 1", listeA, listeB)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithTenantScope Betreiber: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne jeden Tenant-Kontext (direkter Pool-Zugriff, keine Transaktion,
|
||||||
|
// keine Sitzungsvariable gesetzt): fail closed, 0 Zeilen — nicht "alle".
|
||||||
|
liste, err := restricted.ListAbteilungenForAccount(ctx, accA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAbteilungenForAccount ohne Kontext: %v", err)
|
||||||
|
}
|
||||||
|
if len(liste) != 0 {
|
||||||
|
t.Fatalf("RLS-LECK: ohne Tenant-Kontext sichtbar: %+v, want 0 Zeilen (fail closed)", liste)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRLSVerhindertInsertFuerFremdenAccount(t *testing.T) {
|
||||||
|
privileged := openTestStore(t)
|
||||||
|
restricted := openRestrictedTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accA := testAccountID(t, privileged)
|
||||||
|
accB := testAccountID(t, privileged)
|
||||||
|
|
||||||
|
err := restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
|
||||||
|
_, err := restricted.CreateAbteilung(scoped, accB, "Boesartig-Eingeschleust")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("RLS-LECK: INSERT für fremde account_id im Kontext A wurde nicht abgelehnt")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,28 +15,41 @@ import (
|
|||||||
// Login: falsche E-Mail vs. Datenbankfehler).
|
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||||
var ErrNotFound = errors.New("store: nicht gefunden")
|
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||||
|
|
||||||
// User ist ein Login innerhalb eines Account (Mandanten).
|
// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden
|
||||||
|
// nicht gelöscht (app_user wird von antrag/session/entscheidung/
|
||||||
|
// audit_log per Foreign Key referenziert — ein Hard-Delete würde die
|
||||||
|
// Historie zerstören), sondern über Active deaktiviert.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
AccountID string
|
AccountID string
|
||||||
Email string
|
Email string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
Role string
|
Role string
|
||||||
|
Active bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userColumns = `id, account_id, email, password_hash, role, active, created_at`
|
||||||
|
|
||||||
|
func scanUser(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (User, error) {
|
||||||
|
var u User
|
||||||
|
err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
||||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||||
// speichert nur, es hasht nicht selbst.
|
// speichert nur, es hasht nicht selbst.
|
||||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||||
var u User
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
err := s.Pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, account_id, email, password_hash, role, created_at
|
RETURNING `+userColumns,
|
||||||
`, accountID, email, passwordHash, role).Scan(
|
accountID, email, passwordHash, role,
|
||||||
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
|
|
||||||
)
|
)
|
||||||
|
u, err := scanUser(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return User{}, fmt.Errorf("store: create user: %w", err)
|
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||||
}
|
}
|
||||||
@@ -46,11 +59,8 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
|
|||||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
var u User
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
u, err := scanUser(row)
|
||||||
SELECT id, account_id, email, password_hash, role, created_at
|
|
||||||
FROM app_user WHERE email = $1
|
|
||||||
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return User{}, ErrNotFound
|
return User{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -62,11 +72,8 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
|
|||||||
|
|
||||||
// GetUser liest einen Nutzer anhand seiner ID.
|
// GetUser liest einen Nutzer anhand seiner ID.
|
||||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||||
var u User
|
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||||
err := s.Pool.QueryRow(ctx, `
|
u, err := scanUser(row)
|
||||||
SELECT id, account_id, email, password_hash, role, created_at
|
|
||||||
FROM app_user WHERE id = $1
|
|
||||||
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return User{}, ErrNotFound
|
return User{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -75,3 +82,57 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
|||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
||||||
|
// Admin-Bereich (Account-Detailansicht).
|
||||||
|
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
||||||
|
rows, err := s.db(ctx).Query(ctx, `
|
||||||
|
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
u, err := scanUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserPassword ersetzt den Passwort-Hash eines Nutzers (Passwort-
|
||||||
|
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
|
||||||
|
// CreateUser.
|
||||||
|
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: set user password: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||||
|
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||||
|
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||||
|
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
||||||
|
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||||
|
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: set user active: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
322
internal/store/werkzeug.go
Normal file
322
internal/store/werkzeug.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
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
|
||||||
|
// Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
|
||||||
|
// der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
|
||||||
|
// wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
|
||||||
|
// USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
|
||||||
|
// "gemischt" verschleierte, welche Länder konkret beteiligt sind.
|
||||||
|
Verarbeitungslaender []string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
AVVURL string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
|
||||||
|
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
|
||||||
|
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
|
||||||
|
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
|
||||||
|
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
|
||||||
|
// übrigen Booleans hier.
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
|
||||||
|
Zertifizierungen []string
|
||||||
|
Subprozessoren []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 15 Feldern lesbarer als eine positionale
|
||||||
|
// Parameterliste.
|
||||||
|
type WerkzeugInput struct {
|
||||||
|
AccountID *string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungslaender []string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
AVVURL string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AufbewahrungTage *int
|
||||||
|
Zertifizierungen []string
|
||||||
|
Subprozessoren []string
|
||||||
|
GeeigneteZwecke []string
|
||||||
|
Einschraenkungen []string
|
||||||
|
LetztePruefung time.Time
|
||||||
|
Quelle string
|
||||||
|
}
|
||||||
|
|
||||||
|
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
|
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.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
|
||||||
|
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
|
||||||
|
&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.Verarbeitungslaender == nil {
|
||||||
|
in.Verarbeitungslaender = []string{}
|
||||||
|
}
|
||||||
|
if in.Zertifizierungen == nil {
|
||||||
|
in.Zertifizierungen = []string{}
|
||||||
|
}
|
||||||
|
if in.GeeigneteZwecke == nil {
|
||||||
|
in.GeeigneteZwecke = []string{}
|
||||||
|
}
|
||||||
|
if in.Einschraenkungen == nil {
|
||||||
|
in.Einschraenkungen = []string{}
|
||||||
|
}
|
||||||
|
if in.Subprozessoren == nil {
|
||||||
|
in.Subprozessoren = []string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||||
|
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||||
|
normalizeWerkzeugSlices(&in)
|
||||||
|
row := s.db(ctx).QueryRow(ctx, `
|
||||||
|
INSERT INTO werkzeug (
|
||||||
|
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
|
||||||
|
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
|
||||||
|
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
|
RETURNING `+werkzeugColumns,
|
||||||
|
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||||
|
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||||
|
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.db(ctx).QueryRow(ctx, `
|
||||||
|
UPDATE werkzeug SET
|
||||||
|
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||||
|
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
|
||||||
|
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
|
||||||
|
letzte_pruefung = $15, quelle = $16, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+werkzeugColumns,
|
||||||
|
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
|
||||||
|
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
|
||||||
|
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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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
|
||||||
|
}
|
||||||
240
internal/store/werkzeug_test.go
Normal file
240
internal/store/werkzeug_test.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func intPtr(v int) *int { return &v }
|
||||||
|
|
||||||
|
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||||
|
return store.WerkzeugInput{
|
||||||
|
AccountID: accountID,
|
||||||
|
Name: name,
|
||||||
|
Anbieter: "Beispiel-Anbieter GmbH",
|
||||||
|
Verarbeitungslaender: []string{"Deutschland"},
|
||||||
|
AVVVerfuegbar: true,
|
||||||
|
AVVURL: "https://beispiel.example/avv",
|
||||||
|
TrainingOptOut: true,
|
||||||
|
TrainingStandard: true,
|
||||||
|
AufbewahrungTage: intPtr(30),
|
||||||
|
Zertifizierungen: []string{"ISO 27001"},
|
||||||
|
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." {
|
||||||
|
t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren)
|
||||||
|
}
|
||||||
|
if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 {
|
||||||
|
t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
|
||||||
|
unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput {
|
||||||
|
in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe")
|
||||||
|
in.AufbewahrungTage = nil
|
||||||
|
return in
|
||||||
|
}())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err)
|
||||||
|
}
|
||||||
|
if unbekannt.AufbewahrungTage != nil {
|
||||||
|
t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = intPtr(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 == nil || *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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user