Compare commits
61
Commits
11cf9083fa
...
v0.33.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
34b1d8d2a6 | ||
|
|
8f638df387 | ||
|
|
5156fe62da | ||
|
|
2a16dc2200 | ||
|
|
f5ad08cebd | ||
|
|
72005fc326 | ||
|
|
e0218337e7 | ||
|
|
31caa8f66a | ||
|
|
db373e51ce | ||
|
|
917b51299b | ||
|
|
d54636f57a | ||
|
|
15bf277bee | ||
|
|
da91c3e2c1 | ||
|
|
bec34b5988 | ||
|
|
8b08b39725 | ||
|
|
e929e5bf26 | ||
|
|
667343fa81 | ||
|
|
e9e386df85 | ||
|
|
bfe52c73eb |
@@ -1,5 +1,6 @@
|
|||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
dossiers/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
databaseURL := os.Getenv("DATABASE_URL")
|
||||||
|
if databaseURL == "" {
|
||||||
|
log.Fatal("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Migrate(databaseURL); err != nil {
|
||||||
|
log.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(context.Background(), databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
rulesDir := os.Getenv("RULES_DIR")
|
||||||
|
if rulesDir == "" {
|
||||||
|
rulesDir = "rules"
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err)
|
||||||
|
}
|
||||||
|
einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err)
|
||||||
|
}
|
||||||
|
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(db, web.Regelwerk{
|
||||||
|
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("web server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
addr := ":" + port
|
||||||
|
|
||||||
|
log.Printf("Deklarix server starting on %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, server); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
port := os.Getenv("PORT")
|
|
||||||
if port == "" {
|
|
||||||
port = "8080"
|
|
||||||
}
|
|
||||||
addr := ":" + port
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
fmt.Fprintf(w, `{"ok":true}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
log.Printf("Deklarix server starting on %s", addr)
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,24 @@
|
|||||||
module github.com/netcell-it/deklarix
|
module github.com/netcell-it/deklarix
|
||||||
|
|
||||||
go 1.26.6
|
go 1.26.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
|
||||||
|
github.com/go-pdf/fpdf v0.9.0
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
golang.org/x/crypto v0.55.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||||
|
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||||
|
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
|
||||||
|
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||||
|
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
|
||||||
|
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||||
|
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Package auth enthält die reine Logik für Anmeldung: Passwort-Hashing,
|
||||||
|
// Session-Token-Erzeugung. Es fasst keine Datenbank an — das macht
|
||||||
|
// internal/store (account, app_user, session), damit Persistenz an
|
||||||
|
// einer Stelle im Projekt gebündelt bleibt.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinPasswordLength ist die einzige Passwort-Regel, die wir prüfen —
|
||||||
|
// keine erzwungenen Sonderzeichen/Ziffern-Vorgaben, die Nutzer nur zu
|
||||||
|
// vorhersehbaren Mustern verleiten.
|
||||||
|
const MinPasswordLength = 8
|
||||||
|
|
||||||
|
// SessionDuration ist die Gültigkeitsdauer einer neu erstellten Sitzung.
|
||||||
|
const SessionDuration = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// HashPassword hasht ein Klartext-Passwort für die Speicherung. Lehnt
|
||||||
|
// zu kurze Passwörter ab, statt sie stillschweigend zu hashen.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
if len(password) < MinPasswordLength {
|
||||||
|
return "", fmt.Errorf("auth: passwort muss mindestens %d Zeichen haben", MinPasswordLength)
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("auth: passwort hashen: %w", err)
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword prüft ein Klartext-Passwort gegen einen gespeicherten
|
||||||
|
// Hash. Ein Fehler bedeutet: falsches Passwort oder ungültiger Hash —
|
||||||
|
// beides führt zu "Anmeldung abgelehnt", nie zu einem Unterschied, den
|
||||||
|
// ein Angreifer für User-Enumeration nutzen könnte.
|
||||||
|
func VerifyPassword(hash, password string) error {
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||||
|
return fmt.Errorf("auth: passwort ungültig: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionToken erzeugt ein kryptographisch zufälliges Session-Token
|
||||||
|
// (32 Byte, hex-kodiert).
|
||||||
|
func NewSessionToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("auth: token generieren: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashPasswordRejectsShortPassword(t *testing.T) {
|
||||||
|
if _, err := auth.HashPassword("kurz"); err == nil {
|
||||||
|
t.Fatal("expected error for a too-short password, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashAndVerifyRoundTrip(t *testing.T) {
|
||||||
|
hash, err := auth.HashPassword("ein-langes-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
if err := auth.VerifyPassword(hash, "ein-langes-passwort"); err != nil {
|
||||||
|
t.Fatalf("VerifyPassword: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyPasswordRejectsWrongPassword(t *testing.T) {
|
||||||
|
hash, err := auth.HashPassword("ein-langes-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
if err := auth.VerifyPassword(hash, "falsches-passwort"); err == nil {
|
||||||
|
t.Fatal("expected error for a wrong password, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSessionTokenIsRandomAndCorrectLength(t *testing.T) {
|
||||||
|
a, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
b, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("expected two distinct tokens, got the same value twice")
|
||||||
|
}
|
||||||
|
if len(a) != 64 {
|
||||||
|
t.Fatalf("token length = %d, want 64 (hex of 32 random bytes)", len(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// istJaOderUnsicher prüft eine B-Frage. "Unsicher zählt wie Ja" ist eine
|
||||||
|
// 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 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. loeschfrist_max_tage hat noch keine fachlich
|
||||||
|
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb
|
||||||
|
// ebenfalls noch nicht hart gefiltert.
|
||||||
|
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
||||||
|
switch anforderungID {
|
||||||
|
case "avv_erforderlich":
|
||||||
|
return w.AVVVerfuegbar
|
||||||
|
case "eu_verarbeitung":
|
||||||
|
return alleLaenderInEUEWR(w.Verarbeitungslaender)
|
||||||
|
case "kein_training_auf_eingabe":
|
||||||
|
return w.TrainingStandard
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||||
|
for _, k := range kandidaten {
|
||||||
|
var fehlend []string
|
||||||
|
for _, reqID := range anforderungIDs {
|
||||||
|
if !ErfuelltAnforderung(reqID, k.Eigenschaften) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||||
|
}
|
||||||
|
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}) {
|
||||||
|
t.Error("erwartet: erfuellt mit AVV")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}) {
|
||||||
|
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}) {
|
||||||
|
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"}}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
|
||||||
|
}
|
||||||
|
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||||
|
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"})
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadDatenklasse lädt und validiert die Datenklasse-Ableitungstabelle.
|
||||||
|
func LoadDatenklasse(fsys fs.FS, path string) (DatenklasseRegelwerk, error) {
|
||||||
|
var rw DatenklasseRegelwerk
|
||||||
|
data, err := fs.ReadFile(fsys, path)
|
||||||
|
if err != nil {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse lesen: %w", err)
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
|
||||||
|
}
|
||||||
|
if rw.Version < 1 {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: version fehlt oder ungültig")
|
||||||
|
}
|
||||||
|
if len(rw.Stufen) == 0 {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: keine stufen definiert")
|
||||||
|
}
|
||||||
|
seenIDs := map[string]bool{}
|
||||||
|
seenRaenge := map[int]string{}
|
||||||
|
for _, st := range rw.Stufen {
|
||||||
|
if st.ID == "" {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe ohne id")
|
||||||
|
}
|
||||||
|
if seenIDs[st.ID] {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: doppelte stufe-id %q", st.ID)
|
||||||
|
}
|
||||||
|
seenIDs[st.ID] = true
|
||||||
|
if other, ok := seenRaenge[st.Rang]; ok {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufen %q und %q teilen sich rang %d — \"höchste Stufe gewinnt\" braucht eindeutige Ränge", st.ID, other, st.Rang)
|
||||||
|
}
|
||||||
|
seenRaenge[st.Rang] = st.ID
|
||||||
|
if len(st.Ausloeser) == 0 {
|
||||||
|
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe %q hat keine ausloeser", st.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Abteilung ist Stammdatum für den Fragebogen (Feld A.abteilung).
|
||||||
|
type Abteilung struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||||
|
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||||
|
var a Abteilung
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||||
|
RETURNING id, account_id, name, created_at
|
||||||
|
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Abteilung{}, fmt.Errorf("store: create abteilung: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
||||||
|
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||||
|
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, account_id, name, created_at FROM abteilung
|
||||||
|
WHERE account_id = $1 ORDER BY name
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Abteilung
|
||||||
|
for rows.Next() {
|
||||||
|
var a Abteilung
|
||||||
|
if err := rows.Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan abteilung: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||||
|
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||||
|
var a Abteilung
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
||||||
|
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Abteilung{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Abteilung{}, fmt.Errorf("store: get abteilung: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
||||||
|
// angelegt).
|
||||||
|
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete abteilung: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
|
||||||
|
// 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.
|
||||||
|
type Account struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
EinladungToken string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountColumns = `id, name, einladung_token, created_at`
|
||||||
|
|
||||||
|
func scanAccount(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Account, error) {
|
||||||
|
var a Account
|
||||||
|
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, name string) (Account, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO account (name) VALUES ($1)
|
||||||
|
RETURNING `+accountColumns,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
|
||||||
|
// Betreiber-gestützten Anlage korrigieren).
|
||||||
|
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
|
||||||
|
row := s.Pool.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||||
|
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||||
|
a, err := scanAccount(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: get account: %w", err)
|
||||||
|
}
|
||||||
|
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.Pool.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.Pool.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.Pool.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
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, "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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Antrag ist ein Vorhaben aus dem Fragebogen (Abschnitt A) mit den
|
||||||
|
// vollständigen Antworten aus B/C/D als JSON — der Fragebogen ist
|
||||||
|
// adaptiv (Folgefragen hängen von vorherigen Antworten ab), ein starres
|
||||||
|
// Spaltenschema könnte das nicht abbilden. Antworten ist bewusst
|
||||||
|
// []byte (rohes JSON), nicht ein aufgelöster Go-Typ — die Struktur der
|
||||||
|
// Antworten ist Sache von internal/rules (Stufe 2), store speichert nur.
|
||||||
|
type Antrag struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
ErstellerUserID string
|
||||||
|
AbteilungID *string
|
||||||
|
Titel string
|
||||||
|
Beschreibung string
|
||||||
|
Ergebnis string
|
||||||
|
Haeufigkeit string
|
||||||
|
Antworten []byte
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const antragColumns = `id, account_id, ersteller_user_id, abteilung_id, titel, beschreibung,
|
||||||
|
ergebnis, haeufigkeit, antworten, status, created_at, updated_at`
|
||||||
|
|
||||||
|
func scanAntrag(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Antrag, error) {
|
||||||
|
var a Antrag
|
||||||
|
err := row.Scan(
|
||||||
|
&a.ID, &a.AccountID, &a.ErstellerUserID, &a.AbteilungID, &a.Titel, &a.Beschreibung,
|
||||||
|
&a.Ergebnis, &a.Haeufigkeit, &a.Antworten, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
||||||
|
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING `+antragColumns,
|
||||||
|
accountID, erstellerUserID, abteilungID, titel,
|
||||||
|
)
|
||||||
|
a, err := scanAntrag(row)
|
||||||
|
if err != nil {
|
||||||
|
return Antrag{}, fmt.Errorf("store: create antrag: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
||||||
|
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||||
|
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
||||||
|
a, err := scanAntrag(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Antrag{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Antrag{}, fmt.Errorf("store: get antrag: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAntragFelder aktualisiert die Fragebogen-Felder eines Antrags —
|
||||||
|
// solange er im Entwurf ist, kann der Fragebogen adaptiv weiter
|
||||||
|
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
||||||
|
// Status hier nicht).
|
||||||
|
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE antrag SET
|
||||||
|
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING `+antragColumns,
|
||||||
|
id, titel, beschreibung, ergebnis, haeufigkeit, antworten,
|
||||||
|
)
|
||||||
|
a, err := scanAntrag(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Antrag{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Antrag{}, fmt.Errorf("store: update antrag felder: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAntragStatus setzt den Status eines Antrags (entwurf -> eingereicht
|
||||||
|
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
||||||
|
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||||
|
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: set antrag status: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAntraegeForUser liefert alle Anträge, die ein bestimmter Nutzer
|
||||||
|
// gestellt hat, neueste zuerst — für Ebene 2 ("eigene Anträge
|
||||||
|
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
||||||
|
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||||
|
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
||||||
|
`, erstellerUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Antrag
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAntrag(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||||
|
// zuerst.
|
||||||
|
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list antraege for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Antrag
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAntrag(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list antraege for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Pool.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.Pool.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Beispiel Agentur GmbH" {
|
||||||
|
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 TestUpdateAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := s.CreateAccount(ctx, "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, "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, "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) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byEmail, err := s.GetUserByEmail(ctx, "team@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if byEmail.ID != user.ID {
|
||||||
|
t.Fatalf("GetUserByEmail returned a different user than CreateUser")
|
||||||
|
}
|
||||||
|
|
||||||
|
byID, err := s.GetUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||||
|
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) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.GetUserByEmail(context.Background(), "nichtvorhanden@example.com")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEmailIsUnique(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "mitarbeiter"); err != nil {
|
||||||
|
t.Fatalf("CreateUser (1): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "mitarbeiter"); err == nil {
|
||||||
|
t.Fatal("expected error for a duplicate email, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||||
|
sess, err := s.CreateSession(ctx, "test-token-123", user.ID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetSession(ctx, sess.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
if 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 {
|
||||||
|
t.Fatalf("DeleteSession: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetSession(ctx, sess.Token); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSessionNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.GetSession(context.Background(), "unbekanntes-token")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Pool.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.Pool.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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Pool.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.Pool.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.Pool.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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// Migrate wendet alle ausstehenden Migrationen aus migrations/ an.
|
||||||
|
// Die Migrationen sind im Binary eingebettet (go:embed), damit Deklarix
|
||||||
|
// weiterhin als einzelnes Binary lauffähig bleibt.
|
||||||
|
func Migrate(databaseURL string) error {
|
||||||
|
db, err := sql.Open("pgx", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: open db for migration: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: migration driver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := iofs.New(migrationsFS, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: migration source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance("iofs", source, "pgx5", driver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: migrate init: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
return fmt.Errorf("store: migrate up: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
DROP TRIGGER IF EXISTS evidence_package_append_only ON evidence_package;
|
||||||
|
DROP TRIGGER IF EXISTS finding_append_only ON finding;
|
||||||
|
DROP TRIGGER IF EXISTS extraction_append_only ON extraction;
|
||||||
|
DROP FUNCTION IF EXISTS forbid_update_delete();
|
||||||
|
|
||||||
|
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 asset;
|
||||||
|
DROP TABLE IF EXISTS submission;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stufe 1: striktes JSON aus der Claude-Extraktion. Append-only, siehe Trigger unten.
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stufe 2: Ergebnis pro Regel. Append-only, Korrektur = neue Zeile.
|
||||||
|
-- supersedes zeigt auf die alte Zeile, die diese Zeile ersetzt (gesetzt
|
||||||
|
-- beim INSERT der Korrektur, nie per UPDATE — der Trigger würde das
|
||||||
|
-- verbieten). "Aktuell gültig" = Zeilen, auf die kein supersedes zeigt,
|
||||||
|
-- siehe Index unten für den Anti-Join.
|
||||||
|
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')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
supersedes UUID REFERENCES finding (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||||
|
|
||||||
|
-- Dossier + Beweiskette (Hash, RFC-3161-Token). Append-only.
|
||||||
|
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 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()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
|
||||||
|
CREATE FUNCTION forbid_update_delete() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'append-only table: % on % is not allowed', TG_OP, TG_TABLE_NAME;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER extraction_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON extraction
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
|
|
||||||
|
CREATE TRIGGER finding_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON finding
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
|
|
||||||
|
CREATE TRIGGER evidence_package_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON evidence_package
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE finding DROP COLUMN title;
|
||||||
|
ALTER TABLE finding DROP COLUMN fix;
|
||||||
|
ALTER TABLE finding DROP COLUMN sources;
|
||||||
|
ALTER TABLE finding ADD COLUMN message TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE finding ALTER COLUMN message DROP DEFAULT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- finding.message war ein einzelnes Freitextfeld. Fürs Dossier brauchen
|
||||||
|
-- wir Titel, Korrekturvorschlag und Fundstellen aber strukturiert und
|
||||||
|
-- getrennt (siehe internal/dossier), sonst müsste die Anzeige Text
|
||||||
|
-- wieder auseinanderparsen, den wir selbst zusammengefügt haben.
|
||||||
|
ALTER TABLE finding DROP COLUMN message;
|
||||||
|
ALTER TABLE finding ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE finding ADD COLUMN fix TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE finding ADD COLUMN sources TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE finding ALTER COLUMN title DROP DEFAULT;
|
||||||
|
ALTER TABLE finding ALTER COLUMN fix DROP DEFAULT;
|
||||||
|
ALTER TABLE finding ALTER COLUMN sources DROP DEFAULT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE submission DROP COLUMN account_id;
|
||||||
|
DROP TABLE session;
|
||||||
|
DROP TABLE app_user;
|
||||||
|
DROP TABLE account;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Mandantentrennung: ein account ist der Mandant (Creator, Agentur,
|
||||||
|
-- Marke oder Kanzlei als eigene Organisation), app_user ist ein Login
|
||||||
|
-- innerhalb eines accounts. session ist bewusst eine eigene Tabelle
|
||||||
|
-- statt signierter, zustandsloser Tokens — ein Logout muss eine
|
||||||
|
-- Sitzung wirklich beenden können, nicht nur clientseitig "vergessen"
|
||||||
|
-- werden.
|
||||||
|
CREATE TABLE account (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "app_user" statt "user", da user ein reserviertes Wort in SQL ist.
|
||||||
|
CREATE TABLE app_user (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Jeder Beitrag gehört ab jetzt zu genau einem Mandanten. NOT NULL ohne
|
||||||
|
-- Default ist hier sicher, weil noch keine Submission-Zeilen aus der
|
||||||
|
-- Zeit vor Auth existieren (kein Kunde live).
|
||||||
|
ALTER TABLE submission ADD COLUMN account_id UUID NOT NULL REFERENCES account (id);
|
||||||
@@ -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'));
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE asset DROP COLUMN purpose;
|
||||||
@@ -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'));
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE bewertung;
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE entscheidung;
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE registereintrag;
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE app_user DROP COLUMN active;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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.Pool.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.Pool.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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||||
|
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||||
|
// 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 {
|
||||||
|
Token string
|
||||||
|
UserID string
|
||||||
|
ImpersonatedByUserID *string
|
||||||
|
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
|
||||||
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||||
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO session (token, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING `+sessionColumns,
|
||||||
|
token, userID, expiresAt,
|
||||||
|
)
|
||||||
|
sess, err := scanSession(row)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||||
|
}
|
||||||
|
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.Pool.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
|
||||||
|
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||||
|
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||||
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||||
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||||
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
|
||||||
|
sess, err := scanSession(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Session{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: get session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession beendet eine Sitzung (Logout).
|
||||||
|
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 {
|
||||||
|
return fmt.Errorf("store: delete session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store hält den Verbindungspool zur Postgres-Datenbank.
|
||||||
|
type Store struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open baut den Verbindungspool auf und prüft ihn mit einem Ping.
|
||||||
|
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open pool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("store: ping: %w", err)
|
||||||
|
}
|
||||||
|
return &Store{Pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close gibt den Verbindungspool frei.
|
||||||
|
func (s *Store) Close() {
|
||||||
|
s.Pool.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Diese Tests brauchen eine laufende Postgres-Instanz und werden ohne
|
||||||
|
// DATABASE_URL übersprungen, statt eine Verbindung vorzutäuschen.
|
||||||
|
func testDatabaseURL(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("DATABASE_URL nicht gesetzt, überspringe Store-Integrationstest")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
url := testDatabaseURL(t)
|
||||||
|
if err := store.Migrate(url); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
s, err := store.Open(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
||||||
|
func testAccountID(t *testing.T, s *store.Store) string {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
return acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateAndOpen(t *testing.T) {
|
||||||
|
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()
|
||||||
|
|
||||||
|
var tableCount int
|
||||||
|
err = s.Pool.QueryRow(context.Background(), `
|
||||||
|
SELECT count(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = ANY($1)
|
||||||
|
`, []string{"account", "app_user", "session", "audit_log", "abteilung", "werkzeug", "werkzeug_sperre", "antrag"}).Scan(&tableCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query tables: %v", err)
|
||||||
|
}
|
||||||
|
if tableCount != 8 {
|
||||||
|
t.Fatalf("expected 8 tables, got %d", tableCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
|
||||||
|
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
|
||||||
|
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
|
||||||
|
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||||
|
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
Role string
|
||||||
|
Active bool
|
||||||
|
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.
|
||||||
|
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||||
|
// speichert nur, es hasht nicht selbst.
|
||||||
|
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING `+userColumns,
|
||||||
|
accountID, email, passwordHash, role,
|
||||||
|
)
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||||
|
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||||
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("store: get user by email: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser liest einen Nutzer anhand seiner ID.
|
||||||
|
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("store: get user: %w", err)
|
||||||
|
}
|
||||||
|
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.Pool.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Pool.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
|
||||||
|
}
|
||||||
@@ -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.Pool.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.Pool.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.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
||||||
|
w, err := scanWerkzeug(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Werkzeug{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Werkzeug{}, fmt.Errorf("store: get werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||||
|
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWerkzeugeForAccount liefert den für einen Mandanten sichtbaren
|
||||||
|
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
||||||
|
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||||
|
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT `+werkzeugColumns+` FROM werkzeug w
|
||||||
|
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
||||||
|
)) OR w.account_id = $1
|
||||||
|
ORDER BY w.name
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Werkzeug
|
||||||
|
for rows.Next() {
|
||||||
|
w, err := scanWerkzeug(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListZentraleWerkzeuge liefert den vollständigen zentralen Katalog
|
||||||
|
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
||||||
|
// Mandanten-Sperrungen.
|
||||||
|
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Werkzeug
|
||||||
|
for rows.Next() {
|
||||||
|
w, err := scanWerkzeug(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentKatalogVersion liefert eine reproduzierbare Kennung des
|
||||||
|
// aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird
|
||||||
|
// in jeder Bewertung/Entscheidung eingefroren, damit im Audit
|
||||||
|
// nachvollziehbar bleibt, mit welchem Katalogstand ein Vorschlag
|
||||||
|
// erzeugt wurde.
|
||||||
|
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||||
|
var count int
|
||||||
|
var lastUpdate time.Time
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
||||||
|
`).Scan(&count, &lastUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("store: current katalog version: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d-%d", count, lastUpdate.Unix()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugSperre ist die Sperrung eines (auch zentralen) Katalogeintrags
|
||||||
|
// durch einen einzelnen Mandanten — der zentrale Katalog selbst bleibt
|
||||||
|
// dabei unverändert.
|
||||||
|
type WerkzeugSperre struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
WerkzeugID string
|
||||||
|
Grund string
|
||||||
|
GesperrtAm time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||||
|
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||||
|
var sp WerkzeugSperre
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
||||||
|
`, accountID, werkzeugID, grund).Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm)
|
||||||
|
if err != nil {
|
||||||
|
return WerkzeugSperre{}, fmt.Errorf("store: create werkzeug sperre: %w", err)
|
||||||
|
}
|
||||||
|
return sp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
||||||
|
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
||||||
|
`, accountID, werkzeugID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete werkzeug sperre: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||||
|
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
||||||
|
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []WerkzeugSperre
|
||||||
|
for rows.Next() {
|
||||||
|
var sp WerkzeugSperre
|
||||||
|
if err := rows.Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan werkzeug sperre: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, sp)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminCanCreateAndDeleteAbteilung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Vertrieb"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/abteilungen")
|
||||||
|
if !strings.Contains(list.Body.String(), "Vertrieb") {
|
||||||
|
t.Errorf("expected Vertrieb in the Abteilungsliste, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var abtID string
|
||||||
|
for accID, abts := range fs.abteilungen {
|
||||||
|
_ = accID
|
||||||
|
for _, a := range abts {
|
||||||
|
if a.Name == "Vertrieb" {
|
||||||
|
abtID = a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if abtID == "" {
|
||||||
|
t.Fatal("expected Vertrieb to exist in fakeStore")
|
||||||
|
}
|
||||||
|
|
||||||
|
delResp := postForm(t, s, adminCookie, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||||
|
if delResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("delete status = %d, body: %s", delResp.Code, delResp.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := fs.GetAbteilung(context.Background(), abtID); err == nil {
|
||||||
|
t.Error("expected the Abteilung to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeueAntragFormZeigtAngelegteAbteilung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Marketing"}})
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/antraege/neu")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Marketing") {
|
||||||
|
t.Errorf("expected the newly created Abteilung in the Fragebogen, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessAbteilungsverwaltung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/abteilungen")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotDeleteForeignAbteilung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
postForm(t, s, adminA, "/verwaltung/abteilungen", url.Values{"name": {"Nur A"}})
|
||||||
|
var abtID string
|
||||||
|
for _, abts := range fs.abteilungen {
|
||||||
|
for _, a := range abts {
|
||||||
|
if a.Name == "Nur A" {
|
||||||
|
abtID = a.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminB, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's Abteilung", resp.Code)
|
||||||
|
}
|
||||||
|
if _, err := fs.GetAbteilung(context.Background(), abtID); err != nil {
|
||||||
|
t.Error("expected the foreign Abteilung to still exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
|
||||||
|
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
|
||||||
|
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
|
||||||
|
// und kann entscheiden, genau wie ein verantwortlicher.
|
||||||
|
func TestAdminSiehtPosteingang(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/faelle")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||||
|
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminKannEntscheiden(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if antrag.Status != "entschieden" {
|
||||||
|
t.Errorf("Status = %q, want entschieden", antrag.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
|
||||||
|
resp := getWithCookie(t, s, adminCookie, path)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen und Abteilungen für
|
||||||
|
// GENAU EINEN Mandanten, siehe CLAUDE.md, Fünf Ebenen. Anmeldeverfahren,
|
||||||
|
// Werkzeug-Freigaben/-Sperrungen und Rechnungsdaten sind weiterhin
|
||||||
|
// spätere Ausbaustufen. Es gibt noch keinen Einladungslink/CSV-Import/
|
||||||
|
// SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier direkt ein
|
||||||
|
// Initialpasswort, wie schon bei der Firma-Registrierung.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
||||||
|
// Mandanten vergeben darf — "betreiber" ist Ebene 5 (Plattform) und
|
||||||
|
// kann von keinem Mandanten-Admin vergeben werden.
|
||||||
|
var mandantenRollen = []string{"mitarbeiter", "verantwortlicher", "pruefer", "admin"}
|
||||||
|
|
||||||
|
func istMandantenRolle(role string) bool {
|
||||||
|
for _, r := range mandantenRollen {
|
||||||
|
if r == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type nutzerListItem struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
Active bool
|
||||||
|
IstSelbst bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type nutzerListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Nutzer []nutzerListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
||||||
|
func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
self := currentUser(r)
|
||||||
|
users, err := s.store.ListUsersForAccount(r.Context(), self.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.User
|
||||||
|
for _, u := range users {
|
||||||
|
if matchesQuery(q, u.Email, u.Role) {
|
||||||
|
gefiltert = append(gefiltert, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, u := range paginate(gefiltert, pag) {
|
||||||
|
data.Nutzer = append(data.Nutzer, nutzerListItem{
|
||||||
|
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNutzerDeaktivieren sperrt einen Login des eigenen Mandanten —
|
||||||
|
// nicht löschen, siehe store.User (Fremdschlüssel auf antrag/entscheidung/
|
||||||
|
// audit_log würden das verhindern und die Historie zerstören). Ein Admin
|
||||||
|
// kann sich nicht selbst deaktivieren, sonst könnte ein Mandant sich
|
||||||
|
// versehentlich komplett aussperren.
|
||||||
|
func (s *Server) handleNutzerDeaktivieren(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
self := currentUser(r)
|
||||||
|
if id == self.ID {
|
||||||
|
http.Error(w, "Der eigene Zugang kann nicht deaktiviert werden", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := s.store.GetUser(r.Context(), id)
|
||||||
|
if err != nil || target.AccountID != self.AccountID {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetUserActive(r.Context(), id, false); err != nil {
|
||||||
|
http.Error(w, "Nutzer konnte nicht deaktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleNutzerAktivieren(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
target, err := s.store.GetUser(r.Context(), id)
|
||||||
|
if err != nil || target.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetUserActive(r.Context(), id, true); err != nil {
|
||||||
|
http.Error(w, "Nutzer konnte nicht aktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type nutzerNeuData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Rollen []string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleNutzerNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNutzerCreate legt einen weiteren Login im eigenen Mandanten an.
|
||||||
|
// Es gibt keine Einladungsmail (siehe CLAUDE.md, Onboarding — noch
|
||||||
|
// nicht gebaut) — der Admin setzt das Initialpasswort direkt und gibt
|
||||||
|
// es dem/der neuen Nutzer:in auf einem anderen Weg weiter.
|
||||||
|
func (s *Server) handleNutzerCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
role := r.FormValue("role")
|
||||||
|
if email == "" || password == "" || !istMandantenRolle(role) {
|
||||||
|
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "E-Mail, Passwort und eine gültige Rolle sind Pflicht"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.store.CreateUser(r.Context(), currentUser(r).AccountID, email, passwordHash, role); err != nil {
|
||||||
|
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type abteilungListItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type abteilungListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Abteilungen []abteilungListItem
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAbteilungListe zeigt alle Abteilungen des eigenen Mandanten
|
||||||
|
// mit einem Formular zum Anlegen weiterer — bewusst eine Seite statt
|
||||||
|
// Liste+Formular getrennt, da Abteilungen reine Stammdaten ohne
|
||||||
|
// weitere Felder sind (nur `name`).
|
||||||
|
func (s *Server) handleAbteilungListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := abteilungListeData{Title: "Abteilungen", Nav: navFor(r)}
|
||||||
|
for _, a := range abteilungen {
|
||||||
|
data.Abteilungen = append(data.Abteilungen, abteilungListItem{ID: a.ID, Name: a.Name})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "abteilung-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAbteilungCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAbteilung(r.Context(), currentUser(r).AccountID, name); err != nil {
|
||||||
|
http.Error(w, "Abteilung konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAbteilungDelete entfernt eine Abteilung des eigenen Mandanten.
|
||||||
|
// Fremde Abteilungen (anderer Mandant) sind über die ID nicht löschbar
|
||||||
|
// — GetAbteilung prüft AccountID gegen den angemeldeten Mandanten,
|
||||||
|
// bevor gelöscht wird.
|
||||||
|
func (s *Server) handleAbteilungDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
abt, err := s.store.GetAbteilung(r.Context(), id)
|
||||||
|
if err != nil || abt.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Abteilung nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteAbteilung(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Abteilung konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessNutzerverwaltung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter on /verwaltung/nutzer", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCanCreateUserWithMandantenRolle(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"}, "role": {"verantwortlicher"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != "verantwortlicher" {
|
||||||
|
t.Errorf("Role = %q, want verantwortlicher", u.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/nutzer")
|
||||||
|
if !strings.Contains(list.Body.String(), "neu@example.com") {
|
||||||
|
t.Errorf("expected the new user in the Nutzerliste, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotCreateBetreiberUser(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"versuch@example.com"}, "password": {"ein-langes-passwort"}, "role": {"betreiber"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler) for role betreiber", resp.Code)
|
||||||
|
}
|
||||||
|
if _, err := fs.GetUserByEmail(context.Background(), "versuch@example.com"); err == nil {
|
||||||
|
t.Error("expected no user to be created for an invalid role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCreateUserRejectsDuplicateEmail(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"admin@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler) for duplicate email", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten-
|
||||||
|
// Schlüsseln in rules/datenklasse.yaml (b1..b7) und
|
||||||
|
// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3
|
||||||
|
// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten,
|
||||||
|
// ohne sie erst umbenennen zu müssen.
|
||||||
|
var datenfragen = []struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
}{
|
||||||
|
{"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"},
|
||||||
|
{"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"},
|
||||||
|
{"b3", "Daten von Patienten, Mandanten oder Versicherten"},
|
||||||
|
{"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"},
|
||||||
|
{"b5", "Daten, die uns ein Kunde anvertraut hat"},
|
||||||
|
{"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"},
|
||||||
|
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var c2FolgeLabels = map[string]string{
|
||||||
|
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
|
||||||
|
"kreditwuerdigkeit": "Kreditwürdigkeit",
|
||||||
|
"leistungsbewilligung": "Leistungsbewilligung",
|
||||||
|
"bildung": "Zugang zu Bildung",
|
||||||
|
"gesundheit": "medizinische Beurteilung",
|
||||||
|
}
|
||||||
|
|
||||||
|
var c3ArtLabels = map[string]string{
|
||||||
|
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
|
||||||
|
"social_scoring": "Social Scoring",
|
||||||
|
"biometrische_kategorisierung": "Biometrische Kategorisierung",
|
||||||
|
}
|
||||||
|
|
||||||
|
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
|
||||||
|
// siehe antwortenAnzeige.
|
||||||
|
type antwortZeile struct {
|
||||||
|
Frage string
|
||||||
|
Antwort string
|
||||||
|
}
|
||||||
|
|
||||||
|
func antwortLabel(v string) string {
|
||||||
|
switch v {
|
||||||
|
case "ja":
|
||||||
|
return "Ja"
|
||||||
|
case "nein":
|
||||||
|
return "Nein"
|
||||||
|
case "unsicher":
|
||||||
|
return "Unsicher"
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jaNein(v bool) string {
|
||||||
|
if v {
|
||||||
|
return "Ja"
|
||||||
|
}
|
||||||
|
return "Nein"
|
||||||
|
}
|
||||||
|
|
||||||
|
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
|
||||||
|
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
|
||||||
|
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
|
||||||
|
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
|
||||||
|
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
|
||||||
|
// Antworten, auf denen sie beruht — im Audit ist das nicht
|
||||||
|
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
|
||||||
|
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
|
||||||
|
var out []antwortZeile
|
||||||
|
for _, f := range datenfragen {
|
||||||
|
v, _ := antworten[f.Key].(string)
|
||||||
|
if v == "" {
|
||||||
|
v = "nein"
|
||||||
|
}
|
||||||
|
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
|
||||||
|
}
|
||||||
|
|
||||||
|
c1, _ := antworten["c1"].(bool)
|
||||||
|
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
|
||||||
|
|
||||||
|
c2, _ := antworten["c2"].(bool)
|
||||||
|
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
|
||||||
|
if c2 {
|
||||||
|
if folge, _ := antworten["c2_folge"].(string); folge != "" {
|
||||||
|
if label, ok := c2FolgeLabels[folge]; ok {
|
||||||
|
c2Zeile.Antwort += " — " + label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, c2Zeile)
|
||||||
|
|
||||||
|
c3, _ := antworten["c3"].(bool)
|
||||||
|
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
|
||||||
|
if c3 {
|
||||||
|
if art, _ := antworten["c3_art"].(string); art != "" {
|
||||||
|
if label, ok := c3ArtLabels[art]; ok {
|
||||||
|
c3Zeile.Antwort += " — " + label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, c3Zeile)
|
||||||
|
|
||||||
|
c4, _ := antworten["c4"].(bool)
|
||||||
|
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
|
||||||
|
|
||||||
|
c5, _ := antworten["c5"].(bool)
|
||||||
|
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
|
||||||
|
|
||||||
|
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
|
||||||
|
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
|
||||||
|
}
|
||||||
|
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
|
||||||
|
label := "Privater Zugang"
|
||||||
|
if zugang == "firma" {
|
||||||
|
label = "Firmenkonto"
|
||||||
|
}
|
||||||
|
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
|
||||||
|
}
|
||||||
|
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
|
||||||
|
label := "Privatgerät"
|
||||||
|
if geraet == "firma" {
|
||||||
|
label = "Firmengerät"
|
||||||
|
}
|
||||||
|
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type antragFormData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Abteilungen []store.Abteilung
|
||||||
|
Datenfragen []struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen").
|
||||||
|
// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten —
|
||||||
|
// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als
|
||||||
|
// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive
|
||||||
|
// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet,
|
||||||
|
// ohne zusätzlichen Server-Roundtrip.
|
||||||
|
func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if currentUser(r).Role == "betreiber" {
|
||||||
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert
|
||||||
|
// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln.
|
||||||
|
// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT
|
||||||
|
// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja")
|
||||||
|
// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der
|
||||||
|
// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert
|
||||||
|
// erhalten.
|
||||||
|
func antwortenFromForm(r *http.Request) []byte {
|
||||||
|
m := map[string]any{}
|
||||||
|
for _, f := range datenfragen {
|
||||||
|
v := r.FormValue(f.Key)
|
||||||
|
if v == "" {
|
||||||
|
v = "nein"
|
||||||
|
}
|
||||||
|
m[f.Key] = v
|
||||||
|
}
|
||||||
|
m["c1"] = r.FormValue("c1") == "ja"
|
||||||
|
m["c2"] = r.FormValue("c2") == "ja"
|
||||||
|
m["c2_folge"] = r.FormValue("c2_folge")
|
||||||
|
m["c3"] = r.FormValue("c3") == "ja"
|
||||||
|
m["c3_art"] = r.FormValue("c3_art")
|
||||||
|
m["c4"] = r.FormValue("c4") == "ja"
|
||||||
|
m["c5"] = r.FormValue("c5") == "ja"
|
||||||
|
m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext")
|
||||||
|
m["d_zugang"] = r.FormValue("d_zugang")
|
||||||
|
m["d_geraet"] = r.FormValue("d_geraet")
|
||||||
|
|
||||||
|
data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein
|
||||||
|
// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine
|
||||||
|
// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht
|
||||||
|
// vorgesehen.
|
||||||
|
func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if currentUser(r).Role == "betreiber" {
|
||||||
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
titel := r.FormValue("titel")
|
||||||
|
beschreibung := r.FormValue("beschreibung")
|
||||||
|
ergebnis := r.FormValue("ergebnis")
|
||||||
|
haeufigkeit := r.FormValue("haeufigkeit")
|
||||||
|
if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" {
|
||||||
|
http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var abteilungID *string
|
||||||
|
if v := r.FormValue("abteilung_id"); v != "" {
|
||||||
|
abteilungID = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
user := currentUser(r)
|
||||||
|
|
||||||
|
antworten := antwortenFromForm(r)
|
||||||
|
|
||||||
|
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antworten); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil {
|
||||||
|
http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag
|
||||||
|
// (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei
|
||||||
|
// "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe
|
||||||
|
// CLAUDE.md, Bewertungslogik.
|
||||||
|
func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error {
|
||||||
|
antworten, err := rules.ParseAntworten(antwortenJSON)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten)
|
||||||
|
einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
verboten := rules.IstVerboten(einstufung.ID)
|
||||||
|
|
||||||
|
in := store.BewertungInput{
|
||||||
|
AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung,
|
||||||
|
Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten,
|
||||||
|
KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verboten {
|
||||||
|
anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID)
|
||||||
|
anforderungIDs := make([]string, len(anforderungen))
|
||||||
|
for i, a := range anforderungen {
|
||||||
|
in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||||
|
anforderungIDs[i] = a.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||||
|
for i, w := range werkzeuge {
|
||||||
|
aufbewahrungTage := 0
|
||||||
|
if w.AufbewahrungTage != nil {
|
||||||
|
aufbewahrungTage = *w.AufbewahrungTage
|
||||||
|
}
|
||||||
|
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||||
|
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender,
|
||||||
|
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||||
|
in.ZulaessigeWerkzeuge = zulaessig
|
||||||
|
for _, a := range ausgeschlossen {
|
||||||
|
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||||
|
}
|
||||||
|
|
||||||
|
katalogVersion, err := s.store.CurrentKatalogVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.KatalogVersion = katalogVersion
|
||||||
|
}
|
||||||
|
in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen)
|
||||||
|
|
||||||
|
_, err = s.store.CreateBewertung(ctx, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit
|
||||||
|
// an, falls das Regelwerk eine nennt.
|
||||||
|
func einstufungHerleitungText(e rules.EinstufungErgebnis) string {
|
||||||
|
if e.Quelle == "" {
|
||||||
|
return e.Herleitung
|
||||||
|
}
|
||||||
|
return e.Herleitung + " (" + e.Quelle + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
type antragListItem struct {
|
||||||
|
ID string
|
||||||
|
Titel string
|
||||||
|
Status string
|
||||||
|
Haeufigkeit string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type antragListData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Antraege []antragListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
||||||
|
// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten.
|
||||||
|
func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if currentUser(r).Role == "betreiber" {
|
||||||
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.Antrag
|
||||||
|
for _, a := range list {
|
||||||
|
if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) {
|
||||||
|
gefiltert = append(gefiltert, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, a := range paginate(gefiltert, pag) {
|
||||||
|
data.Antraege = append(data.Antraege, antragListItem{
|
||||||
|
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
||||||
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type anforderungView struct {
|
||||||
|
Beschreibung string
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
|
type werkzeugAusschlussView struct {
|
||||||
|
Name string
|
||||||
|
NichtErfuellt []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type bewertungView struct {
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []anforderungView
|
||||||
|
ZulaessigeWerkzeuge []string // Namen, nicht IDs
|
||||||
|
AusgeschlosseneWerkzeuge []werkzeugAusschlussView
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type antragDetailData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Titel string
|
||||||
|
Beschreibung string
|
||||||
|
Ergebnis string
|
||||||
|
Haeufigkeit string
|
||||||
|
Status string
|
||||||
|
CreatedAt string
|
||||||
|
Antworten []antwortZeile
|
||||||
|
Bewertung *bewertungView
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
|
||||||
|
// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender
|
||||||
|
// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe
|
||||||
|
// Muster wie schon beim alten Produkt für fremde Mandanten.
|
||||||
|
func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil || antrag.ErstellerUserID != currentUser(r).ID {
|
||||||
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var antworten map[string]any
|
||||||
|
if len(antrag.Antworten) > 0 {
|
||||||
|
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := antragDetailData{
|
||||||
|
Title: "Antrag", Nav: navFor(r), Titel: antrag.Titel,
|
||||||
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||||
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
||||||
|
}
|
||||||
|
|
||||||
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err == nil {
|
||||||
|
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toBewertungView löst die in der Bewertung nur als ID gespeicherten
|
||||||
|
// Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung
|
||||||
|
// gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt,
|
||||||
|
// statt den ganzen Antrag unanzeigbar zu machen.
|
||||||
|
func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView {
|
||||||
|
v := &bewertungView{
|
||||||
|
Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung,
|
||||||
|
Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten,
|
||||||
|
RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion,
|
||||||
|
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
}
|
||||||
|
for _, a := range b.Anforderungen {
|
||||||
|
v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||||
|
}
|
||||||
|
for _, id := range b.ZulaessigeWerkzeuge {
|
||||||
|
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
||||||
|
}
|
||||||
|
for _, a := range b.AusgeschlosseneWerkzeuge {
|
||||||
|
v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{
|
||||||
|
Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) werkzeugName(ctx context.Context, id string) string {
|
||||||
|
w, err := s.store.GetWerkzeug(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return w.Name
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fullAntragForm() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"titel": {"Angebotstexte generieren"}, "beschreibung": {"KI schreibt Angebotstexte"},
|
||||||
|
"ergebnis": {"Fertiger Text zur Freigabe"}, "haeufigkeit": {"gelegentlich"},
|
||||||
|
"b1": {"ja"}, "b2": {"nein"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
|
||||||
|
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
|
||||||
|
"c4": {"nein"}, "c5": {"ja"},
|
||||||
|
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragNewFormRendersFragebogen(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/antraege/neu")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
for _, want := range []string{`name="titel"`, `name="b1"`, `name="c2_folge"`, `name="d_zugang"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("expected %q in the Fragebogen, got: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
loc := resp.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(loc, "/antraege/") {
|
||||||
|
t.Fatalf("Location = %q, want a redirect to the new antrag", loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if antrag.Status != "eingereicht" {
|
||||||
|
t.Fatalf("Status = %q, want eingereicht", antrag.Status)
|
||||||
|
}
|
||||||
|
var antworten map[string]any
|
||||||
|
if err := json.Unmarshal(antrag.Antworten, &antworten); err != nil {
|
||||||
|
t.Fatalf("Antworten unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if antworten["b1"] != "ja" {
|
||||||
|
t.Errorf("b1 = %v, want ja", antworten["b1"])
|
||||||
|
}
|
||||||
|
if antworten["c5"] != true {
|
||||||
|
t.Errorf("c5 = %v, want true", antworten["c5"])
|
||||||
|
}
|
||||||
|
|
||||||
|
detailResp := getWithCookie(t, s, cookie, loc)
|
||||||
|
if detailResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||||
|
}
|
||||||
|
body := detailResp.Body.String()
|
||||||
|
if !strings.Contains(body, "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected the antrag title on the detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||||
|
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||||
|
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragCreateRejectsMissingFields(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", url.Values{"titel": {"Nur Titel"}})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for missing required fields", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragListShowsOnlyOwnAntraege(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||||
|
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||||
|
|
||||||
|
if resp := postForm(t, s, cookieA, "/antraege", fullAntragForm()); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("create (A) status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
listA := getWithCookie(t, s, cookieA, "/antraege")
|
||||||
|
if !strings.Contains(listA.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected Mandant A to see their own antrag, got: %s", listA.Body.String())
|
||||||
|
}
|
||||||
|
listB := getWithCookie(t, s, cookieB, "/antraege")
|
||||||
|
if strings.Contains(listB.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected Mandant B to see no antrag, got: %s", listB.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntragDetailRejectsForeignUser(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||||
|
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||||
|
|
||||||
|
createResp := postForm(t, s, cookieA, "/antraege", fullAntragForm())
|
||||||
|
antragURL := createResp.Header().Get("Location")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookieB, antragURL)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign user's antrag", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullAntragForm liefert b1=ja (Datenklasse personenbezogen) und
|
||||||
|
// c1..c3=nein (keine der verboten/hochrisiko/transparenzpflicht-
|
||||||
|
// Varianten trifft zu) — die Einstufung fällt damit auf die
|
||||||
|
// Auffangregel "minimal" zurück (siehe rules/kivo_einstufung.yaml).
|
||||||
|
func TestAntragCreateComputesBewertung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if b.Datenklasse != "personenbezogen" {
|
||||||
|
t.Errorf("Datenklasse = %q, want personenbezogen", b.Datenklasse)
|
||||||
|
}
|
||||||
|
if b.Einstufung != "minimal" {
|
||||||
|
t.Errorf("Einstufung = %q, want minimal", b.Einstufung)
|
||||||
|
}
|
||||||
|
if b.Verboten {
|
||||||
|
t.Error("Verboten = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||||
|
if !strings.Contains(detail.Body.String(), "Das System entscheidet nicht") {
|
||||||
|
t.Errorf("expected the mandatory disclaimer on the detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntragCreateVerbotenSkipsWerkzeugSearch prüft die K.-o.-Prüfung:
|
||||||
|
// c3=ja mit c3_art=social_scoring löst "verboten" aus (Art. 5 KI-VO) —
|
||||||
|
// dann darf keine Werkzeugsuche stattfinden (siehe CLAUDE.md,
|
||||||
|
// Bewertungslogik).
|
||||||
|
func TestAntragCreateVerbotenSkipsWerkzeugSearch(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
form := fullAntragForm()
|
||||||
|
form.Set("c3", "ja")
|
||||||
|
form.Set("c3_art", "social_scoring")
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", form)
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if !b.Verboten || b.Einstufung != "verboten" {
|
||||||
|
t.Fatalf("Bewertung = %+v, want Verboten=true, Einstufung=verboten", b)
|
||||||
|
}
|
||||||
|
if len(b.ZulaessigeWerkzeuge) != 0 || len(b.AusgeschlosseneWerkzeuge) != 0 {
|
||||||
|
t.Errorf("expected no werkzeug search for a verboten Bewertung, got %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntragCreateFiltersWerkzeugeByAnforderungen prüft den harten
|
||||||
|
// Filter: personenbezogen verlangt avv_erforderlich — ein Werkzeug ohne
|
||||||
|
// AVV wird ausgeschlossen und im Ergebnis mit Begründung genannt statt
|
||||||
|
// stillschweigend wegzufallen, ein Werkzeug mit AVV bleibt zulässig.
|
||||||
|
func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{
|
||||||
|
ID: "werkzeug-ok", Name: "ChatGPT Enterprise",
|
||||||
|
AVVVerfuegbar: true, TrainingStandard: true,
|
||||||
|
}
|
||||||
|
fs.werkzeuge["werkzeug-ohne-avv"] = store.Werkzeug{
|
||||||
|
ID: "werkzeug-ohne-avv", Name: "Freies KI-Tool",
|
||||||
|
AVVVerfuegbar: false, TrainingStandard: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "werkzeug-ok" {
|
||||||
|
t.Errorf("ZulaessigeWerkzeuge = %v, want [werkzeug-ok]", b.ZulaessigeWerkzeuge)
|
||||||
|
}
|
||||||
|
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-ohne-avv" {
|
||||||
|
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want [werkzeug-ohne-avv]", b.AusgeschlosseneWerkzeuge)
|
||||||
|
}
|
||||||
|
if got := b.AusgeschlosseneWerkzeuge[0].NichtErfuellt; len(got) != 1 || got[0] != "avv_erforderlich" {
|
||||||
|
t.Errorf("NichtErfuellt = %v, want [avv_erforderlich]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||||
|
body := detail.Body.String()
|
||||||
|
if !strings.Contains(body, "ChatGPT Enterprise") || !strings.Contains(body, "Freies KI-Tool") {
|
||||||
|
t.Errorf("expected both werkzeuge (by name) on the detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
for _, path := range []string{"/antraege", "/antraege/neu"} {
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, path)
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("GET %s status = %d, want 303 redirect for a betreiber", path, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authPageData struct {
|
||||||
|
Title string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
||||||
|
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||||
|
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
|
||||||
|
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
|
||||||
|
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
|
||||||
|
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
|
||||||
|
// Mitarbeiter über dieses Formular — die kommen über den
|
||||||
|
// Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
|
||||||
|
// Import/Mandantenlink (weiterhin nicht gebaut).
|
||||||
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountName := r.FormValue("account_name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if accountName == "" || email == "" || password == "" {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||||
|
if err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||||
|
if err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.startSession(w, r, user.ID); err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "ungültiges Formular"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
user, err := s.store.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
// Absichtlich dieselbe Meldung wie bei falschem Passwort — sonst
|
||||||
|
// verrät die Fehlermeldung, ob eine E-Mail-Adresse existiert.
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Erst NACH erfolgreicher Passwortprüfung unterscheiden — sonst würde
|
||||||
|
// diese Meldung einem Angreifer ohne gültiges Passwort verraten, dass
|
||||||
|
// die E-Mail-Adresse zu einem (deaktivierten) Konto gehört.
|
||||||
|
if !user.Active {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Dieses Konto ist deaktiviert"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.startSession(w, r, user.ID); err != nil {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der
|
||||||
|
// Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch
|
||||||
|
// getrennt vom Mandantenbereich).
|
||||||
|
if user.Role == "betreiber" {
|
||||||
|
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
|
_ = s.store.DeleteSession(r.Context(), cookie.Value)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSession erzeugt eine neue Sitzung und setzt das Session-Cookie.
|
||||||
|
// Secure ist an genau dann, wenn die Anfrage selbst über TLS kam — ein
|
||||||
|
// fest verdrahtetes Secure=true würde lokale Entwicklung ohne TLS
|
||||||
|
// (http://localhost) brechen, da Browser ein Secure-Cookie über Klartext-
|
||||||
|
// HTTP schlicht nicht speichern.
|
||||||
|
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||||
|
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSessionCookie setzt das Session-Cookie — gemeinsam genutzt von
|
||||||
|
// startSession (regulärer Login) und handleBetreiberLoginAls
|
||||||
|
// (Support-Login).
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Expires: expiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannFirmaAnlegen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{
|
||||||
|
"account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fs.GetUserByEmail(context.Background(), "admin@neue-firma.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if user.Role != "admin" {
|
||||||
|
t.Errorf("Role = %q, want admin", user.Role)
|
||||||
|
}
|
||||||
|
acc, err := fs.GetAccount(context.Background(), user.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if acc.Name != "Neue Firma GmbH" {
|
||||||
|
t.Errorf("Name = %q, want Neue Firma GmbH", acc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der neue Login funktioniert tatsächlich.
|
||||||
|
loginResp := postForm(t, s, nil, "/login", url.Values{
|
||||||
|
"email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if loginResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberFirmaAnlegenRejectsMissingFields(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{"account_name": {"Nur Name"}})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Pflicht") {
|
||||||
|
t.Errorf("expected a validation error, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberKannFirmaUmbenennen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Alter Name")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID+"/umbenennen", url.Values{"name": {"Neuer Name"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := fs.GetAccount(context.Background(), acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Neuer Name" {
|
||||||
|
t.Errorf("Name = %q, want Neuer Name", got.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+acc.ID)
|
||||||
|
if !strings.Contains(detail.Body.String(), "Neuer Name") {
|
||||||
|
t.Errorf("expected the new name on the detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotCreateFirma(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/betreiber/accounts/neu")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type betreiberDashboardData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
AccountCount int
|
||||||
|
WerkzeugCount int
|
||||||
|
RecentAuditCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberDashboard zeigt eine kurze Übersicht als Einstieg in
|
||||||
|
// den Plattform-Bereich (Ebene 5 — nur Betreiber, technisch getrennt
|
||||||
|
// vom Mandantenbereich).
|
||||||
|
func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
accounts, err := s.store.ListAccounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
werkzeuge, err := s.store.ListZentraleWerkzeuge(ctx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := betreiberDashboardData{
|
||||||
|
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts),
|
||||||
|
WerkzeugCount: len(werkzeuge), RecentAuditCount: len(auditLog),
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAccountListItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAccountListData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Accounts []betreiberAccountListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
||||||
|
func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accounts, err := s.store.ListAccounts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.Account
|
||||||
|
for _, a := range accounts {
|
||||||
|
if matchesQuery(q, a.Name) {
|
||||||
|
gefiltert = append(gefiltert, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, a := range paginate(gefiltert, pag) {
|
||||||
|
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
||||||
|
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-accounts", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAccountNeuData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberAccountNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountCreate legt eine Firma samt erstem admin-Login
|
||||||
|
// an — dasselbe Ergebnis wie die öffentliche Firma-Registrierung
|
||||||
|
// (handleRegister), nur vom Betreiber ausgelöst statt von der Firma
|
||||||
|
// selbst (z. B. bei vertriebsunterstütztem Onboarding oder zum Anlegen
|
||||||
|
// eines Testkontos). Der Betreiber setzt das Initialpasswort direkt,
|
||||||
|
// wie überall sonst im Produkt ohne E-Mail-Infrastruktur.
|
||||||
|
func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountName := r.FormValue("account_name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if accountName == "" || email == "" || password == "" {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||||
|
if err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin"); err != nil {
|
||||||
|
data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/betreiber/accounts/"+acc.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountRename benennt eine Firma um (z. B. Tippfehler
|
||||||
|
// korrigieren) — kein sonstiges Bearbeiten, Löschen bleibt bewusst
|
||||||
|
// ungebaut (siehe CLAUDE.md, Offene Punkte: hängt an der noch nicht
|
||||||
|
// getroffenen Abrechnungs-/Freischaltungs-Architektur, und ein
|
||||||
|
// Hard-Delete würde gegen die Fremdschlüssel aus antrag/app_user/
|
||||||
|
// audit_log laufen).
|
||||||
|
func (s *Server) handleBetreiberAccountRename(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if _, err := s.store.UpdateAccount(r.Context(), id, name); err != nil {
|
||||||
|
http.Error(w, "Firma konnte nicht umbenannt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/accounts/"+id, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberUserView struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAccountDetailData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
AccountID string
|
||||||
|
Name string
|
||||||
|
Users []betreiberUserView
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberAccountDetail zeigt einen Mandanten mit seinen Logins.
|
||||||
|
func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := betreiberAccountDetailData{
|
||||||
|
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name,
|
||||||
|
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
data.Users = append(data.Users, betreiberUserView{ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAuditEntryView struct {
|
||||||
|
CreatedAt string
|
||||||
|
Action string
|
||||||
|
TargetType string
|
||||||
|
TargetID string
|
||||||
|
Details string
|
||||||
|
}
|
||||||
|
|
||||||
|
type betreiberAuditLogData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Entries []betreiberAuditEntryView
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditLogFetchLimit begrenzt, wie viele der neuesten Einträge aus der
|
||||||
|
// DB geholt werden, bevor im Go-Code gefiltert/paginiert wird — echte
|
||||||
|
// DB-seitige Offset-Paginierung wäre für ein potenziell sehr groß
|
||||||
|
// werdendes Audit-Log der sauberere Ansatz, ist hier aber (noch) nicht
|
||||||
|
// umgesetzt (siehe CLAUDE.md, Offene Punkte).
|
||||||
|
const auditLogFetchLimit = 1000
|
||||||
|
|
||||||
|
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
||||||
|
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := s.store.ListAuditLog(r.Context(), auditLogFetchLimit)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.AuditEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
if matchesQuery(q, e.Action, e.TargetType, e.Details) {
|
||||||
|
gefiltert = append(gefiltert, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, e := range paginate(gefiltert, pag) {
|
||||||
|
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
||||||
|
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||||
|
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-audit-log", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberLoginAls meldet den Betreiber als einen bestimmten
|
||||||
|
// Kunden-Nutzer an (Support-Login) — ohne dessen Passwort zu kennen.
|
||||||
|
// Nur für aktive Nutzer möglich (ein deaktivierter Login soll auch per
|
||||||
|
// Support-Zugriff nicht nutzbar sein). Die neue Sitzung ersetzt die
|
||||||
|
// eigene Betreiber-Sitzung (kein Sitzungs-Stack) — der Betreiber meldet
|
||||||
|
// sich danach mit den eigenen Zugangsdaten wieder an, das ist für die
|
||||||
|
// seltene Nutzung dieser Funktion einfacher als ein "Zurück zum
|
||||||
|
// Betreiber"-Mechanismus. Jede Nutzung wird append-only im Audit-Log
|
||||||
|
// festgehalten (Actor = Betreiber, Target = der Kunden-Nutzer) und die
|
||||||
|
// entstehende Sitzung trägt den Betreiber als ImpersonatedByUserID —
|
||||||
|
// die Nav zeigt während der gesamten Sitzung einen auffälligen Hinweis
|
||||||
|
// (siehe currentImpersonator, layout.html), damit nie unklar ist, dass
|
||||||
|
// gerade im Kontext eines fremden Kontos gehandelt wird.
|
||||||
|
func (s *Server) handleBetreiberLoginAls(w http.ResponseWriter, r *http.Request) {
|
||||||
|
betreiber := currentUser(r)
|
||||||
|
ziel, err := s.store.GetUser(r.Context(), r.PathValue("userID"))
|
||||||
|
if err != nil || ziel.AccountID != r.PathValue("accountID") {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ziel.Active {
|
||||||
|
http.Error(w, "Nutzer ist deaktiviert", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||||
|
if _, err := s.store.CreateImpersonatedSession(r.Context(), token, ziel.ID, betreiber.ID, expiresAt); err != nil {
|
||||||
|
http.Error(w, "Sitzung konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), betreiber.ID, "betreiber_login_als_nutzer", "app_user", ziel.ID, ziel.Email); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
// Ebene 5 (Plattform) — Pflege des zentralen Werkzeugkatalogs
|
||||||
|
// (`werkzeug.account_id IS NULL`). Mandantenspezifische Ergänzungen
|
||||||
|
// (`account_id` gesetzt) sind NICHT Teil dieser Seiten — die bleiben
|
||||||
|
// Sache des jeweiligen Mandanten (Ebene 4, "eigene Werkzeug-
|
||||||
|
// Freigaben/-Sperrungen", noch nicht gebaut), siehe CLAUDE.md.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen
|
||||||
|
// (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q"
|
||||||
|
// (Freitext über Name/Anbieter/Länder) und optional "zweck".
|
||||||
|
type werkzeugFilter struct {
|
||||||
|
Suche string
|
||||||
|
Zweck string
|
||||||
|
}
|
||||||
|
|
||||||
|
func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter {
|
||||||
|
return werkzeugFilter{
|
||||||
|
Suche: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||||
|
Zweck: r.URL.Query().Get("zweck"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein
|
||||||
|
// "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template
|
||||||
|
// nur exportierte Methoden per Reflection aufrufen kann.
|
||||||
|
func (f werkzeugFilter) Aktiv() bool {
|
||||||
|
return f.Suche != "" || f.Zweck != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f werkzeugFilter) matches(wz store.Werkzeug) bool {
|
||||||
|
if f.Zweck != "" {
|
||||||
|
gefunden := false
|
||||||
|
for _, z := range wz.GeeigneteZwecke {
|
||||||
|
if z == f.Zweck {
|
||||||
|
gefunden = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Suche != "" {
|
||||||
|
q := strings.ToLower(f.Suche)
|
||||||
|
heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " "))
|
||||||
|
if !strings.Contains(heuhaufen, q) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go)
|
||||||
|
// — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck,
|
||||||
|
// nur für Verarbeitungsländer statt Zwecke.
|
||||||
|
func istGueltigesLand(v string) bool {
|
||||||
|
for _, l := range weltLaenderliste {
|
||||||
|
if l == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke"
|
||||||
|
// (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld
|
||||||
|
// Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den
|
||||||
|
// künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am
|
||||||
|
// 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste
|
||||||
|
// v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert)
|
||||||
|
// — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab,
|
||||||
|
// nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung.
|
||||||
|
var gueltigeZwecke = []string{
|
||||||
|
"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche",
|
||||||
|
"Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten",
|
||||||
|
}
|
||||||
|
|
||||||
|
func istGueltigerZweck(v string) bool {
|
||||||
|
for _, z := range gueltigeZwecke {
|
||||||
|
if z == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen,
|
||||||
|
// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne
|
||||||
|
// führende/folgende Leerzeichen und ohne leere Einträge.
|
||||||
|
func splitListe(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type werkzeugListItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
TrainingStandard bool
|
||||||
|
Aufbewahrung string
|
||||||
|
Zertifizierungen string
|
||||||
|
GeeigneteZwecke string
|
||||||
|
LetztePruefung string
|
||||||
|
PruefStatusLabel string
|
||||||
|
PruefStatusCSS string
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat
|
||||||
|
// keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer
|
||||||
|
// sofortigen Löschung) verwechselt wird.
|
||||||
|
func formatAufbewahrung(tage *int) string {
|
||||||
|
if tage == nil {
|
||||||
|
return "unbekannt"
|
||||||
|
}
|
||||||
|
if *tage == 0 {
|
||||||
|
return "0 Tage (sofort)"
|
||||||
|
}
|
||||||
|
return strconv.Itoa(*tage) + " Tage"
|
||||||
|
}
|
||||||
|
|
||||||
|
type werkzeugListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Werkzeuge []werkzeugListItem
|
||||||
|
Filter werkzeugFilter
|
||||||
|
ZweckeOptionen []string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein
|
||||||
|
// Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe
|
||||||
|
// CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss
|
||||||
|
// belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt
|
||||||
|
// 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage,
|
||||||
|
// wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen,
|
||||||
|
// nicht erst wenn die Frist schon gerissen ist.
|
||||||
|
const (
|
||||||
|
werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour
|
||||||
|
werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer
|
||||||
|
// letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) /
|
||||||
|
// abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die
|
||||||
|
// Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch).
|
||||||
|
func pruefStatus(letztePruefung time.Time) (label, css string) {
|
||||||
|
alter := time.Since(letztePruefung)
|
||||||
|
switch {
|
||||||
|
case alter > werkzeugPruefungVeraltetNach:
|
||||||
|
return "abgelaufen", "status-hoch"
|
||||||
|
case alter > werkzeugPruefungBaldFaelligNach:
|
||||||
|
return "bald fällig", "status-mittel"
|
||||||
|
default:
|
||||||
|
return "aktuell", "status-niedrig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||||
|
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter := werkzeugFilterFromRequest(r)
|
||||||
|
var gefiltert []store.Werkzeug
|
||||||
|
for _, wz := range werkzeuge {
|
||||||
|
if filter.matches(wz) {
|
||||||
|
gefiltert = append(gefiltert, wz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
seite := paginate(gefiltert, pag)
|
||||||
|
|
||||||
|
data := werkzeugListeData{
|
||||||
|
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||||
|
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||||
|
}
|
||||||
|
for _, wz := range seite {
|
||||||
|
label, css := pruefStatus(wz.LetztePruefung)
|
||||||
|
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||||
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
DPFZertifiziert: wz.DPFZertifiziert,
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||||
|
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||||
|
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
|
||||||
|
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
|
||||||
|
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
|
||||||
|
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
|
||||||
|
type werkzeugFormData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
ActionBase string
|
||||||
|
ID string // leer bei "neu"
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
LaenderOptionen []string
|
||||||
|
LaenderAusgewaehlt map[string]bool
|
||||||
|
DPFZertifiziert bool
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
AVVURL string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0"
|
||||||
|
Zertifizierungen string
|
||||||
|
Subprozessoren string
|
||||||
|
ZweckeOptionen []string
|
||||||
|
ZweckeAusgewaehlt map[string]bool
|
||||||
|
Einschraenkungen string
|
||||||
|
LetztePruefung string
|
||||||
|
Quelle string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// auswahlSet baut die Checkbox-Vorbelegung aus einer bereits
|
||||||
|
// gespeicherten oder erneut eingereichten Liste von Zwecken.
|
||||||
|
func auswahlSet(ausgewaehlt []string) map[string]bool {
|
||||||
|
m := make(map[string]bool, len(ausgewaehlt))
|
||||||
|
for _, z := range ausgewaehlt {
|
||||||
|
m[z] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum
|
||||||
|
// Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber-
|
||||||
|
// und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen.
|
||||||
|
func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData {
|
||||||
|
aufbewahrung := ""
|
||||||
|
if wz.AufbewahrungTage != nil {
|
||||||
|
aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage)
|
||||||
|
}
|
||||||
|
return werkzeugFormData{
|
||||||
|
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID,
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert,
|
||||||
|
Name: wz.Name, Anbieter: wz.Anbieter,
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||||
|
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung,
|
||||||
|
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "),
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke),
|
||||||
|
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||||
|
Quelle: wz.Quelle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := werkzeugFormData{
|
||||||
|
Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge",
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
|
||||||
|
// accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
|
||||||
|
// für einen mandantenspezifischen Eintrag (Ebene 4).
|
||||||
|
func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
|
||||||
|
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
|
||||||
|
if err != nil {
|
||||||
|
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
||||||
|
}
|
||||||
|
var verarbeitungslaender []string
|
||||||
|
for _, l := range r.Form["verarbeitungslaender"] {
|
||||||
|
if istGueltigesLand(l) {
|
||||||
|
verarbeitungslaender = append(verarbeitungslaender, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(verarbeitungslaender) == 0 {
|
||||||
|
return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht"
|
||||||
|
}
|
||||||
|
name := r.FormValue("name")
|
||||||
|
anbieter := r.FormValue("anbieter")
|
||||||
|
quelle := r.FormValue("quelle")
|
||||||
|
if name == "" || anbieter == "" || quelle == "" {
|
||||||
|
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
||||||
|
}
|
||||||
|
var aufbewahrungTage *int
|
||||||
|
if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" {
|
||||||
|
wert, err := strconv.Atoi(rohwert)
|
||||||
|
if err != nil || wert < 0 {
|
||||||
|
return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)"
|
||||||
|
}
|
||||||
|
aufbewahrungTage = &wert
|
||||||
|
}
|
||||||
|
|
||||||
|
var geeigneteZwecke []string
|
||||||
|
for _, z := range r.Form["geeignete_zwecke"] {
|
||||||
|
if istGueltigerZweck(z) {
|
||||||
|
geeigneteZwecke = append(geeigneteZwecke, z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.WerkzeugInput{
|
||||||
|
AccountID: accountID,
|
||||||
|
Name: name,
|
||||||
|
Anbieter: anbieter,
|
||||||
|
Verarbeitungslaender: verarbeitungslaender,
|
||||||
|
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||||
|
AVVURL: r.FormValue("avv_url"),
|
||||||
|
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||||
|
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||||
|
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||||
|
AufbewahrungTage: aufbewahrungTage,
|
||||||
|
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||||
|
Subprozessoren: splitListe(r.FormValue("subprozessoren")),
|
||||||
|
GeeigneteZwecke: geeigneteZwecke,
|
||||||
|
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||||
|
LetztePruefung: letztePruefung,
|
||||||
|
Quelle: quelle,
|
||||||
|
}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
|
||||||
|
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
|
||||||
|
// statt das Formular zu leeren.
|
||||||
|
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||||
|
return werkzeugFormData{
|
||||||
|
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id,
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]),
|
||||||
|
DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on",
|
||||||
|
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"),
|
||||||
|
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
||||||
|
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
|
||||||
|
AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")),
|
||||||
|
Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"),
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]),
|
||||||
|
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||||
|
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugCreate legt einen zentralen Katalogeintrag an.
|
||||||
|
func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugEditForm zeigt einen zentralen Katalogeintrag
|
||||||
|
// zum Bearbeiten. Ein mandantenspezifischer Eintrag (account_id
|
||||||
|
// gesetzt) ist hier bewusst nicht erreichbar — das ist Sache des
|
||||||
|
// jeweiligen Mandanten, nicht der Plattform.
|
||||||
|
func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil || wz.AccountID != nil {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge")
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugUpdate speichert Änderungen an einem zentralen
|
||||||
|
// Katalogeintrag. Werkzeug ist nicht append-only (siehe store.UpdateWerkzeug)
|
||||||
|
// — eine bereits getroffene Entscheidung hält ihren eigenen, zum
|
||||||
|
// Entscheidungszeitpunkt eingefrorenen Snapshot (entscheidung.werkzeug_snapshot).
|
||||||
|
func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID != nil {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBetreiberWerkzeugDelete entfernt einen zentralen Katalogeintrag.
|
||||||
|
func (s *Server) handleBetreiberWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID != nil {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func werkzeugForm() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
|
||||||
|
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||||
|
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
||||||
|
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
||||||
|
"einschraenkungen": {""}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCanCreateZentralesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var w store.Werkzeug
|
||||||
|
for _, wz := range fs.werkzeuge {
|
||||||
|
w = wz
|
||||||
|
}
|
||||||
|
if w.Name != "ChatGPT Enterprise" || w.AccountID != nil {
|
||||||
|
t.Fatalf("Werkzeug = %+v, unerwartete Werte", w)
|
||||||
|
}
|
||||||
|
if len(w.Zertifizierungen) != 2 {
|
||||||
|
t.Errorf("Zertifizierungen = %v, want 2 Einträge", w.Zertifizierungen)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||||
|
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the new werkzeug in the Katalogliste, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberWerkzeugCreateRequiresLetztePruefungUndQuelle(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
form := werkzeugForm()
|
||||||
|
form.Set("letzte_pruefung", "")
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", form)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler) ohne letzte_pruefung", resp.Code)
|
||||||
|
}
|
||||||
|
if len(fs.werkzeuge) != 0 {
|
||||||
|
t.Errorf("expected no werkzeug to be created, got %d", len(fs.werkzeuge))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCanUpdateAndDeleteZentralesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||||
|
var id string
|
||||||
|
for wid := range fs.werkzeuge {
|
||||||
|
id = wid
|
||||||
|
}
|
||||||
|
|
||||||
|
editResp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id)
|
||||||
|
if editResp.Code != http.StatusOK || !strings.Contains(editResp.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Fatalf("edit form status = %d, body: %s", editResp.Code, editResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
updateForm := werkzeugForm()
|
||||||
|
updateForm.Set("name", "ChatGPT Enterprise (aktualisiert)")
|
||||||
|
updateResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id, updateForm)
|
||||||
|
if updateResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||||
|
}
|
||||||
|
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (aktualisiert)" {
|
||||||
|
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := fs.werkzeuge[id]; ok {
|
||||||
|
t.Error("expected the werkzeug to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBetreiberCannotEditMandantenEigenesWerkzeug: ein mandantenspezifischer
|
||||||
|
// Katalogeintrag (account_id gesetzt) ist über die Betreiber-Seiten
|
||||||
|
// nicht erreichbar — das bleibt Sache des jeweiligen Mandanten.
|
||||||
|
func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Mandant mit eigenem Werkzeug")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
eigenesID := "eigenes-werkzeug"
|
||||||
|
fs.werkzeuge[eigenesID] = store.Werkzeug{ID: eigenesID, AccountID: &acc.ID, Name: "Mandanten-eigenes Tool"}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+eigenesID)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberWerkzeugListeFiltertNachSucheUndZweck(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
fs.werkzeuge["a"] = store.Werkzeug{
|
||||||
|
ID: "a", Name: "ChatGPT Enterprise", Anbieter: "OpenAI",
|
||||||
|
Verarbeitungslaender: []string{"USA"}, GeeigneteZwecke: []string{"Textgenerierung"},
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
fs.werkzeuge["b"] = store.Werkzeug{
|
||||||
|
ID: "b", Name: "DeepL Pro", Anbieter: "DeepL SE",
|
||||||
|
Verarbeitungslaender: []string{"Deutschland"}, GeeigneteZwecke: []string{"Übersetzung"},
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=deepl")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || !strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||||
|
t.Fatalf("q=deepl sollte nur DeepL Pro zeigen, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?zweck=Textgenerierung")
|
||||||
|
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") || strings.Contains(resp.Body.String(), "DeepL Pro") {
|
||||||
|
t.Fatalf("zweck=Textgenerierung sollte nur ChatGPT Enterprise zeigen, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?q=nichts-passt")
|
||||||
|
if !strings.Contains(resp.Body.String(), "Keine Treffer") {
|
||||||
|
t.Fatalf("erwartet 'Keine Treffer' bei erfolgloser Suche, body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberWerkzeugListePaginiert(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
id := fmt.Sprintf("wz-%02d", i)
|
||||||
|
fs.werkzeuge[id] = store.Werkzeug{
|
||||||
|
ID: id, Name: fmt.Sprintf("Werkzeug %02d", i), Anbieter: "Testanbieter",
|
||||||
|
LetztePruefung: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Seite 1 von 2") {
|
||||||
|
t.Fatalf("erwartet 'Seite 1 von 2' bei 25 Einträgen (20 pro Seite), body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Weiter") || strings.Contains(resp.Body.String(), "Zurück") {
|
||||||
|
t.Fatalf("Seite 1 sollte einen Weiter-, aber keinen Zurück-Button zeigen")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge?page=2")
|
||||||
|
if !strings.Contains(resp.Body.String(), "Seite 2 von 2") {
|
||||||
|
t.Fatalf("erwartet 'Seite 2 von 2', body: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Weiter") || !strings.Contains(resp.Body.String(), "Zurück") {
|
||||||
|
t.Fatalf("Seite 2 sollte einen Zurück-, aber keinen Weiter-Button zeigen")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
|
||||||
|
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
|
||||||
|
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
|
||||||
|
// beim einzelnen "Nutzer anlegen" generiert der Import ein
|
||||||
|
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
|
||||||
|
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
|
||||||
|
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
|
||||||
|
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
|
||||||
|
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type csvMitarbeiterZeile struct {
|
||||||
|
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
|
||||||
|
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
|
||||||
|
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
|
||||||
|
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
|
||||||
|
// einzelne falsche Rolle nicht den ganzen Import verhindert).
|
||||||
|
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
|
||||||
|
cr := csv.NewReader(r)
|
||||||
|
cr.TrimLeadingSpace = true
|
||||||
|
cr.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
header, err := cr.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
emailCol, roleCol := -1, -1
|
||||||
|
for i, h := range header {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(h)) {
|
||||||
|
case "email", "e-mail":
|
||||||
|
emailCol = i
|
||||||
|
case "role", "rolle":
|
||||||
|
roleCol = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if emailCol == -1 {
|
||||||
|
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []csvMitarbeiterZeile
|
||||||
|
zeile := 1
|
||||||
|
for {
|
||||||
|
record, err := cr.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
|
||||||
|
}
|
||||||
|
zeile++
|
||||||
|
if emailCol >= len(record) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(record[emailCol])
|
||||||
|
if email == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
role := "mitarbeiter"
|
||||||
|
if roleCol != -1 && roleCol < len(record) {
|
||||||
|
if v := strings.TrimSpace(record[roleCol]); v != "" {
|
||||||
|
role = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
|
||||||
|
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
|
||||||
|
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
|
||||||
|
// Länge, die sich noch manuell weitergeben lässt.
|
||||||
|
func generateEinmalPasswort() (string, error) {
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token[:16], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportFormData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||||
|
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportErgebnisZeile struct {
|
||||||
|
Zeile int
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
Passwort string // nur bei Erfolg gesetzt
|
||||||
|
Fehler string // nur bei Fehler gesetzt
|
||||||
|
}
|
||||||
|
|
||||||
|
type csvImportErgebnisData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Ergebnisse []csvImportErgebnisZeile
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
|
||||||
|
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
|
||||||
|
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
|
||||||
|
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
|
||||||
|
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
|
||||||
|
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := r.FormFile("csv")
|
||||||
|
if err != nil {
|
||||||
|
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
zeilen, err := parseCSVMitarbeiter(file)
|
||||||
|
if err != nil {
|
||||||
|
s.renderCSVImportForm(w, r, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(zeilen) == 0 {
|
||||||
|
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
var ergebnisse []csvImportErgebnisZeile
|
||||||
|
for _, z := range zeilen {
|
||||||
|
if !istMandantenRolle(z.Role) {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
passwort, err := generateEinmalPasswort()
|
||||||
|
if err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(passwort)
|
||||||
|
if err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
|
||||||
|
}
|
||||||
|
|
||||||
|
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
|
||||||
|
// — reale Browser-Uploads laufen genauso über multipart/form-data.
|
||||||
|
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte(csvContent)); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("mw.Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
|
||||||
|
t.Fatalf("expected both imported emails in the result, got: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail (lisa): %v", err)
|
||||||
|
}
|
||||||
|
if lisa.Role != "verantwortlicher" {
|
||||||
|
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
|
||||||
|
}
|
||||||
|
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail (tom): %v", err)
|
||||||
|
}
|
||||||
|
if tom.Role != "mitarbeiter" {
|
||||||
|
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
|
||||||
|
body := resp.Body.String()
|
||||||
|
start := strings.Index(body, "<code>")
|
||||||
|
end := strings.Index(body, "</code>")
|
||||||
|
if start == -1 || end == -1 {
|
||||||
|
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
|
||||||
|
}
|
||||||
|
passwort := body[start+len("<code>") : end]
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, nil, "/login", map[string][]string{
|
||||||
|
"email": {"neu@example.com"}, "password": {passwort},
|
||||||
|
})
|
||||||
|
if loginResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
|
||||||
|
t.Error("expected no user to be created for an invalid role (betreiber)")
|
||||||
|
}
|
||||||
|
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
|
||||||
|
t.Error("expected the valid row to still be imported despite the earlier invalid row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("mw.Close: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.AddCookie(adminCookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
|
||||||
|
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessCSVImport(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer/import")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
|
||||||
|
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 2 {
|
||||||
|
t.Fatalf("zeilen = %+v, want 2", zeilen)
|
||||||
|
}
|
||||||
|
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
|
||||||
|
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
|
||||||
|
}
|
||||||
|
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
|
||||||
|
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
|
||||||
|
csv := "email\nlisa@example.com\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
|
||||||
|
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
|
||||||
|
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
|
||||||
|
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
|
||||||
|
csv := "name,role\nLisa,mitarbeiter\n"
|
||||||
|
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when the header has no email column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
|
||||||
|
csv := "email\nlisa@example.com\ntom@example.com\n"
|
||||||
|
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||||
|
}
|
||||||
|
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
|
||||||
|
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
|
||||||
|
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
|
||||||
|
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
|
||||||
|
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
|
||||||
|
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
|
||||||
|
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
|
||||||
|
// Abschnitt A), nicht hier.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type einladungFormData struct {
|
||||||
|
Title string
|
||||||
|
Firmenname string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
|
||||||
|
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
|
||||||
|
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
|
||||||
|
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
|
||||||
|
// existiert hat oder nur nicht mehr gültig ist.
|
||||||
|
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||||
|
if err != nil {
|
||||||
|
s.renderEinladungError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderEinladungForm(w, acc.Name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
|
||||||
|
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderEinladungError(w http.ResponseWriter) {
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
|
||||||
|
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
|
||||||
|
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
|
||||||
|
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
|
||||||
|
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||||
|
if err != nil {
|
||||||
|
s.renderEinladungError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
if email == "" || password == "" {
|
||||||
|
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
s.renderEinladungForm(w, acc.Name, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
|
||||||
|
if err != nil {
|
||||||
|
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.startSession(w, r, user.ID); err != nil {
|
||||||
|
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type einladungVerwaltungData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Link string
|
||||||
|
}
|
||||||
|
|
||||||
|
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
|
||||||
|
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
|
||||||
|
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
|
||||||
|
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
|
||||||
|
// X-Forwarded-Proto berücksichtigen.
|
||||||
|
func einladungBasisURL(r *http.Request) string {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return scheme + "://" + r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
|
||||||
|
// Mandanten zum Kopieren.
|
||||||
|
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
|
||||||
|
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := einladungVerwaltungData{
|
||||||
|
Title: "Einladungslink", Nav: navFor(r),
|
||||||
|
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
|
||||||
|
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
|
||||||
|
// außerhalb des Unternehmens geteilt wurde).
|
||||||
|
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
|
||||||
|
newToken, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
|
||||||
|
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEinladungFormZeigtFirmenname(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Beispiel GmbH") {
|
||||||
|
t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "ungültig") {
|
||||||
|
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{
|
||||||
|
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if len(resp.Result().Cookies()) == 0 {
|
||||||
|
t.Fatal("expected a session cookie to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if user.Role != "mitarbeiter" || user.AccountID != acc.ID {
|
||||||
|
t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := resp.Result().Cookies()[0]
|
||||||
|
protected := getWithCookie(t, s, cookie, "/antraege")
|
||||||
|
if protected.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected the new session to work, status = %d", protected.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{
|
||||||
|
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "ungültig") {
|
||||||
|
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if len(fs.users) != 0 {
|
||||||
|
t.Error("expected no user to be created for an invalid token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSiehtEinladungslink(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "/einladung/") {
|
||||||
|
t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminKannEinladungslinkErneuern(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
altesToken := fs.accounts[admin.AccountID].EinladungToken
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
neuesToken := fs.accounts[admin.AccountID].EinladungToken
|
||||||
|
if neuesToken == altesToken {
|
||||||
|
t.Fatal("expected the token to change")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken)
|
||||||
|
if !strings.Contains(oldLinkResp.Body.String(), "ungültig") {
|
||||||
|
t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String())
|
||||||
|
}
|
||||||
|
newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken)
|
||||||
|
if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") {
|
||||||
|
t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessEinladungsverwaltung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/einladung")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// Ebene 3 (Fachebene) — Posteingang offener Anträge und Entscheiden.
|
||||||
|
// Rollen "verantwortlicher" (Entscheidungsrecht) und "pruefer"
|
||||||
|
// (identische Sicht, kein Entscheidungsrecht) sehen dieselben Seiten;
|
||||||
|
// siehe requireFachebene in middleware.go und CLAUDE.md, Fünf Ebenen.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fallListItem struct {
|
||||||
|
ID string
|
||||||
|
Titel string
|
||||||
|
Haeufigkeit string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fallListData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Faelle []fallListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
||||||
|
// "eingereicht") Anträge des ganzen Mandanten — im Unterschied zu
|
||||||
|
// GET /antraege, das nur die eigenen Anträge eines Nutzers zeigt.
|
||||||
|
func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
antraege, err := s.store.ListAntraegeForAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var offene []fallListItem
|
||||||
|
for _, a := range antraege {
|
||||||
|
if a.Status != "eingereicht" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesQuery(q, a.Titel, a.Haeufigkeit) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
offene = append(offene, fallListItem{
|
||||||
|
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
||||||
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(offene))
|
||||||
|
data := fallListData{
|
||||||
|
Title: "Posteingang", Nav: navFor(r),
|
||||||
|
Faelle: paginate(offene, pag), Suche: q, Pagination: pag,
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type entscheidungView struct {
|
||||||
|
Entscheidung string
|
||||||
|
Begruendung string
|
||||||
|
WerkzeugName string
|
||||||
|
GueltigBis string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type werkzeugOption struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fallDetailData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
ID string
|
||||||
|
Titel string
|
||||||
|
Beschreibung string
|
||||||
|
Ergebnis string
|
||||||
|
Haeufigkeit string
|
||||||
|
Status string
|
||||||
|
CreatedAt string
|
||||||
|
Antworten []antwortZeile
|
||||||
|
Bewertung *bewertungView
|
||||||
|
Entscheidung *entscheidungView
|
||||||
|
CanEntscheiden bool
|
||||||
|
WerkzeugOptionen []werkzeugOption
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
|
||||||
|
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
|
||||||
|
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
|
||||||
|
func hatEntscheidungsrecht(role string) bool {
|
||||||
|
return role == "verantwortlicher" || role == "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
|
||||||
|
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
||||||
|
// eine tatsächliche Entscheidung davon abweicht (dann ist die
|
||||||
|
// Begründung Pflicht, siehe CLAUDE.md, Grundregel).
|
||||||
|
func vorschlagFuer(b store.Bewertung) string {
|
||||||
|
if b.Verboten {
|
||||||
|
return "abgelehnt"
|
||||||
|
}
|
||||||
|
if len(b.ZulaessigeWerkzeuge) > 0 {
|
||||||
|
return "genehmigt"
|
||||||
|
}
|
||||||
|
return "rueckfrage"
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFallDetail zeigt einen Antrag mit Bewertung für die Fachebene
|
||||||
|
// — anders als GET /antraege/{id} (nur der eigene Antrag) ist hier
|
||||||
|
// jeder Antrag des Mandanten sichtbar, unabhängig vom Ersteller.
|
||||||
|
func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil || antrag.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var antworten map[string]any
|
||||||
|
if len(antrag.Antworten) > 0 {
|
||||||
|
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := fallDetailData{
|
||||||
|
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
||||||
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
||||||
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
||||||
|
}
|
||||||
|
|
||||||
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err == nil {
|
||||||
|
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
||||||
|
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
||||||
|
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.CanEntscheiden = hatEntscheidungsrecht(currentUser(r).Role) && antrag.Status == "eingereicht" && data.Bewertung != nil
|
||||||
|
|
||||||
|
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err == nil {
|
||||||
|
v := &entscheidungView{
|
||||||
|
Entscheidung: entscheidung.Entscheidung, Begruendung: entscheidung.Begruendung,
|
||||||
|
CreatedAt: entscheidung.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
}
|
||||||
|
if entscheidung.WerkzeugSnapshot != nil {
|
||||||
|
v.WerkzeugName = entscheidung.WerkzeugSnapshot.Name
|
||||||
|
}
|
||||||
|
if entscheidung.GueltigBis != nil {
|
||||||
|
v.GueltigBis = entscheidung.GueltigBis.Format("02.01.2006")
|
||||||
|
}
|
||||||
|
data.Entscheidung = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gueltigkeitFuer liefert das Ablaufdatum einer Genehmigung — 12 Monate,
|
||||||
|
// bei hochrisiko 6 (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage).
|
||||||
|
func gueltigkeitFuer(einstufung string) time.Time {
|
||||||
|
if einstufung == "hochrisiko" {
|
||||||
|
return time.Now().AddDate(0, 6, 0)
|
||||||
|
}
|
||||||
|
return time.Now().AddDate(1, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
||||||
|
// Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
|
||||||
|
// haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
|
||||||
|
// requireFachebene (404, weil die Seite für sie gar nicht existieren
|
||||||
|
// soll) ist das hier ein 403: ein Prüfer darf wissen, dass der Fall
|
||||||
|
// existiert, nur nicht entscheiden.
|
||||||
|
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
if !hatEntscheidungsrecht(user.Role) {
|
||||||
|
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
||||||
|
if err != nil || antrag.AccountID != user.AccountID {
|
||||||
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if antrag.Status != "eingereicht" {
|
||||||
|
http.Error(w, "Antrag ist bereits entschieden", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bewertung nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entscheidungTyp := r.FormValue("entscheidung")
|
||||||
|
begruendung := r.FormValue("begruendung")
|
||||||
|
werkzeugID := r.FormValue("werkzeug_id")
|
||||||
|
|
||||||
|
switch entscheidungTyp {
|
||||||
|
case "genehmigt", "genehmigt_mit_auflagen", "abgelehnt", "rueckfrage":
|
||||||
|
default:
|
||||||
|
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if vorschlagFuer(bewertung) != entscheidungTyp && begruendung == "" {
|
||||||
|
http.Error(w, "Begründung erforderlich, wenn die Entscheidung vom Vorschlag abweicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
|
||||||
|
in := store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||||
|
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
||||||
|
}
|
||||||
|
var werkzeugName string
|
||||||
|
if istGenehmigung {
|
||||||
|
zulaessig := false
|
||||||
|
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
||||||
|
if id == werkzeugID {
|
||||||
|
zulaessig = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if werkzeugID == "" || !zulaessig {
|
||||||
|
http.Error(w, "bei Genehmigung muss ein zulässiges Werkzeug gewählt werden", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
werkzeug, err := s.store.GetWerkzeug(r.Context(), werkzeugID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
werkzeugName = werkzeug.Name
|
||||||
|
in.WerkzeugID = &werkzeugID
|
||||||
|
in.WerkzeugSnapshot = &werkzeug
|
||||||
|
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
||||||
|
in.GueltigBis = &gueltigBis
|
||||||
|
}
|
||||||
|
|
||||||
|
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_entschieden", "antrag", antrag.ID, entscheidungTyp); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe
|
||||||
|
// CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts
|
||||||
|
// Genehmigtes, das im Register stehen müsste.
|
||||||
|
if istGenehmigung {
|
||||||
|
abteilungName := ""
|
||||||
|
if antrag.AbteilungID != nil {
|
||||||
|
if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil {
|
||||||
|
abteilungName = abt.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var auflagen []string
|
||||||
|
for _, a := range bewertung.Anforderungen {
|
||||||
|
auflagen = append(auflagen, a.Beschreibung)
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{
|
||||||
|
AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
||||||
|
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
||||||
|
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
||||||
|
Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedFallImAccount legt einen Mandanten mit einem Mitarbeiter an, der
|
||||||
|
// einen Antrag einreicht (mit personenbezogener Datenklasse, Einstufung
|
||||||
|
// minimal — siehe fullAntragForm), und liefert dessen ID sowie eine
|
||||||
|
// zweite Cookie für einen weiteren Nutzer der Rolle role im selben
|
||||||
|
// Mandanten (verantwortlicher/pruefer).
|
||||||
|
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
resp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
for id := range fs.antraege {
|
||||||
|
if fs.antraege[id].AccountID == acc.ID {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fachebeneCookie = seedUserInAccount(t, fs, acc.ID, role+"@example.com", role)
|
||||||
|
return antragID, fachebeneCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessFaelle(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "mitarbeiter@example.com")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/faelle")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter on /faelle", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
|
||||||
|
otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "other-verantwortlicher@example.com", "verantwortlicher")
|
||||||
|
otherResp := getWithCookie(t, s, otherCookie, "/faelle")
|
||||||
|
if strings.Contains(otherResp.Body.String(), "Angebotstexte generieren") {
|
||||||
|
t.Errorf("expected no cross-tenant antrag in the Posteingang, got: %s", otherResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||||
|
t.Errorf("expected the Entscheiden form for verantwortlicher, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die
|
||||||
|
// tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die
|
||||||
|
// daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung
|
||||||
|
// nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel).
|
||||||
|
func TestFallDetailZeigtFragebogenAntworten(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||||
|
t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||||
|
t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, prueferCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), `name="entscheidung"`) {
|
||||||
|
t.Errorf("expected no Entscheiden form for pruefer, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallEntscheidenRejectsPruefer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
|
||||||
|
|
||||||
|
resp := postForm(t, s, prueferCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||||
|
if resp.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403 for pruefer", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFallEntscheidenRequiresBegruendungOnAbweichung: fullAntragForm
|
||||||
|
// liefert keine passenden Werkzeuge im (leeren) Katalog, der Vorschlag
|
||||||
|
// ist also "rueckfrage" (siehe vorschlagFuer). "abgelehnt" weicht davon
|
||||||
|
// ab und braucht eine Begründung.
|
||||||
|
func TestFallEntscheidenRequiresBegruendungOnAbweichung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"abgelehnt"}})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 ohne Begründung bei Abweichung, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug im Katalog verfügbar, trotzdem vertretbar"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303 mit Begründung, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303 (rueckfrage entspricht dem Vorschlag bei leerem Katalog), body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||||
|
if !strings.Contains(detail.Body.String(), "rueckfrage") {
|
||||||
|
t.Errorf("expected the Entscheidung on the fall-detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
|
||||||
|
s := newServer(t, fs)
|
||||||
|
createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||||
|
if createResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
if fs.antraege[id].AccountID == acc.ID {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher")
|
||||||
|
|
||||||
|
// Ohne werkzeug_id: abgelehnt.
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 ohne Werkzeug bei Genehmigung", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit zulässigem Werkzeug: erfolgreich, Antrag wird "entschieden".
|
||||||
|
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
antrag, err := fs.GetAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if antrag.Status != "entschieden" {
|
||||||
|
t.Errorf("Status = %q, want entschieden", antrag.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
|
||||||
|
if !strings.Contains(detail.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the chosen werkzeug name on the fall-detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFallDetailRejectsForeignAccount(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "fremd@example.com", "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, otherCookie, "/faelle/"+antragID)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's fall", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"ok":true}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type indexData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
OffeneAntraege int
|
||||||
|
ZeigePosteingang bool
|
||||||
|
OffenerPosteingang int
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIndex ist die Startseite nach der Anmeldung — ein kurzer
|
||||||
|
// Überblick statt reiner Navigations-Duplikate (die Links stehen
|
||||||
|
// eh schon in der Seitenleiste): eigene offene Anträge, und für die
|
||||||
|
// Fachebene zusätzlich die Zahl offener Fälle im Posteingang. Bewusst
|
||||||
|
// ohne die Wiedervorlage-Zahl hier — deren Berechnung vergleicht jeden
|
||||||
|
// aktiven Genehmigungs-Snapshot gegen den aktuellen Katalogstand
|
||||||
|
// (siehe handleWiedervorlageListe) und wäre für eine Startseite, die
|
||||||
|
// bei jedem Login geladen wird, zu teuer.
|
||||||
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
nav := navFor(r)
|
||||||
|
data := indexData{Title: "Start", Nav: nav}
|
||||||
|
|
||||||
|
if !nav.IsBetreiber {
|
||||||
|
eigene, err := s.store.ListAntraegeForUser(r.Context(), user.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, a := range eigene {
|
||||||
|
if a.Status == "eingereicht" {
|
||||||
|
data.OffeneAntraege++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nav.IsFachebene {
|
||||||
|
data.ZeigePosteingang = true
|
||||||
|
alle, err := s.store.ListAntraegeForAccount(r.Context(), user.AccountID)
|
||||||
|
if err == nil {
|
||||||
|
for _, a := range alle {
|
||||||
|
if a.Status == "eingereicht" {
|
||||||
|
data.OffenerPosteingang++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, `href="/antraege"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Meine offenen Anträge") {
|
||||||
|
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
if !strings.Contains(body, `href="/faelle"`) || !strings.Contains(body, `stat-card-value">1</span>`) || !strings.Contains(body, "Posteingang") {
|
||||||
|
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexZeigtKeinenPosteingangFuerMitarbeiter(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Posteingang") {
|
||||||
|
t.Errorf("expected no Posteingang tile for mitarbeiter, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), `href="/betreiber"`) || !strings.Contains(resp.Body.String(), "Zur Plattform") {
|
||||||
|
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||||
|
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBetreiberKannAlsNutzerAnmelden(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
cookies := resp.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("expected a new session cookie")
|
||||||
|
}
|
||||||
|
neueCookie := cookies[0]
|
||||||
|
|
||||||
|
// Die neue Sitzung ist tatsächlich der Kunden-Login.
|
||||||
|
protected := getWithCookie(t, s, neueCookie, "/antraege")
|
||||||
|
if protected.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected the impersonated session to work, status = %d", protected.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsZeigtBanner(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
neueCookie := loginResp.Result().Cookies()[0]
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, neueCookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "Support-Zugriff durch") || !strings.Contains(resp.Body.String(), "betreiber@example.com") {
|
||||||
|
t.Errorf("expected the impersonation banner naming the betreiber, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberLoginAlsProtokolliertAuditEintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Kunde GmbH", "kunde@example.com")
|
||||||
|
kunde, err := fs.GetUserByEmail(context.Background(), "kunde@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
postForm(t, s, betreiberCookie, "/betreiber/accounts/"+kunde.AccountID+"/nutzer/"+kunde.ID+"/anmelden-als", url.Values{})
|
||||||
|
|
||||||
|
var gefunden bool
|
||||||
|
for _, e := range fs.auditLog {
|
||||||
|
if e.Action == "betreiber_login_als_nutzer" && e.ActorUserID == betreiber.ID && e.TargetID == kunde.ID {
|
||||||
|
gefunden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gefunden {
|
||||||
|
t.Errorf("expected an audit log entry for the impersonation, got: %+v", fs.auditLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberCannotLoginAlsDeaktiviertenNutzer(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Kunde GmbH", "kunde-admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
weg, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+weg.ID+"/deaktivieren", url.Values{})
|
||||||
|
|
||||||
|
resp := postForm(t, s, betreiberCookie, "/betreiber/accounts/"+weg.AccountID+"/nutzer/"+weg.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for a deactivated user", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotLoginAls(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
self, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/betreiber/accounts/"+self.AccountID+"/nutzer/"+self.ID+"/anmelden-als", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
// weltLaenderliste ist die kontrollierte Auswahl für "Verarbeitungsländer"
|
||||||
|
// im Werkzeugkatalog — eine echte Länderliste statt grober Eimer wie
|
||||||
|
// "EU"/"USA"/"gemischt" (die USA sind DSGVO-rechtlich bereits ein
|
||||||
|
// Drittland wie jedes andere, siehe CLAUDE.md). Enthält zusätzlich zwei
|
||||||
|
// Pseudo-Einträge für Fälle, in denen der Anbieter selbst keinen
|
||||||
|
// einzelnen Staat zusichert bzw. keine Cloud-Verarbeitung stattfindet.
|
||||||
|
var weltLaenderliste = []string{
|
||||||
|
"EU-Region (kein einzelnes Land benannt)",
|
||||||
|
"On-Premise (selbst gehostet)",
|
||||||
|
|
||||||
|
"Afghanistan", "Ägypten", "Albanien", "Algerien", "Andorra", "Angola",
|
||||||
|
"Antigua und Barbuda", "Äquatorialguinea", "Argentinien", "Armenien",
|
||||||
|
"Aserbaidschan", "Äthiopien", "Australien", "Bahamas", "Bahrain",
|
||||||
|
"Bangladesch", "Barbados", "Belgien", "Belize", "Benin", "Bhutan",
|
||||||
|
"Bolivien", "Bosnien und Herzegowina", "Botsuana", "Brasilien",
|
||||||
|
"Brunei", "Bulgarien", "Burkina Faso", "Burundi", "Chile", "China",
|
||||||
|
"Costa Rica", "Dänemark", "Deutschland", "Dominica",
|
||||||
|
"Dominikanische Republik", "Dschibuti", "Ecuador", "El Salvador",
|
||||||
|
"Elfenbeinküste", "Eritrea", "Estland", "Eswatini", "Fidschi",
|
||||||
|
"Finnland", "Frankreich", "Gabun", "Gambia", "Georgien", "Ghana",
|
||||||
|
"Grenada", "Griechenland", "Guatemala", "Guinea", "Guinea-Bissau",
|
||||||
|
"Guyana", "Haiti", "Honduras", "Indien", "Indonesien", "Irak",
|
||||||
|
"Iran", "Irland", "Island", "Israel", "Italien", "Jamaika", "Japan",
|
||||||
|
"Jemen", "Jordanien", "Kambodscha", "Kamerun", "Kanada", "Kap Verde",
|
||||||
|
"Kasachstan", "Katar", "Kenia", "Kirgisistan", "Kiribati",
|
||||||
|
"Kolumbien", "Komoren", "Kongo (Republik)",
|
||||||
|
"Kongo (Demokratische Republik)", "Kosovo", "Kroatien", "Kuba",
|
||||||
|
"Kuwait", "Laos", "Lesotho", "Lettland", "Libanon", "Liberia",
|
||||||
|
"Libyen", "Liechtenstein", "Litauen", "Luxemburg", "Madagaskar",
|
||||||
|
"Malawi", "Malaysia", "Malediven", "Mali", "Malta", "Marokko",
|
||||||
|
"Marshallinseln", "Mauretanien", "Mauritius", "Mexiko",
|
||||||
|
"Mikronesien", "Moldau", "Monaco", "Mongolei", "Montenegro",
|
||||||
|
"Mosambik", "Myanmar", "Namibia", "Nauru", "Nepal", "Neuseeland",
|
||||||
|
"Nicaragua", "Niederlande", "Niger", "Nigeria", "Nordkorea",
|
||||||
|
"Nordmazedonien", "Norwegen", "Oman", "Österreich", "Osttimor",
|
||||||
|
"Pakistan", "Palau", "Palästina", "Panama", "Papua-Neuguinea",
|
||||||
|
"Paraguay", "Peru", "Philippinen", "Polen", "Portugal", "Ruanda",
|
||||||
|
"Rumänien", "Russland", "Salomonen", "Sambia", "Samoa",
|
||||||
|
"San Marino", "São Tomé und Príncipe", "Saudi-Arabien", "Schweden",
|
||||||
|
"Schweiz", "Senegal", "Serbien", "Seychellen", "Sierra Leone",
|
||||||
|
"Simbabwe", "Singapur", "Slowakei", "Slowenien", "Somalia",
|
||||||
|
"Spanien", "Sri Lanka", "St. Kitts und Nevis", "St. Lucia",
|
||||||
|
"St. Vincent und die Grenadinen", "Südafrika", "Sudan", "Südkorea",
|
||||||
|
"Südsudan", "Suriname", "Syrien", "Tadschikistan", "Taiwan",
|
||||||
|
"Tansania", "Thailand", "Togo", "Tonga", "Trinidad und Tobago",
|
||||||
|
"Tschad", "Tschechien", "Tunesien", "Türkei", "Turkmenistan",
|
||||||
|
"Tuvalu", "Uganda", "Ukraine", "Ungarn", "Uruguay", "USA",
|
||||||
|
"Usbekistan", "Vanuatu", "Vatikanstadt", "Venezuela",
|
||||||
|
"Vereinigte Arabische Emirate", "Vereinigtes Königreich", "Vietnam",
|
||||||
|
"Zentralafrikanische Republik", "Zypern",
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminCanCreateEditAndDeleteEigenesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge", werkzeugForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var id string
|
||||||
|
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
for wid, w := range fs.werkzeuge {
|
||||||
|
if w.AccountID != nil && *w.AccountID == admin.AccountID {
|
||||||
|
id = wid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected an eigenes werkzeug to exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the eigenes werkzeug in the list, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
editForm := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/"+id)
|
||||||
|
if editForm.Code != http.StatusOK || !strings.Contains(editForm.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Fatalf("edit form status = %d, body: %s", editForm.Code, editForm.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
updateForm := werkzeugForm()
|
||||||
|
updateForm.Set("name", "ChatGPT Enterprise (eigene Version)")
|
||||||
|
updateResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id, updateForm)
|
||||||
|
if updateResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||||
|
}
|
||||||
|
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (eigene Version)" {
|
||||||
|
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := fs.werkzeuge[id]; ok {
|
||||||
|
t.Error("expected the eigenes werkzeug to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotEditZentralesWerkzeugUeberMandantenRoute(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a central werkzeug via the tenant route", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotEditFremdesEigenesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
postForm(t, s, adminA, "/verwaltung/werkzeuge", werkzeugForm())
|
||||||
|
adminAUser, err := fs.GetUserByEmail(context.Background(), "admin-a@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
for wid, w := range fs.werkzeuge {
|
||||||
|
if w.AccountID != nil && *w.AccountID == adminAUser.AccountID {
|
||||||
|
id = wid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
resp := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge/"+id)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's eigenes werkzeug", resp.Code)
|
||||||
|
}
|
||||||
|
deleteResp := postForm(t, s, adminB, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||||
|
if deleteResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("delete status = %d, want 404 for a foreign account's eigenes werkzeug", deleteResp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md,
|
||||||
|
// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für
|
||||||
|
// sich sperren, ohne den zentralen Katalog selbst zu verändern (das
|
||||||
|
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go),
|
||||||
|
// UND eigene, mandantenspezifische Katalogeinträge pflegen
|
||||||
|
// (`werkzeug.account_id` = der eigene Account) — dieselbe
|
||||||
|
// Formular-Vorlage wie der Betreiber nutzt (werkzeugFormData,
|
||||||
|
// betreiber-werkzeug-form.html, ActionBase unterscheidet die Ziel-URL).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mandantWerkzeugItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
TrainingStandard bool
|
||||||
|
Aufbewahrung string
|
||||||
|
GeeigneteZwecke string
|
||||||
|
LetztePruefung string
|
||||||
|
PruefStatusLabel string
|
||||||
|
PruefStatusCSS string
|
||||||
|
Gesperrt bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type eigenesWerkzeugItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Anbieter string
|
||||||
|
Verarbeitungslaender string
|
||||||
|
LetztePruefung string
|
||||||
|
}
|
||||||
|
|
||||||
|
type mandantWerkzeugListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Werkzeuge []mandantWerkzeugItem
|
||||||
|
Filter werkzeugFilter
|
||||||
|
ZweckeOptionen []string
|
||||||
|
Pagination pagination
|
||||||
|
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
|
||||||
|
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag, sowie
|
||||||
|
// die eigenen (mandantenspezifischen) Katalogeinträge mit Bearbeiten/
|
||||||
|
// Löschen.
|
||||||
|
func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
zentral, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sperren, err := s.store.ListWerkzeugSperrenForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eigene, err := s.store.ListWerkzeugeForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Werkzeuge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gesperrt := make(map[string]bool, len(sperren))
|
||||||
|
for _, sp := range sperren {
|
||||||
|
gesperrt[sp.WerkzeugID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := werkzeugFilterFromRequest(r)
|
||||||
|
var gefiltert []store.Werkzeug
|
||||||
|
for _, wz := range zentral {
|
||||||
|
if filter.matches(wz) {
|
||||||
|
gefiltert = append(gefiltert, wz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
seite := paginate(gefiltert, pag)
|
||||||
|
|
||||||
|
data := mandantWerkzeugListeData{
|
||||||
|
Title: "Werkzeugkatalog", Nav: navFor(r),
|
||||||
|
Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag,
|
||||||
|
}
|
||||||
|
for _, wz := range seite {
|
||||||
|
label, css := pruefStatus(wz.LetztePruefung)
|
||||||
|
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
||||||
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard,
|
||||||
|
Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage),
|
||||||
|
GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
PruefStatusLabel: label, PruefStatusCSS: css,
|
||||||
|
Gesperrt: gesperrt[wz.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, wz := range eigene {
|
||||||
|
if wz.AccountID == nil {
|
||||||
|
continue // zentral, oben schon gelistet
|
||||||
|
}
|
||||||
|
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{
|
||||||
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "),
|
||||||
|
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := werkzeugFormData{
|
||||||
|
Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge",
|
||||||
|
LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{},
|
||||||
|
ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{},
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugCreate legt einen mandantenspezifischen
|
||||||
|
// Katalogeintrag an (`werkzeug.account_id` = eigener Account) — sichtbar
|
||||||
|
// nur für diesen Mandanten, verändert den zentralen Katalog nicht.
|
||||||
|
func (s *Server) handleMandantWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugEditForm zeigt einen eigenen Katalogeintrag zum
|
||||||
|
// Bearbeiten. Ein zentraler oder fremder Eintrag ist hier nicht
|
||||||
|
// erreichbar (404) — das bleibt Sache der Plattform bzw. des jeweils
|
||||||
|
// anderen Mandanten.
|
||||||
|
func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge")
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugUpdate speichert Änderungen an einem eigenen
|
||||||
|
// Katalogeintrag.
|
||||||
|
func (s *Server) handleMandantWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||||
|
if errMsg != "" {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, errMsg)
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||||
|
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugDelete entfernt einen eigenen Katalogeintrag.
|
||||||
|
func (s *Server) handleMandantWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||||
|
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||||
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag
|
||||||
|
// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt
|
||||||
|
// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für
|
||||||
|
// diesen einen Mandanten.
|
||||||
|
func (s *Server) handleMandantWerkzeugSperren(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grund := r.FormValue("grund")
|
||||||
|
if _, err := s.store.CreateWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id"), grund); err != nil {
|
||||||
|
http.Error(w, "Werkzeug konnte nicht gesperrt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMandantWerkzeugEntsperren(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.store.DeleteWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id")); err != nil {
|
||||||
|
http.Error(w, "Sperrung konnte nicht aufgehoben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminCanSperrenUndEntsperrenZentralesWerkzeug(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Fatalf("expected the zentrale werkzeug in the list, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{"grund": {"zu teuer"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("sperren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
afterSperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if !strings.Contains(afterSperren.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||||
|
t.Errorf("expected the werkzeug to show as gesperrt, got: %s", afterSperren.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/entsperren", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entsperren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
afterEntsperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||||
|
if strings.Contains(afterEntsperren.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||||
|
t.Errorf("expected the werkzeug to no longer show as gesperrt, got: %s", afterEntsperren.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSperreIstProMandantIsoliert(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
|
||||||
|
postForm(t, s, adminA, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{})
|
||||||
|
|
||||||
|
respB := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge")
|
||||||
|
if strings.Contains(respB.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||||
|
t.Errorf("expected Mandant B to see the werkzeug as unsperrt, got: %s", respB.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessWerkzeugverwaltung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/werkzeuge")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionCookieName = "deklarix_session"
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
userContextKey contextKey = iota
|
||||||
|
impersonatorContextKey
|
||||||
|
)
|
||||||
|
|
||||||
|
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||||
|
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, "", false),
|
||||||
|
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
||||||
|
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
||||||
|
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben. Der
|
||||||
|
// zweite Rückgabewert ist die E-Mail des Betreibers, falls diese Sitzung
|
||||||
|
// durch einen Support-Login entstanden ist (siehe CreateImpersonatedSession),
|
||||||
|
// sonst leer.
|
||||||
|
func (s *Server) authenticate(r *http.Request) (store.User, string, bool) {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return store.User{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
return store.User{}, "", false
|
||||||
|
}
|
||||||
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
|
return store.User{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||||
|
if err != nil || !user.Active {
|
||||||
|
return store.User{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
impersonator := ""
|
||||||
|
if sess.ImpersonatedByUserID != nil {
|
||||||
|
if betreiber, err := s.store.GetUser(r.Context(), *sess.ImpersonatedByUserID); err == nil {
|
||||||
|
impersonator = betreiber.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, impersonator, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuthContext legt Nutzer und ggf. Impersonator-Hinweis in den
|
||||||
|
// Request-Kontext — gemeinsame Hilfsfunktion für alle require*-Middlewares.
|
||||||
|
func withAuthContext(r *http.Request, user store.User, impersonator string) *http.Request {
|
||||||
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
|
if impersonator != "" {
|
||||||
|
ctx = context.WithValue(ctx, impersonatorContextKey, impersonator)
|
||||||
|
}
|
||||||
|
return r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||||
|
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||||
|
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, impersonator, ok := s.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese
|
||||||
|
// werden nur aus einer bereits authentifizierten Seite heraus
|
||||||
|
// aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung
|
||||||
|
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
||||||
|
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, impersonator, ok := s.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
|
||||||
|
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
|
||||||
|
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
|
||||||
|
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
|
||||||
|
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
|
||||||
|
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||||
|
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, impersonator, ok := s.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role != "betreiber" {
|
||||||
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
||||||
|
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
||||||
|
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
|
||||||
|
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
|
||||||
|
// verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
|
||||||
|
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
|
||||||
|
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
|
||||||
|
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
|
||||||
|
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
|
||||||
|
// — ohne das könnte eine frisch registrierte Firma mit nur einem
|
||||||
|
// admin-Login keinen einzigen eingereichten Antrag sehen oder
|
||||||
|
// bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei
|
||||||
|
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
|
||||||
|
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
|
||||||
|
// nicht erfährt, dass es die Seite überhaupt gibt.
|
||||||
|
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, impersonator, ok := s.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
|
||||||
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/
|
||||||
|
// Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei
|
||||||
|
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
||||||
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, impersonator, ok := s.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Role != "admin" {
|
||||||
|
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, withAuthContext(r, user, impersonator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
|
||||||
|
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
|
||||||
|
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||||
|
// Plattform-Link gezeigt werden soll.
|
||||||
|
type navData struct {
|
||||||
|
IsBetreiber bool
|
||||||
|
IsFachebene bool
|
||||||
|
IsAdmin bool
|
||||||
|
Impersonator string // E-Mail des Betreibers bei einem Support-Login, sonst leer
|
||||||
|
}
|
||||||
|
|
||||||
|
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||||
|
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
|
||||||
|
// currentUser).
|
||||||
|
func navFor(r *http.Request) navData {
|
||||||
|
role := currentUser(r).Role
|
||||||
|
return navData{
|
||||||
|
IsBetreiber: role == "betreiber",
|
||||||
|
// admin sieht die Fachebene mit, siehe requireFachebene.
|
||||||
|
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
||||||
|
IsAdmin: role == "admin",
|
||||||
|
Impersonator: currentImpersonator(r),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
||||||
|
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
|
||||||
|
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
|
||||||
|
// kein Laufzeitfall, den man abfangen sollte.
|
||||||
|
func currentUser(r *http.Request) store.User {
|
||||||
|
user, ok := r.Context().Value(userContextKey).(store.User)
|
||||||
|
if !ok {
|
||||||
|
panic("web: currentUser aufgerufen ohne requirePage/requireAPI")
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentImpersonator liefert die E-Mail des Betreibers, falls die
|
||||||
|
// aktuelle Sitzung ein Support-Login ist, sonst einen leeren String.
|
||||||
|
func currentImpersonator(r *http.Request) string {
|
||||||
|
v, _ := r.Context().Value(impersonatorContextKey).(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminKannNutzerDeaktivierenUndReaktivieren(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
target, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("deaktivieren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
deaktiviert, err := fs.GetUser(context.Background(), target.ID)
|
||||||
|
if err != nil || deaktiviert.Active {
|
||||||
|
t.Fatalf("expected user to be inactive, got %+v (err %v)", deaktiviert, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResp := postForm(t, s, nil, "/login", url.Values{"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}})
|
||||||
|
if !strings.Contains(loginResp.Body.String(), "deaktiviert") {
|
||||||
|
t.Errorf("expected a deactivation message on login, got: %s", loginResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
reaktivierenResp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/aktivieren", url.Values{})
|
||||||
|
if reaktivierenResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("aktivieren status = %d, body: %s", reaktivierenResp.Code, reaktivierenResp.Body.String())
|
||||||
|
}
|
||||||
|
reaktiviert, err := fs.GetUser(context.Background(), target.ID)
|
||||||
|
if err != nil || !reaktiviert.Active {
|
||||||
|
t.Fatalf("expected user to be active again, got %+v (err %v)", reaktiviert, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminKannSichNichtSelbstDeaktivieren(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
self, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+self.ID+"/deaktivieren", url.Values{})
|
||||||
|
if resp.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for self-deactivation", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeaktivierterNutzerVerliertLaufendeSitzungSofort(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, admin.AccountID, "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
if resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege"); resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected active session to work, status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{})
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege")
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected the deactivated user's session to be rejected, status = %d", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotDeactivateForeignAccountUser(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||||
|
seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||||
|
targetB, err := fs.GetUserByEmail(context.Background(), "admin-b@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminA, "/verwaltung/nutzer/"+targetB.ID+"/deaktivieren", url.Values{})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for a foreign account's user", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// pagination.go — wiederverwendbare serverseitige Paginierung für
|
||||||
|
// Listen-Seiten (Werkzeugkatalog, Register, ...). Bewusst ohne
|
||||||
|
// JavaScript: Seiten-/Filterwechsel läuft über ein einziges GET-Formular,
|
||||||
|
// das Query-Parameter (q, zweck, page, ...) setzt — siehe CLAUDE.md,
|
||||||
|
// Frontend.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultPerPage = 20
|
||||||
|
|
||||||
|
// pagination beschreibt die aktuelle Seite einer Liste. Templates lesen
|
||||||
|
// daraus Seitenzahl, Gesamtzahl und ob Vor/Zurück-Buttons sichtbar sein
|
||||||
|
// sollen.
|
||||||
|
type pagination struct {
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
Total int
|
||||||
|
TotalPages int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginationFromRequest liest "page" aus der Query (Standard: 1, nie
|
||||||
|
// kleiner 1, nie größer als die letzte tatsächlich vorhandene Seite).
|
||||||
|
// per_page ist bewusst nicht vom Nutzer einstellbar (kein Query-Param
|
||||||
|
// dafür) — eine feste Seitengröße hält die Filter-URLs einfach und
|
||||||
|
// verhindert versehentlich riesige Anfragen.
|
||||||
|
func paginationFromRequest(r *http.Request, total int) pagination {
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
perPage := defaultPerPage
|
||||||
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
return pagination{
|
||||||
|
Page: page, PerPage: perPage, Total: total, TotalPages: totalPages,
|
||||||
|
HasPrev: page > 1, HasNext: page < totalPages,
|
||||||
|
PrevPage: page - 1, NextPage: page + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounds liefert den Start-/End-Index für die aktuelle Seite innerhalb
|
||||||
|
// der (bereits gefilterten) Gesamtliste.
|
||||||
|
func (p pagination) bounds() (start, end int) {
|
||||||
|
start = (p.Page - 1) * p.PerPage
|
||||||
|
end = start + p.PerPage
|
||||||
|
if end > p.Total {
|
||||||
|
end = p.Total
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesQuery prüft, ob q (Freitext-Suche, ohne Beachtung von
|
||||||
|
// Groß-/Kleinschreibung) in mindestens einem der übergebenen Felder
|
||||||
|
// vorkommt — leeres q trifft immer zu. Gemeinsame Basis für die
|
||||||
|
// Freitext-Filter mehrerer Listen-Seiten (Register, Nutzerverwaltung,
|
||||||
|
// Posteingang, ...), damit nicht jede Liste ihre eigene Such-Logik
|
||||||
|
// erfindet.
|
||||||
|
func matchesQuery(q string, felder ...string) bool {
|
||||||
|
if q == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
q = strings.ToLower(q)
|
||||||
|
for _, f := range felder {
|
||||||
|
if strings.Contains(strings.ToLower(f), q) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginate schneidet den Ausschnitt für die aktuelle Seite aus einer
|
||||||
|
// bereits gefilterten/sortierten Liste heraus — generisch, damit jede
|
||||||
|
// Listen-Seite (Werkzeuge, Register, ...) dieselbe Funktion nutzt statt
|
||||||
|
// eigene Slice-Arithmetik zu wiederholen.
|
||||||
|
func paginate[T any](items []T, p pagination) []T {
|
||||||
|
start, end := p.bounds()
|
||||||
|
return items[start:end]
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Ebene 3 — Register und Export (Schritt 6 der Baureihenfolge, siehe
|
||||||
|
// CLAUDE.md). Jede Genehmigung erzeugt automatisch einen Registereintrag
|
||||||
|
// (siehe handleFallEntscheiden); diese Seiten zeigen und exportieren
|
||||||
|
// sie. Reuse des bereits vorhandenen (bisher ungenutzten) go-pdf/fpdf-
|
||||||
|
// Dependency statt einer neuen Bibliothek für den PDF-Export.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-pdf/fpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type registerEintragView struct {
|
||||||
|
Zweck string
|
||||||
|
Abteilung string
|
||||||
|
Werkzeug string
|
||||||
|
Datenklasse string
|
||||||
|
Einstufung string
|
||||||
|
Auflagen string
|
||||||
|
Verantwortlicher string
|
||||||
|
EntschiedenAm string
|
||||||
|
GueltigBis string
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Eintraege []registerEintragView
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) {
|
||||||
|
eintraege, err := s.store.ListRegistereintraegeForAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]registerEintragView, len(eintraege))
|
||||||
|
for i, e := range eintraege {
|
||||||
|
gueltigBis := ""
|
||||||
|
if e.GueltigBis != nil {
|
||||||
|
gueltigBis = e.GueltigBis.Format("02.01.2006")
|
||||||
|
}
|
||||||
|
views[i] = registerEintragView{
|
||||||
|
Zweck: e.Zweck, Abteilung: e.Abteilung, Werkzeug: e.Werkzeug,
|
||||||
|
Datenklasse: e.Datenklasse, Einstufung: e.Einstufung, Auflagen: strings.Join(e.Auflagen, "; "),
|
||||||
|
Verantwortlicher: e.Verantwortlicher, EntschiedenAm: e.EntschiedenAm.Format("02.01.2006 15:04"), GueltigBis: gueltigBis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterListe zeigt das Register (alle Genehmigungen des
|
||||||
|
// Mandanten) — Ebene 3, für verantwortlicher und pruefer gleichermaßen
|
||||||
|
// lesbar.
|
||||||
|
func (s *Server) handleRegisterListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
views, err := s.registerEintraegeFuerAccount(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []registerEintragView
|
||||||
|
for _, e := range views {
|
||||||
|
if matchesQuery(q, e.Zweck, e.Abteilung, e.Werkzeug, e.Verantwortlicher) {
|
||||||
|
gefiltert = append(gefiltert, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := registerListeData{
|
||||||
|
Title: "Register", Nav: navFor(r), Eintraege: paginate(gefiltert, pag),
|
||||||
|
Suche: q, Pagination: pag,
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var registerCSVHeader = []string{"Zweck", "Abteilung", "Werkzeug", "Datenklasse", "Einstufung", "Auflagen", "Verantwortliche/r", "Entschieden am", "Gültig bis"}
|
||||||
|
|
||||||
|
func registerCSVRow(e registerEintragView) []string {
|
||||||
|
return []string{e.Zweck, e.Abteilung, e.Werkzeug, e.Datenklasse, e.Einstufung, e.Auflagen, e.Verantwortlicher, e.EntschiedenAm, e.GueltigBis}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterExportCSV exportiert das Register als CSV.
|
||||||
|
func (s *Server) handleRegisterExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
views, err := s.registerEintraegeFuerAccount(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="register.csv"`)
|
||||||
|
cw := csv.NewWriter(w)
|
||||||
|
_ = cw.Write(registerCSVHeader)
|
||||||
|
for _, e := range views {
|
||||||
|
_ = cw.Write(registerCSVRow(e))
|
||||||
|
}
|
||||||
|
cw.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterExportPDF exportiert das Register als PDF — eine
|
||||||
|
// einfache Tabelle, keine Gestaltung über den Design-System-Anspruch
|
||||||
|
// des restlichen Produkts hinaus.
|
||||||
|
func (s *Server) handleRegisterExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||||
|
views, err := s.registerEintraegeFuerAccount(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf := fpdf.New("L", "mm", "A4", "")
|
||||||
|
// cp1252 deckt deutsche Umlaute/ß ab — die eingebauten Helvetica-
|
||||||
|
// Fonts von fpdf können sonst kein UTF-8 darstellen.
|
||||||
|
tr := pdf.UnicodeTranslatorFromDescriptor("")
|
||||||
|
pdf.AddPage()
|
||||||
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
|
pdf.CellFormat(0, 10, tr("Register"), "", 1, "L", false, 0, "")
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
|
colWidths := []float64{45, 25, 35, 30, 30, 55, 40, 30, 25}
|
||||||
|
pdf.SetFont("Helvetica", "B", 8)
|
||||||
|
for i, h := range registerCSVHeader {
|
||||||
|
pdf.CellFormat(colWidths[i], 8, tr(h), "1", 0, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(-1)
|
||||||
|
|
||||||
|
pdf.SetFont("Helvetica", "", 8)
|
||||||
|
for _, e := range views {
|
||||||
|
row := registerCSVRow(e)
|
||||||
|
for i, cell := range row {
|
||||||
|
pdf.CellFormat(colWidths[i], 8, tr(cell), "1", 0, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="register.pdf"`)
|
||||||
|
if err := pdf.Output(w); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("PDF konnte nicht erzeugt werden: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genehmigeFall reicht einen Antrag ein und genehmigt ihn mit einem
|
||||||
|
// echten, zulässigen Werkzeug — Vorbereitung für Register-Tests, die
|
||||||
|
// einen tatsächlichen Registereintrag brauchen.
|
||||||
|
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Register-Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||||
|
if createResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
if fs.antraege[id].AccountID == acc.ID {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher")
|
||||||
|
entscheidenResp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
|
||||||
|
})
|
||||||
|
if entscheidenResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Entscheiden: status = %d, body: %s", entscheidenResp.Code, entscheidenResp.Body.String())
|
||||||
|
}
|
||||||
|
return acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenehmigungErzeugtRegistereintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID := genehmigeFall(t, fs, s)
|
||||||
|
|
||||||
|
list := fs.registereintraege[accountID]
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("registereintraege = %+v, want exactly one entry", list)
|
||||||
|
}
|
||||||
|
e := list[0]
|
||||||
|
if e.Werkzeug != "ChatGPT Enterprise" || e.Zweck != "Angebotstexte generieren" || e.Datenklasse != "personenbezogen" {
|
||||||
|
t.Errorf("Registereintrag = %+v, unerwartete Werte", e)
|
||||||
|
}
|
||||||
|
if e.Verantwortlicher != "verantwortlicher@example.com" {
|
||||||
|
t.Errorf("Verantwortlicher = %q, want verantwortlicher@example.com", e.Verantwortlicher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbgelehnteEntscheidungErzeugtKeinenRegistereintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug verfügbar"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, list := range fs.registereintraege {
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("expected no registereintrag for an abgelehnt Entscheidung, got %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterListeZeigtGenehmigung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID := genehmigeFall(t, fs, s)
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the genehmigtes werkzeug in the register, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterExportCSVEnthaeltEintrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID := genehmigeFall(t, fs, s)
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.csv")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if ct := resp.Header().Get("Content-Type"); !strings.Contains(ct, "text/csv") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/csv", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||||
|
t.Errorf("expected the werkzeug in the CSV export, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterExportPDFReturnsPDF(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID := genehmigeFall(t, fs, s)
|
||||||
|
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.pdf")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if ct := resp.Header().Get("Content-Type"); ct != "application/pdf" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/pdf", ct)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(resp.Body.String(), "%PDF") {
|
||||||
|
t.Error("expected the response body to start with the PDF magic bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessRegister(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/registereintraege")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter on /registereintraege", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
|
||||||
|
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
|
||||||
|
// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies
|
||||||
|
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
|
||||||
|
// Account — siehe middleware.go.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.html
|
||||||
|
var templatesFS embed.FS
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
|
||||||
|
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
|
||||||
|
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||||
|
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||||
|
type Store interface {
|
||||||
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||||
|
UpdateAccount(ctx context.Context, id, name string) (store.Account, error)
|
||||||
|
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||||
|
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||||
|
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
||||||
|
ListAccounts(ctx context.Context) ([]store.Account, error)
|
||||||
|
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
||||||
|
GetUser(ctx context.Context, id string) (store.User, error)
|
||||||
|
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||||
|
SetUserActive(ctx context.Context, id string, active bool) error
|
||||||
|
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||||
|
CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error)
|
||||||
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||||
|
DeleteSession(ctx context.Context, token string) error
|
||||||
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||||
|
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
||||||
|
|
||||||
|
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
|
||||||
|
CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error)
|
||||||
|
DeleteAbteilung(ctx context.Context, id string) error
|
||||||
|
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
|
||||||
|
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
|
||||||
|
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
|
||||||
|
SetAntragStatus(ctx context.Context, id, status string) error
|
||||||
|
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
|
||||||
|
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
|
||||||
|
|
||||||
|
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
||||||
|
ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error)
|
||||||
|
CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error)
|
||||||
|
DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error
|
||||||
|
ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error)
|
||||||
|
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
||||||
|
CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error)
|
||||||
|
UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error)
|
||||||
|
DeleteWerkzeug(ctx context.Context, id string) error
|
||||||
|
CurrentKatalogVersion(ctx context.Context) (string, error)
|
||||||
|
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
|
||||||
|
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
|
||||||
|
|
||||||
|
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
|
||||||
|
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
|
||||||
|
ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error)
|
||||||
|
|
||||||
|
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
||||||
|
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
||||||
|
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||||
|
// Bewertung eines Antrags braucht (siehe internal/rules). Ein eigener
|
||||||
|
// Typ statt drei einzelne Server-Felder, damit main.go sie an einer
|
||||||
|
// Stelle zusammenstellt.
|
||||||
|
type Regelwerk struct {
|
||||||
|
Datenklasse rules.DatenklasseRegelwerk
|
||||||
|
Einstufung rules.EinstufungRegelwerk
|
||||||
|
Anforderungen rules.AnforderungsRegelwerk
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
||||||
|
type Server struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
store Store
|
||||||
|
regelwerk Regelwerk
|
||||||
|
templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||||
|
// wird einmal beim Start geladen, nicht pro Request.
|
||||||
|
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||||
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
store: st,
|
||||||
|
regelwerk: regelwerk,
|
||||||
|
templates: tmpl,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
|
mux.HandleFunc("GET /register", s.handleRegisterForm)
|
||||||
|
mux.HandleFunc("POST /register", s.handleRegister)
|
||||||
|
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
|
||||||
|
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
||||||
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||||
|
mux.HandleFunc("POST /login", s.handleLogin)
|
||||||
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||||
|
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
|
||||||
|
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
|
||||||
|
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
|
||||||
|
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
|
||||||
|
mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe))
|
||||||
|
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
|
||||||
|
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden))
|
||||||
|
mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe))
|
||||||
|
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
|
||||||
|
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
|
||||||
|
mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
|
||||||
|
mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
|
||||||
|
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
|
||||||
|
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
|
||||||
|
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
|
||||||
|
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
||||||
|
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
||||||
|
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))
|
||||||
|
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
|
||||||
|
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
|
||||||
|
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||||
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
|
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountCreate))
|
||||||
|
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{id}/umbenennen", s.requireBetreiber(s.handleBetreiberAccountRename))
|
||||||
|
mux.HandleFunc("POST /betreiber/accounts/{accountID}/nutzer/{userID}/anmelden-als", s.requireBetreiber(s.handleBetreiberLoginAls))
|
||||||
|
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||||
|
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
||||||
|
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate))
|
||||||
|
mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm))
|
||||||
|
mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate))
|
||||||
|
mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete))
|
||||||
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
|
s.mux = mux
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP macht Server zu einem http.Handler.
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,971 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testSessionCookie = "deklarix_session"
|
||||||
|
|
||||||
|
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
|
||||||
|
// Handler-Tests keine echte Postgres-Instanz brauchen.
|
||||||
|
type fakeStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
nextID int
|
||||||
|
accounts map[string]store.Account
|
||||||
|
users map[string]store.User
|
||||||
|
usersByEmail map[string]string // email -> user id
|
||||||
|
sessions map[string]store.Session
|
||||||
|
auditLog []store.AuditEntry
|
||||||
|
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
|
||||||
|
antraege map[string]store.Antrag
|
||||||
|
werkzeuge map[string]store.Werkzeug
|
||||||
|
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
||||||
|
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||||
|
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
|
||||||
|
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeStore() *fakeStore {
|
||||||
|
return &fakeStore{
|
||||||
|
accounts: map[string]store.Account{},
|
||||||
|
users: map[string]store.User{},
|
||||||
|
usersByEmail: map[string]string{},
|
||||||
|
sessions: map[string]store.Session{},
|
||||||
|
abteilungen: map[string][]store.Abteilung{},
|
||||||
|
antraege: map[string]store.Antrag{},
|
||||||
|
werkzeuge: map[string]store.Werkzeug{},
|
||||||
|
bewertungen: map[string][]store.Bewertung{},
|
||||||
|
entscheidungen: map[string][]store.Entscheidung{},
|
||||||
|
registereintraege: map[string][]store.Registereintrag{},
|
||||||
|
werkzeugSperren: map[string][]store.WerkzeugSperre{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) newID() string {
|
||||||
|
f.nextID++
|
||||||
|
return fmt.Sprintf("id-%d", f.nextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
id := f.newID()
|
||||||
|
acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()}
|
||||||
|
f.accounts[acc.ID] = acc
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc, ok := f.accounts[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Account{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
acc.Name = name
|
||||||
|
f.accounts[id] = acc
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, acc := range f.accounts {
|
||||||
|
if acc.EinladungToken == token {
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.Account{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc, ok := f.accounts[accountID]
|
||||||
|
if !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
acc.EinladungToken = newToken
|
||||||
|
f.accounts[accountID] = acc
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc, ok := f.accounts[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Account{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Account
|
||||||
|
for _, acc := range f.accounts {
|
||||||
|
out = append(out, acc)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, exists := f.usersByEmail[email]; exists {
|
||||||
|
return store.User{}, fmt.Errorf("fakeStore: email %s bereits vergeben", email)
|
||||||
|
}
|
||||||
|
u := store.User{
|
||||||
|
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
|
||||||
|
Role: role, Active: true, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.users[u.ID] = u
|
||||||
|
f.usersByEmail[email] = u.ID
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
id, ok := f.usersByEmail[email]
|
||||||
|
if !ok {
|
||||||
|
return store.User{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return f.users[id], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
u, ok := f.users[id]
|
||||||
|
if !ok {
|
||||||
|
return store.User{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
u, ok := f.users[id]
|
||||||
|
if !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
u.Active = active
|
||||||
|
f.users[id] = u
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.User
|
||||||
|
for _, u := range f.users {
|
||||||
|
if u.AccountID == accountID {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess := store.Session{Token: token, UserID: userID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||||
|
f.sessions[token] = sess
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess := store.Session{Token: token, UserID: userID, ImpersonatedByUserID: &impersonatedByUserID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||||
|
f.sessions[token] = sess
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess, ok := f.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return store.Session{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.sessions, token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
e := store.AuditEntry{
|
||||||
|
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
|
||||||
|
TargetID: targetID, Details: details, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.auditLog = append(f.auditLog, e)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out := make([]store.AuditEntry, len(f.auditLog))
|
||||||
|
for i, e := range f.auditLog {
|
||||||
|
out[len(f.auditLog)-1-i] = e
|
||||||
|
}
|
||||||
|
if len(out) > limit {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.abteilungen[accountID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for _, list := range f.abteilungen {
|
||||||
|
for _, a := range list {
|
||||||
|
if a.ID == id {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.Abteilung{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
a := store.Abteilung{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()}
|
||||||
|
f.abteilungen[accountID] = append(f.abteilungen[accountID], a)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteAbteilung(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for accID, list := range f.abteilungen {
|
||||||
|
for i, a := range list {
|
||||||
|
if a.ID == id {
|
||||||
|
f.abteilungen[accID] = append(list[:i], list[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
a := store.Antrag{
|
||||||
|
ID: f.newID(), AccountID: accountID, ErstellerUserID: erstellerUserID, AbteilungID: abteilungID,
|
||||||
|
Titel: titel, Status: "entwurf", Antworten: []byte(`{}`), CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.antraege[a.ID] = a
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetAntrag(ctx context.Context, id string) (store.Antrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
a, ok := f.antraege[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Antrag{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
a, ok := f.antraege[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Antrag{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
a.Titel, a.Beschreibung, a.Ergebnis, a.Haeufigkeit, a.Antworten = titel, beschreibung, ergebnis, haeufigkeit, antworten
|
||||||
|
a.UpdatedAt = time.Now()
|
||||||
|
f.antraege[id] = a
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
a, ok := f.antraege[id]
|
||||||
|
if !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
a.Status = status
|
||||||
|
f.antraege[id] = a
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Antrag
|
||||||
|
for _, a := range f.antraege {
|
||||||
|
if a.ErstellerUserID == erstellerUserID {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Antrag
|
||||||
|
for _, a := range f.antraege {
|
||||||
|
if a.AccountID == accountID {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Werkzeug
|
||||||
|
for _, w := range f.werkzeuge {
|
||||||
|
if w.AccountID == nil || *w.AccountID == accountID {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Werkzeug
|
||||||
|
for _, w := range f.werkzeuge {
|
||||||
|
if w.AccountID == nil {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sp := store.WerkzeugSperre{ID: f.newID(), AccountID: accountID, WerkzeugID: werkzeugID, Grund: grund, GesperrtAm: time.Now()}
|
||||||
|
f.werkzeugSperren[accountID] = append(f.werkzeugSperren[accountID], sp)
|
||||||
|
return sp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
list := f.werkzeugSperren[accountID]
|
||||||
|
for i, sp := range list {
|
||||||
|
if sp.WerkzeugID == werkzeugID {
|
||||||
|
f.werkzeugSperren[accountID] = append(list[:i], list[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.werkzeugSperren[accountID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w := store.Werkzeug{
|
||||||
|
ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter,
|
||||||
|
Verarbeitungslaender: in.Verarbeitungslaender, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL,
|
||||||
|
TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, DPFZertifiziert: in.DPFZertifiziert, AufbewahrungTage: in.AufbewahrungTage,
|
||||||
|
Zertifizierungen: in.Zertifizierungen, Subprozessoren: in.Subprozessoren, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen,
|
||||||
|
LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.werkzeuge[w.ID] = w
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w, ok := f.werkzeuge[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Werkzeug{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
w.Name, w.Anbieter, w.Verarbeitungslaender = in.Name, in.Anbieter, in.Verarbeitungslaender
|
||||||
|
w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL
|
||||||
|
w.TrainingOptOut, w.TrainingStandard, w.DPFZertifiziert, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage
|
||||||
|
w.Zertifizierungen, w.Subprozessoren, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen
|
||||||
|
w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now()
|
||||||
|
f.werkzeuge[id] = w
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.werkzeuge[id]; !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.werkzeuge, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w, ok := f.werkzeuge[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Werkzeug{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return fmt.Sprintf("fake-%d", len(f.werkzeuge)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
b := store.Bewertung{
|
||||||
|
ID: f.newID(), AntragID: in.AntragID,
|
||||||
|
Datenklasse: in.Datenklasse, DatenklasseHerleitung: in.DatenklasseHerleitung,
|
||||||
|
Einstufung: in.Einstufung, EinstufungHerleitung: in.EinstufungHerleitung,
|
||||||
|
Verboten: in.Verboten, Anforderungen: in.Anforderungen,
|
||||||
|
ZulaessigeWerkzeuge: in.ZulaessigeWerkzeuge, AusgeschlosseneWerkzeuge: in.AusgeschlosseneWerkzeuge,
|
||||||
|
RegelwerkVersion: in.RegelwerkVersion, KatalogVersion: in.KatalogVersion,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.bewertungen[in.AntragID] = append(f.bewertungen[in.AntragID], b)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
bs := f.bewertungen[antragID]
|
||||||
|
if len(bs) == 0 {
|
||||||
|
return store.Bewertung{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return bs[len(bs)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
e := store.Entscheidung{
|
||||||
|
ID: f.newID(), AntragID: in.AntragID, BewertungID: in.BewertungID, EntscheiderUserID: in.EntscheiderUserID,
|
||||||
|
Entscheidung: in.Entscheidung, WerkzeugID: in.WerkzeugID, WerkzeugSnapshot: in.WerkzeugSnapshot,
|
||||||
|
Begruendung: in.Begruendung, GueltigBis: in.GueltigBis, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.entscheidungen[in.AntragID] = append(f.entscheidungen[in.AntragID], e)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
es := f.entscheidungen[antragID]
|
||||||
|
if len(es) == 0 {
|
||||||
|
return store.Entscheidung{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return es[len(es)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Entscheidung
|
||||||
|
for antragID, a := range f.antraege {
|
||||||
|
if a.AccountID != accountID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
es := f.entscheidungen[antragID]
|
||||||
|
if len(es) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
latest := es[len(es)-1]
|
||||||
|
if latest.Entscheidung == "genehmigt" || latest.Entscheidung == "genehmigt_mit_auflagen" {
|
||||||
|
out = append(out, latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
e := store.Registereintrag{
|
||||||
|
ID: f.newID(), AccountID: in.AccountID, AntragID: in.AntragID, EntscheidungID: in.EntscheidungID,
|
||||||
|
Zweck: in.Zweck, Abteilung: in.Abteilung, Werkzeug: in.Werkzeug,
|
||||||
|
Datenklasse: in.Datenklasse, Einstufung: in.Einstufung, Auflagen: in.Auflagen,
|
||||||
|
Verantwortlicher: in.Verantwortlicher, EntschiedenAm: in.EntschiedenAm, GueltigBis: in.GueltigBis,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.registereintraege[in.AccountID] = append(f.registereintraege[in.AccountID], e)
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.registereintraege[accountID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
||||||
|
// die auch main.go beim Start lädt. Handler-Tests laufen so gegen das
|
||||||
|
// tatsächliche Regelwerk statt gegen ein Test-Fixture, das getrennt
|
||||||
|
// von rules/ gepflegt werden müsste.
|
||||||
|
func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
||||||
|
t.Helper()
|
||||||
|
fsys := os.DirFS("../../rules")
|
||||||
|
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 web.Regelwerk{Datenklasse: dk, Einstufung: ei, Anforderungen: an}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||||
|
t.Helper()
|
||||||
|
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
|
||||||
|
// Account, einen Nutzer und eine gültige Sitzung an und liefert das
|
||||||
|
// Session-Cookie.
|
||||||
|
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := fs.CreateAccount(ctx, accountName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword("test-passwort-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
user, err := fs.CreateUser(ctx, acc.ID, email, hash, role)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: testSessionCookie, Value: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedUserInAccount legt einen weiteren Nutzer in einem bereits
|
||||||
|
// existierenden Account an — für Tests, die mehrere Rollen im selben
|
||||||
|
// Mandanten brauchen (z. B. mitarbeiter stellt einen Antrag,
|
||||||
|
// verantwortlicher entscheidet darüber).
|
||||||
|
func seedUserInAccount(t *testing.T, fs *fakeStore, accountID, email, role string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
hash, err := auth.HashPassword("test-passwort-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
user, err := fs.CreateUser(ctx, accountID, email, hash, role)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: testSessionCookie, Value: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
|
||||||
|
t.Helper()
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
return s, fs, cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
|
||||||
|
|
||||||
|
func TestHandleHealth(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.String() != `{"ok":true}` {
|
||||||
|
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStaticServesHTMX(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.Len() < 1000 {
|
||||||
|
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests: Auth (Registrierung, Login, Logout, Zugriffsschutz) ───────
|
||||||
|
|
||||||
|
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
|
||||||
|
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||||
|
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
|
||||||
|
"password": {"ein-sicheres-passwort"},
|
||||||
|
})
|
||||||
|
if regResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||||
|
}
|
||||||
|
cookies := regResp.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("expected a session cookie to be set after registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
indexResp := getWithCookie(t, s, cookies[0], "/")
|
||||||
|
if indexResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var created store.User
|
||||||
|
for _, u := range fs.users {
|
||||||
|
created = u
|
||||||
|
}
|
||||||
|
if created.Role != "admin" {
|
||||||
|
t.Fatalf("expected the first user of a new company to have role admin, got %q", created.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
form := url.Values{
|
||||||
|
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
||||||
|
"password": {"ein-sicheres-passwort"},
|
||||||
|
}
|
||||||
|
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("first register status = %d, want 303", resp.Code)
|
||||||
|
}
|
||||||
|
resp := postForm(t, s, nil, "/register", form)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second register status = %d, want 200 (re-rendered form with error)", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "schon vergeben") {
|
||||||
|
t.Errorf("expected a duplicate-email error, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginWithCorrectPassword(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
hash, err := auth.HashPassword("richtiges-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "mitarbeiter"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"bestehend@example.com"}, "password": {"richtiges-passwort"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if len(resp.Result().Cookies()) == 0 {
|
||||||
|
t.Fatal("expected a session cookie after successful login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
hash, err := auth.HashPassword("admin-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "betreiber@example.com", hash, "betreiber"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"betreiber@example.com"}, "password": {"admin-passwort"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if loc := resp.Header().Get("Location"); loc != "/betreiber" {
|
||||||
|
t.Fatalf("Location = %q, want /betreiber", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
hash, err := auth.HashPassword("richtiges-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
acc, _ := fs.CreateAccount(context.Background(), "X")
|
||||||
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"x@example.com"}, "password": {"falsch"}})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (re-rendered login form)", resp.Code)
|
||||||
|
}
|
||||||
|
if len(resp.Result().Cookies()) != 0 {
|
||||||
|
t.Fatal("expected no session cookie for a failed login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
|
||||||
|
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
|
||||||
|
t.Errorf("expected the generic invalid-credentials message, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
|
||||||
|
}
|
||||||
|
if loc := w.Header().Get("Location"); loc != "/login" {
|
||||||
|
t.Fatalf("Location = %q, want /login", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogoutClearsSession(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("index before logout = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
logoutResp := postForm(t, s, cookie, "/logout", url.Values{})
|
||||||
|
if logoutResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("logout status = %d, want 303", logoutResp.Code)
|
||||||
|
}
|
||||||
|
if _, err := fs.GetSession(context.Background(), cookie.Value); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatal("expected the session to be deleted from the store after logout")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("index after logout = %d, want 303 redirect (session no longer valid)", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleIndexRendersForMitarbeiter(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests: Plattform-Bereich (nur Betreiber) ─────────────────────────
|
||||||
|
|
||||||
|
func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log", "/betreiber/werkzeuge"} {
|
||||||
|
resp := getWithCookie(t, s, cookie, path)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberRoutesRedirectToLoginWithoutSession(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t)
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, nil, "/betreiber")
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberDashboardAccessibleForBetreiber(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||||
|
seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Betreiber"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("expected %q in the betreiber account list, got: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberAccountDetailShowsUsers(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
seedAccount(t, fs, "Mandant A", "mitarbeiter-a@example.com")
|
||||||
|
|
||||||
|
var accID string
|
||||||
|
for id, acc := range fs.accounts {
|
||||||
|
if acc.Name == "Mandant A" {
|
||||||
|
accID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+accID)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "mitarbeiter-a@example.com") {
|
||||||
|
t.Errorf("expected the user's email on the account detail page, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetreiberAuditLogShowsEntries(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||||
|
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateAuditEntry(context.Background(), betreiber.ID, "werkzeug.aktualisiert", "werkzeug", "w1", "manuell geprüft"); err != nil {
|
||||||
|
t.Fatalf("CreateAuditEntry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/audit-log")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "werkzeug.aktualisiert") {
|
||||||
|
t.Errorf("expected the audit entry on the audit log page, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user