Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14516b4adf | ||
|
|
3ea2489f45 | ||
|
|
f7863fd4d2 | ||
|
|
3d20d86a4f | ||
|
|
e4793303fc |
@@ -1,4 +1,2 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
web/*/node_modules/
|
|
||||||
web/*/.next/
|
|
||||||
|
|||||||
@@ -64,9 +64,6 @@ Keine Änderungen ermittelbar.
|
|||||||
## 2026-08-27 17:28 – 17:29 (1m)
|
## 2026-08-27 17:28 – 17:29 (1m)
|
||||||
**Beschreibung:** Claude Code Session
|
**Beschreibung:** Claude Code Session
|
||||||
**Projekt:** code
|
**Projekt:** code
|
||||||
## 2026-08-28 21:44 – 21:44 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** nexarch
|
|
||||||
|
|
||||||
### Commits
|
### Commits
|
||||||
Keine Commits in dieser Session.
|
Keine Commits in dieser Session.
|
||||||
@@ -130,32 +127,5 @@ Keine Commits in dieser Session.
|
|||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||||
- internal/db/db.go | 11 +++++++++++
|
- internal/db/db.go | 11 +++++++++++
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/index.ts | 25 +++++++++++++++++++++++++
|
|
||||||
- web/shl/package.json | 23 +++++++++++++++++++++++
|
|
||||||
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- web/shl/tsconfig.json | 18 ++++++++++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-28 21:51 – 21:57 (5m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** nexarch
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- web/shl/package.json | 2 ++
|
|
||||||
- web/shl/vitest.config.ts | 8 ++++++++
|
|
||||||
- web/shl/vitest.setup.ts | 1 +
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
// auditlog-devserver stellt AUD-03s ExportHandler (internal/audit) fuer die
|
|
||||||
// Next.js-Audit-Log-Ansicht (AUD-04) bereit. Getrennt von cmd/core aus
|
|
||||||
// demselben Grund wie die anderen *-devserver (siehe LIC-04/TEN-05): echte
|
|
||||||
// Auth/RBAC ist noch nicht in die zentrale Server-Topologie verdrahtet.
|
|
||||||
//
|
|
||||||
// Authorize wird hier mit einem geteilten Admin-Token ueber
|
|
||||||
// crypto/subtle.ConstantTimeCompare umgesetzt — demselben Timing-safe-Muster
|
|
||||||
// wie internal/audit.timingsafe (AUD-02), NICHT ueber eine neue
|
|
||||||
// Rollen-/Rechteschicht, da diese Kachel ausdruecklich nur von AUD-03
|
|
||||||
// abhaengt und keine Rechteverwaltung duplizieren soll.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/auditadmin"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
|
|
||||||
if dsn == "" {
|
|
||||||
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
|
|
||||||
}
|
|
||||||
adminToken := os.Getenv("NEXARCH_AUDIT_ADMIN_TOKEN")
|
|
||||||
if adminToken == "" {
|
|
||||||
log.Fatal("NEXARCH_AUDIT_ADMIN_TOKEN nicht gesetzt")
|
|
||||||
}
|
|
||||||
addr := os.Getenv("NEXARCH_AUDITLOG_LISTEN_ADDR")
|
|
||||||
if addr == "" {
|
|
||||||
addr = ":8083"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
pool, err := db.Connect(ctx, dsn)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("db: %v", err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
auditLog := audit.NewLog(pool)
|
|
||||||
tokenAuthorize := auditadmin.NewTokenAuthorizer(adminToken)
|
|
||||||
authorize := func(_ context.Context, caller string) bool { return tokenAuthorize(caller) }
|
|
||||||
handler := audit.NewExportHandler(auditLog, authorize)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/audit/export", withCORS(handler.Export))
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
||||||
|
|
||||||
log.Printf("auditlog-devserver listening on %s", addr)
|
|
||||||
log.Fatal(http.ListenAndServe(addr, mux))
|
|
||||||
}
|
|
||||||
|
|
||||||
func withCORS(next http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+10
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -34,12 +35,20 @@ func main() {
|
|||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
tenantHandler := tenant.NewHandler(provisioner)
|
||||||
|
|
||||||
|
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
||||||
|
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
||||||
|
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
|
||||||
|
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
|
||||||
|
superadmins := user.NewSuperadminStore(registryPool)
|
||||||
|
userHandler := user.NewHandler(nil, superadmins)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
|
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||||
|
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
||||||
|
|
||||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ module gitea.perlbach24.de/scripte/nexarch
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.6.0
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0
|
||||||
|
golang.org/x/crypto v0.17.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
golang.org/x/crypto v0.17.0 // indirect
|
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package apiserver
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const requestContextKey contextKey = iota
|
||||||
|
|
||||||
|
// RequestContext ist der Tenant-/Benutzerkontext, den die Middleware-Kette
|
||||||
|
// fuer nachgelagerte Handler bereitstellt (Akzeptanzkriterium 3).
|
||||||
|
type RequestContext struct {
|
||||||
|
UserID string
|
||||||
|
TenantSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromContext liest den von der Middleware gesetzten Kontext.
|
||||||
|
func FromContext(ctx context.Context) (RequestContext, bool) {
|
||||||
|
rc, ok := ctx.Value(requestContextKey).(RequestContext)
|
||||||
|
return rc, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Package apiserver implementiert Core API-01: das REST-Grundgerüst mit
|
||||||
|
// URL-Versionierung, einheitlichem Fehlerformat und Middleware-Kette
|
||||||
|
// (Auth, Tenant-/Benutzerkontext, Logging).
|
||||||
|
package apiserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errorBody ist das EINE Fehlerschema fuer alle Endpunkte unter /api/{version}/
|
||||||
|
// (Akzeptanzkriterium 2).
|
||||||
|
type errorBody struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError schreibt einen Fehler im einheitlichen Schema. code ist ein
|
||||||
|
// stabiler, maschinenlesbarer Bezeichner (z.B. "unauthenticated"), message
|
||||||
|
// ein fuer Menschen lesbarer deutscher Text.
|
||||||
|
func WriteError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
var body errorBody
|
||||||
|
body.Error.Code = code
|
||||||
|
body.Error.Message = message
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package apiserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authAndTenantContext prueft die Session (wiederverwendet auth.TokenIssuer.Verify
|
||||||
|
// aus IAM-02 — keine zweite JWT-Implementierung) und setzt bei Erfolg
|
||||||
|
// RequestContext fuer nachgelagerte Handler (Akzeptanzkriterium 3). Anders
|
||||||
|
// als auth.RequireAuth (Klartext-Fehler) antwortet diese Middleware im
|
||||||
|
// einheitlichen API-01-Fehlerschema (Akzeptanzkriterium 2), damit ALLE
|
||||||
|
// Endpunkte unter /api/{version}/ dasselbe Format liefern, auch bei
|
||||||
|
// Auth-Fehlern.
|
||||||
|
func authAndTenantContext(issuer *auth.TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(auth.CookieName)
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := issuer.Verify(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rc := RequestContext{UserID: claims.UserID, TenantSlug: claims.TenantSlug}
|
||||||
|
next(w, r.WithContext(context.WithValue(r.Context(), requestContextKey, rc)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusRecorder) WriteHeader(code int) {
|
||||||
|
s.status = code
|
||||||
|
s.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingMiddleware protokolliert jede Anfrage strukturiert.
|
||||||
|
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
start := time.Now()
|
||||||
|
next(rec, r)
|
||||||
|
slog.Info("api-anfrage", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dauer", time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package apiserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server registriert versionierte API-Routen (Akzeptanzkriterium 1: unter
|
||||||
|
// /api/{version}/...) und verdrahtet fuer jede Route dieselbe Middleware-
|
||||||
|
// Kette (Logging -> Auth+Tenantkontext -> Handler).
|
||||||
|
type Server struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
issuer *auth.TokenIssuer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(issuer *auth.TokenIssuer) *Server {
|
||||||
|
return &Server{mux: http.NewServeMux(), issuer: issuer}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle registriert pattern unter der angegebenen Version, z.B.
|
||||||
|
// Handle("v1", "/things", h) -> erreichbar unter /api/v1/things. Verschiedene
|
||||||
|
// Versionen sind unabhaengige Pfade — eine neue Version beeintraechtigt
|
||||||
|
// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3).
|
||||||
|
func (s *Server) Handle(version, pattern string, h http.HandlerFunc) {
|
||||||
|
full := "/api/" + version + pattern
|
||||||
|
s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
|
||||||
|
func (s *Server) HandleV1(pattern string, h http.HandlerFunc) {
|
||||||
|
s.Handle("v1", pattern, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
return s.mux
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package apiserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer() (*Server, *auth.TokenIssuer) {
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
return NewServer(issuer), issuer
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAuthCookie(req *http.Request, token string) *http.Request {
|
||||||
|
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar.
|
||||||
|
func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) {
|
||||||
|
srv, issuer := newTestServer()
|
||||||
|
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern
|
||||||
|
// bei fehlerhafter Anfrage dasselbe Fehlerschema.
|
||||||
|
func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) {
|
||||||
|
srv, _ := newTestServer()
|
||||||
|
srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("%s: status = %d, want 401", path, rec.Code)
|
||||||
|
}
|
||||||
|
var body errorBody
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body.Error.Code == "" || body.Error.Message == "" {
|
||||||
|
t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/
|
||||||
|
// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte.
|
||||||
|
func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) {
|
||||||
|
srv, issuer := newTestServer()
|
||||||
|
|
||||||
|
var gotA, gotB RequestContext
|
||||||
|
srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotA, _ = FromContext(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotB, _ = FromContext(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
token, err := issuer.Issue("user-42", "tenant-x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} {
|
||||||
|
req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: status = %d, want 200", path, rec.Code)
|
||||||
|
}
|
||||||
|
if got.UserID != "user-42" || got.TenantSlug != "tenant-x" {
|
||||||
|
t.Fatalf("%s: request-context unerwartet: %+v", path, *got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen,
|
||||||
|
// ohne v1 zu beeintraechtigen.
|
||||||
|
func TestVersioning_V2DoesNotAffectV1(t *testing.T) {
|
||||||
|
srv, issuer := newTestServer()
|
||||||
|
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("v1-antwort"))
|
||||||
|
})
|
||||||
|
srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("v2-antwort"))
|
||||||
|
})
|
||||||
|
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
|
||||||
|
recV1 := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(recV1, reqV1)
|
||||||
|
if recV1.Body.String() != "v1-antwort" {
|
||||||
|
t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token)
|
||||||
|
recV2 := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(recV2, reqV2)
|
||||||
|
if recV2.Body.String() != "v2-antwort" {
|
||||||
|
t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 nach dem Anlegen von v2 erneut pruefen — unveraendert.
|
||||||
|
reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
|
||||||
|
recV1Again := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(recV1Again, reqV1Again)
|
||||||
|
if recV1Again.Body.String() != "v1-antwort" {
|
||||||
|
t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) {
|
||||||
|
srv, _ := newTestServer()
|
||||||
|
srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
|
||||||
|
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// Package audit implementiert Core AUD-01: das zentrale, vom allgemeinen
|
|
||||||
// Anwendungs-Log getrennte Audit-Datenmodell fuer sicherheits- und
|
|
||||||
// compliancerelevante Ereignisse (wer, was, wann, an welchem Tenant).
|
|
||||||
// Unveraenderlichkeit (Append-only) ist AUD-02, Export/Filter-API ist AUD-03
|
|
||||||
// — dieses Paket liefert nur das Datenmodell und den EINEN zentralen
|
|
||||||
// Schreibpfad (Akzeptanzkriterium 3).
|
|
||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SystemTenant ist der reservierte Tenant-Bezug fuer mandantenuebergreifende
|
|
||||||
// Ereignisse (z.B. Superadmin-Aktionen) — es gibt bewusst KEINEN Weg, ein
|
|
||||||
// Ereignis ganz ohne Tenant-Bezug zu schreiben (Akzeptanzkriterium 2).
|
|
||||||
const SystemTenant = "system"
|
|
||||||
|
|
||||||
var ErrMissingTenant = errors.New("audit: tenant_slug darf nicht leer sein")
|
|
||||||
var ErrMissingActor = errors.New("audit: actor darf nicht leer sein")
|
|
||||||
var ErrMissingAction = errors.New("audit: action darf nicht leer sein")
|
|
||||||
|
|
||||||
// Event ist ein strukturiertes Audit-Ereignis (Akzeptanzkriterium 1: Akteur,
|
|
||||||
// Aktion, Zielobjekt, Zeitpunkt, Tenant).
|
|
||||||
type Event struct {
|
|
||||||
TenantSlug string
|
|
||||||
Actor string
|
|
||||||
Action string
|
|
||||||
Target string
|
|
||||||
Metadata map[string]any
|
|
||||||
OccurredAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log ist der EINE zentrale Schreibpfad fuer Audit-Ereignisse — es gibt
|
|
||||||
// bewusst keine zweite Schreibmoeglichkeit, damit kein Handler versehentlich
|
|
||||||
// direkt in audit_events schreibt und dabei die Validierung umgeht
|
|
||||||
// (Akzeptanzkriterium 3).
|
|
||||||
type Log struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLog(pool *pgxpool.Pool) *Log {
|
|
||||||
return &Log{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record persistiert genau einen Audit-Eintrag. Fehlender Tenant-Bezug wird
|
|
||||||
// bereits hier abgewiesen (klarer Fehler statt Constraint-Verletzung im
|
|
||||||
// Normalfall) — die Datenbank-CHECK-Constraint aus der Migration ist die
|
|
||||||
// zweite, unumgehbare Verteidigungslinie (Akzeptanzkriterium 2 / Pruefung 2).
|
|
||||||
func (l *Log) Record(ctx context.Context, e Event) error {
|
|
||||||
if e.TenantSlug == "" {
|
|
||||||
return ErrMissingTenant
|
|
||||||
}
|
|
||||||
if e.Actor == "" {
|
|
||||||
return ErrMissingActor
|
|
||||||
}
|
|
||||||
if e.Action == "" {
|
|
||||||
return ErrMissingAction
|
|
||||||
}
|
|
||||||
if e.Metadata == nil {
|
|
||||||
e.Metadata = map[string]any{}
|
|
||||||
}
|
|
||||||
metadataJSON, err := json.Marshal(e.Metadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("metadaten serialisieren: %w", err)
|
|
||||||
}
|
|
||||||
if e.OccurredAt.IsZero() {
|
|
||||||
e.OccurredAt = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = l.pool.Exec(ctx, `
|
|
||||||
INSERT INTO audit_events (occurred_at, tenant_slug, actor, action, target, metadata)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
`, e.OccurredAt, e.TenantSlug, e.Actor, e.Action, e.Target, metadataJSON)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("audit-ereignis schreiben: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountByTenant ist eine schlanke Lesehilfe fuer Tests/Diagnose — die
|
|
||||||
// eigentliche Filter-/Export-API ist AUD-03, hier bewusst nicht vorgezogen.
|
|
||||||
func (l *Log) CountByTenant(ctx context.Context, tenantSlug string) (int, error) {
|
|
||||||
var n int
|
|
||||||
if err := l.pool.QueryRow(ctx, `
|
|
||||||
SELECT count(*) FROM audit_events WHERE tenant_slug = $1
|
|
||||||
`, tenantSlug).Scan(&n); err != nil {
|
|
||||||
return 0, fmt.Errorf("audit-ereignisse zaehlen: %w", err)
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupAuditTest(t *testing.T) (*Log, *pgxpool.Pool, func()) {
|
|
||||||
t.Helper()
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pool: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
|
||||||
actor TEXT NOT NULL CHECK (actor <> ''),
|
|
||||||
action TEXT NOT NULL CHECK (action <> ''),
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return NewLog(pool), pool, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang
|
|
||||||
// (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag.
|
|
||||||
func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
|
|
||||||
log, pool, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := log.Record(ctx, Event{
|
|
||||||
TenantSlug: "test_acme",
|
|
||||||
Actor: "alice@example.com",
|
|
||||||
Action: "iam.login_failed",
|
|
||||||
Target: "user:alice@example.com",
|
|
||||||
Metadata: map[string]any{"reason": "falsches passwort"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("record: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := log.CountByTenant(ctx, "test_acme")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("count: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
var actor, action, target string
|
|
||||||
if err := pool.QueryRow(ctx, `
|
|
||||||
SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme'
|
|
||||||
`).Scan(&actor, &action, &target); err != nil {
|
|
||||||
t.Fatalf("eintrag lesen: %v", err)
|
|
||||||
}
|
|
||||||
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
|
|
||||||
t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird
|
|
||||||
// bereits vom zentralen Schreibpfad abgewiesen.
|
|
||||||
func TestRecord_RejectsMissingTenant(t *testing.T) {
|
|
||||||
log, _, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"})
|
|
||||||
if !errors.Is(err, ErrMissingTenant) {
|
|
||||||
t.Fatalf("erwartet ErrMissingTenant, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT,
|
|
||||||
// der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der
|
|
||||||
// Schutz haengt nicht allein von der Go-Validierung ab.
|
|
||||||
func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) {
|
|
||||||
_, pool, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
_, err := pool.Exec(ctx, `
|
|
||||||
INSERT INTO audit_events (tenant_slug, actor, action, target)
|
|
||||||
VALUES ('', 'alice', 'irgendwas', 'ziel')
|
|
||||||
`)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecord_RejectsMissingActorAndAction(t *testing.T) {
|
|
||||||
log, _, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) {
|
|
||||||
t.Fatalf("erwartet ErrMissingActor, habe %v", err)
|
|
||||||
}
|
|
||||||
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) {
|
|
||||||
t.Fatalf("erwartet ErrMissingAction, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) {
|
|
||||||
log, _, cleanup := setupAuditTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil {
|
|
||||||
t.Fatalf("record mit SystemTenant: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Filter grenzt einen Export ein (Akzeptanzkriterium 1). Leere/Nil-Felder
|
|
||||||
// bedeuten "kein Filter auf diesem Feld".
|
|
||||||
type Filter struct {
|
|
||||||
TenantSlug string
|
|
||||||
Actor string
|
|
||||||
Action string
|
|
||||||
From *time.Time
|
|
||||||
To *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildFilterQuery(f Filter) (string, []any) {
|
|
||||||
query := `SELECT occurred_at, tenant_slug, actor, action, target, metadata FROM audit_events WHERE 1=1`
|
|
||||||
var args []any
|
|
||||||
|
|
||||||
if f.TenantSlug != "" {
|
|
||||||
args = append(args, f.TenantSlug)
|
|
||||||
query += fmt.Sprintf(" AND tenant_slug = $%d", len(args))
|
|
||||||
}
|
|
||||||
if f.Actor != "" {
|
|
||||||
args = append(args, f.Actor)
|
|
||||||
query += fmt.Sprintf(" AND actor = $%d", len(args))
|
|
||||||
}
|
|
||||||
if f.Action != "" {
|
|
||||||
args = append(args, f.Action)
|
|
||||||
query += fmt.Sprintf(" AND action = $%d", len(args))
|
|
||||||
}
|
|
||||||
if f.From != nil {
|
|
||||||
args = append(args, *f.From)
|
|
||||||
query += fmt.Sprintf(" AND occurred_at >= $%d", len(args))
|
|
||||||
}
|
|
||||||
if f.To != nil {
|
|
||||||
args = append(args, *f.To)
|
|
||||||
query += fmt.Sprintf(" AND occurred_at <= $%d", len(args))
|
|
||||||
}
|
|
||||||
query += " ORDER BY occurred_at"
|
|
||||||
return query, args
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamCSV schreibt gefilterte Audit-Eintraege direkt als CSV in w, Zeile
|
|
||||||
// fuer Zeile ueber rows.Next() — es wird zu keinem Zeitpunkt das gesamte
|
|
||||||
// Ergebnis im Speicher aufgebaut (Akzeptanzkriterium 3 / Pruefung 1).
|
|
||||||
func (l *Log) StreamCSV(ctx context.Context, filter Filter, w io.Writer) error {
|
|
||||||
query, args := buildFilterQuery(filter)
|
|
||||||
rows, err := l.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("export abfragen: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
cw := csv.NewWriter(w)
|
|
||||||
if err := cw.Write([]string{"occurred_at", "tenant_slug", "actor", "action", "target", "metadata"}); err != nil {
|
|
||||||
return fmt.Errorf("csv-header schreiben: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var occurredAt time.Time
|
|
||||||
var tenantSlug, actor, action, target string
|
|
||||||
var metadataJSON []byte
|
|
||||||
if err := rows.Scan(&occurredAt, &tenantSlug, &actor, &action, &target, &metadataJSON); err != nil {
|
|
||||||
return fmt.Errorf("zeile lesen: %w", err)
|
|
||||||
}
|
|
||||||
if err := cw.Write([]string{
|
|
||||||
occurredAt.Format(time.RFC3339), tenantSlug, actor, action, target, string(metadataJSON),
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("csv-zeile schreiben: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cw.Flush()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return fmt.Errorf("export lesen: %w", err)
|
|
||||||
}
|
|
||||||
return cw.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportRecord ist die JSON-Repraesentation einer exportierten Zeile.
|
|
||||||
type exportRecord struct {
|
|
||||||
OccurredAt time.Time `json:"occurred_at"`
|
|
||||||
TenantSlug string `json:"tenant_slug"`
|
|
||||||
Actor string `json:"actor"`
|
|
||||||
Action string `json:"action"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
Metadata json.RawMessage `json:"metadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamJSON schreibt gefilterte Audit-Eintraege als JSON Lines (ein
|
|
||||||
// JSON-Objekt pro Zeile) — bewusst kein einzelnes grosses JSON-Array, da
|
|
||||||
// dessen korrektes Streaming (Kommas/Klammern ohne Zwischenpufferung)
|
|
||||||
// unnoetige Komplexitaet fuer denselben Zweck waere. Wie StreamCSV
|
|
||||||
// zeilenweise ueber rows.Next(), kein Aufbau im Speicher.
|
|
||||||
func (l *Log) StreamJSON(ctx context.Context, filter Filter, w io.Writer) error {
|
|
||||||
query, args := buildFilterQuery(filter)
|
|
||||||
rows, err := l.pool.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("export abfragen: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
for rows.Next() {
|
|
||||||
var rec exportRecord
|
|
||||||
var metadataJSON []byte
|
|
||||||
if err := rows.Scan(&rec.OccurredAt, &rec.TenantSlug, &rec.Actor, &rec.Action, &rec.Target, &metadataJSON); err != nil {
|
|
||||||
return fmt.Errorf("zeile lesen: %w", err)
|
|
||||||
}
|
|
||||||
rec.Metadata = metadataJSON
|
|
||||||
if err := enc.Encode(rec); err != nil {
|
|
||||||
return fmt.Errorf("json-zeile schreiben: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Authorize entscheidet, ob caller den Export ausfuehren darf. Die
|
|
||||||
// eigentliche Rollen-/Rechtepruefung (RBAC-02 Policy-Enforcement) ist nicht
|
|
||||||
// Teil dieser Kachel — ExportHandler kennt nur diese schmale Schnittstelle,
|
|
||||||
// analog zum RetentionRegistrar-Muster aus AUD-05.
|
|
||||||
type Authorize func(ctx context.Context, caller string) bool
|
|
||||||
|
|
||||||
// ExportHandler stellt den Export als HTTP-Endpunkt bereit
|
|
||||||
// (Akzeptanzkriterium 2: fuer berechtigte Rollen verfuegbar).
|
|
||||||
type ExportHandler struct {
|
|
||||||
log *Log
|
|
||||||
authorize Authorize
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewExportHandler(log *Log, authorize Authorize) *ExportHandler {
|
|
||||||
return &ExportHandler{log: log, authorize: authorize}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export liest Filter-Query-Parameter (tenant, actor, action, from, to,
|
|
||||||
// format) und schreibt DIREKT auf den ResponseWriter (io.Writer) — dieselbe
|
|
||||||
// Streaming-Funktion wie in export.go, kein zusaetzlicher Pufferungsschritt.
|
|
||||||
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// "caller" identifiziert die anfragende Person fuer die Berechtigungs-
|
|
||||||
// pruefung — bewusst getrennt vom Filterfeld "actor" (das den
|
|
||||||
// AUDIT-Akteur meint, ueber den gefiltert wird).
|
|
||||||
caller := r.URL.Query().Get("caller")
|
|
||||||
if caller == "" || !h.authorize(r.Context(), caller) {
|
|
||||||
http.Error(w, "keine berechtigung fuer audit-log-export", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := Filter{
|
|
||||||
TenantSlug: r.URL.Query().Get("tenant"),
|
|
||||||
Actor: r.URL.Query().Get("actor"),
|
|
||||||
Action: r.URL.Query().Get("action"),
|
|
||||||
}
|
|
||||||
if from := r.URL.Query().Get("from"); from != "" {
|
|
||||||
t, err := time.Parse(time.RFC3339, from)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "ungueltiges from-datum, erwartet RFC3339", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filter.From = &t
|
|
||||||
}
|
|
||||||
if to := r.URL.Query().Get("to"); to != "" {
|
|
||||||
t, err := time.Parse(time.RFC3339, to)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "ungueltiges to-datum, erwartet RFC3339", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filter.To = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.URL.Query().Get("format") {
|
|
||||||
case "json":
|
|
||||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
|
||||||
if err := h.log.StreamJSON(r.Context(), filter, w); err != nil {
|
|
||||||
http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
w.Header().Set("Content-Type", "text/csv")
|
|
||||||
if err := h.log.StreamCSV(r.Context(), filter, w); err != nil {
|
|
||||||
http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupExportTest(t *testing.T) (*Log, func()) {
|
|
||||||
t.Helper()
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pool: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
|
||||||
actor TEXT NOT NULL CHECK (actor <> ''),
|
|
||||||
action TEXT NOT NULL CHECK (action <> ''),
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
||||||
)`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup := func() { pool.Close() }
|
|
||||||
return NewLog(pool), cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 2: Filterkombinationen liefern korrekte
|
|
||||||
// Teilmengen.
|
|
||||||
func TestExport_FilterCombinations(t *testing.T) {
|
|
||||||
log, cleanup := setupExportTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
run := fmt.Sprintf("_%d", time.Now().UnixNano())
|
|
||||||
tenantA, tenantB := "test_fa"+run, "test_fb"+run
|
|
||||||
alice, bob := "alice"+run, "bob"+run
|
|
||||||
|
|
||||||
events := []Event{
|
|
||||||
{TenantSlug: tenantA, Actor: alice, Action: "login", Target: "x"},
|
|
||||||
{TenantSlug: tenantA, Actor: bob, Action: "login", Target: "x"},
|
|
||||||
{TenantSlug: tenantA, Actor: alice, Action: "logout", Target: "x"},
|
|
||||||
{TenantSlug: tenantB, Actor: alice, Action: "login", Target: "x"},
|
|
||||||
}
|
|
||||||
for _, e := range events {
|
|
||||||
if err := log.Record(ctx, e); err != nil {
|
|
||||||
t.Fatalf("record: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
filter Filter
|
|
||||||
wantLen int
|
|
||||||
}{
|
|
||||||
{"nach tenant", Filter{TenantSlug: tenantA}, 3},
|
|
||||||
{"nach tenant+actor", Filter{TenantSlug: tenantA, Actor: alice}, 2},
|
|
||||||
{"nach tenant+actor+action", Filter{TenantSlug: tenantA, Actor: alice, Action: "login"}, 1},
|
|
||||||
{"nach actor ueber beide tenants", Filter{Actor: alice, Action: "login"}, 2},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := log.StreamCSV(ctx, c.filter, &buf); err != nil {
|
|
||||||
t.Fatalf("stream: %v", err)
|
|
||||||
}
|
|
||||||
rows, err := csv.NewReader(&buf).ReadAll()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("csv parsen: %v", err)
|
|
||||||
}
|
|
||||||
got := len(rows) - 1 // Header abziehen
|
|
||||||
if got != c.wantLen {
|
|
||||||
t.Fatalf("erwartet %d zeilen, habe %d", c.wantLen, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 2: Zeitraum-Filter.
|
|
||||||
func TestExport_TimeRangeFilter(t *testing.T) {
|
|
||||||
log, cleanup := setupExportTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
tenant := fmt.Sprintf("test_tr_%d", time.Now().UnixNano())
|
|
||||||
past := time.Now().Add(-48 * time.Hour)
|
|
||||||
future := time.Now().Add(48 * time.Hour)
|
|
||||||
|
|
||||||
if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "a", Action: "x", Target: "t", OccurredAt: time.Now()}); err != nil {
|
|
||||||
t.Fatalf("record: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &past, To: &future}, &buf); err != nil {
|
|
||||||
t.Fatalf("stream (innerhalb range): %v", err)
|
|
||||||
}
|
|
||||||
if got := countLines(buf.String()) - 1; got != 1 {
|
|
||||||
t.Fatalf("erwartet 1 eintrag innerhalb des zeitraums, habe %d", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
farPast := time.Now().Add(-96 * time.Hour)
|
|
||||||
buf.Reset()
|
|
||||||
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &farPast, To: &past}, &buf); err != nil {
|
|
||||||
t.Fatalf("stream (ausserhalb range): %v", err)
|
|
||||||
}
|
|
||||||
if got := countLines(buf.String()) - 1; got != 0 {
|
|
||||||
t.Fatalf("erwartet 0 eintraege ausserhalb des zeitraums, habe %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func countLines(s string) int {
|
|
||||||
s = strings.TrimRight(s, "\n")
|
|
||||||
if s == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return len(strings.Split(s, "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 1: Export mit hoher Eintragszahl ohne
|
|
||||||
// uebermaessigen Speicherverbrauch — Stichprobe per runtime.MemStats.
|
|
||||||
func TestExport_StreamsLargeResultWithoutExcessiveMemory(t *testing.T) {
|
|
||||||
log, cleanup := setupExportTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
tenant := fmt.Sprintf("test_large_%d", time.Now().UnixNano())
|
|
||||||
const n = 20000
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "bulk", Action: "test.bulk", Target: fmt.Sprintf("obj-%d", i)}); err != nil {
|
|
||||||
t.Fatalf("record %d: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.GC()
|
|
||||||
var before runtime.MemStats
|
|
||||||
runtime.ReadMemStats(&before)
|
|
||||||
|
|
||||||
lineCount := 0
|
|
||||||
cw := &countingWriter{onWrite: func(p []byte) { lineCount += strings.Count(string(p), "\n") }}
|
|
||||||
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant}, cw); err != nil {
|
|
||||||
t.Fatalf("stream: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.GC()
|
|
||||||
var after runtime.MemStats
|
|
||||||
runtime.ReadMemStats(&after)
|
|
||||||
|
|
||||||
if lineCount != n+1 { // +1 Header
|
|
||||||
t.Fatalf("erwartet %d zeilen (inkl. header), habe %d", n+1, lineCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grobe Stichprobe: ein NICHT streamender Export haette hier locker
|
|
||||||
// mehrere MB an einmal gehaltenen Zeilen/Strings erzeugt. Grosszuegige
|
|
||||||
// Schwelle, da Go-Heap-Messungen naturgemaess rauschen.
|
|
||||||
const maxAcceptableGrowth = 3 * 1024 * 1024 // 3 MB
|
|
||||||
growth := int64(after.HeapAlloc) - int64(before.HeapAlloc)
|
|
||||||
t.Logf("heap-wachstum waehrend export von %d zeilen: %d bytes (schwelle: %d)", n, growth, maxAcceptableGrowth)
|
|
||||||
if growth > maxAcceptableGrowth {
|
|
||||||
t.Fatalf("heap ist um %d bytes gewachsen, erwartet unter %d (hinweis auf vollstaendige pufferung statt streaming)", growth, maxAcceptableGrowth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type countingWriter struct {
|
|
||||||
onWrite func(p []byte)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *countingWriter) Write(p []byte) (int, error) {
|
|
||||||
w.onWrite(p)
|
|
||||||
return len(p), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 3: Zugriff ohne passende Berechtigung wird abgewiesen.
|
|
||||||
func TestExportHandler_RejectsWithoutAuthorization(t *testing.T) {
|
|
||||||
log, cleanup := setupExportTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
handler := NewExportHandler(log, func(ctx context.Context, caller string) bool {
|
|
||||||
return caller == "berechtigte-person@example.com"
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/audit/export?caller=unberechtigt@example.com", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.Export(rec, req)
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("unberechtigt: status = %d, want 403", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
reqNoCaller := httptest.NewRequest(http.MethodGet, "/audit/export", nil)
|
|
||||||
recNoCaller := httptest.NewRecorder()
|
|
||||||
handler.Export(recNoCaller, reqNoCaller)
|
|
||||||
if recNoCaller.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("ohne caller: status = %d, want 403", recNoCaller.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
reqOK := httptest.NewRequest(http.MethodGet, "/audit/export?caller=berechtigte-person@example.com", nil)
|
|
||||||
recOK := httptest.NewRecorder()
|
|
||||||
handler.Export(recOK, reqOK)
|
|
||||||
if recOK.Code != http.StatusOK {
|
|
||||||
t.Fatalf("berechtigt: status = %d, want 200", recOK.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
// Package auditadmin implementiert Core AUD-04: die Berechtigungspruefung
|
|
||||||
// fuer die Audit-Log-Ansicht. Enthaelt bewusst KEINE eigene Filter-/
|
|
||||||
// Export-Logik — die Ansicht ist "reiner Konsument der Export-API" (AUD-03,
|
|
||||||
// internal/audit.ExportHandler); dieses Paket liefert nur die
|
|
||||||
// audit.Authorize-Implementierung, die der Entwicklungs-/Testserver
|
|
||||||
// (cmd/auditlog-devserver) einhaengt.
|
|
||||||
package auditadmin
|
|
||||||
|
|
||||||
import "crypto/subtle"
|
|
||||||
|
|
||||||
// NewTokenAuthorizer liefert eine audit.Authorize-Funktion, die den
|
|
||||||
// aufrufenden "caller"-Wert timing-safe gegen ein geteiltes Admin-Token
|
|
||||||
// vergleicht — dasselbe Muster wie internal/audit.timingsafe (AUD-02),
|
|
||||||
// NICHT ueber eine neue Rollen-/Rechteschicht, da AUD-04 ausdruecklich nur
|
|
||||||
// von AUD-03 abhaengt.
|
|
||||||
func NewTokenAuthorizer(adminToken string) func(caller string) bool {
|
|
||||||
return func(caller string) bool {
|
|
||||||
if caller == "" || adminToken == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return subtle.ConstantTimeCompare([]byte(caller), []byte(adminToken)) == 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package auditadmin
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// Grundlage fuer Akzeptanzkriterium 1/2: die Oberflaeche darf Daten und den
|
|
||||||
// Export nur bei korrektem Admin-Token abrufen.
|
|
||||||
func TestNewTokenAuthorizer_AcceptsCorrectToken(t *testing.T) {
|
|
||||||
authorize := NewTokenAuthorizer("geheimes-token")
|
|
||||||
if !authorize("geheimes-token") {
|
|
||||||
t.Fatal("erwartet true fuer korrektes token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewTokenAuthorizer_RejectsWrongToken(t *testing.T) {
|
|
||||||
authorize := NewTokenAuthorizer("geheimes-token")
|
|
||||||
if authorize("falsches-token") {
|
|
||||||
t.Fatal("erwartet false fuer falsches token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewTokenAuthorizer_RejectsEmptyCaller(t *testing.T) {
|
|
||||||
authorize := NewTokenAuthorizer("geheimes-token")
|
|
||||||
if authorize("") {
|
|
||||||
t.Fatal("erwartet false fuer leeren aufrufer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewTokenAuthorizer_RejectsWhenNoTokenConfigured(t *testing.T) {
|
|
||||||
authorize := NewTokenAuthorizer("")
|
|
||||||
if authorize("irgendwas") {
|
|
||||||
t.Fatal("erwartet false, wenn kein admin-token konfiguriert ist (fail-safe-default)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
|
||||||
|
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
|
||||||
|
// Teil dieser Kachel (siehe IAM-03..07).
|
||||||
|
type Handler struct {
|
||||||
|
login *LoginService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(login *LoginService) *Handler {
|
||||||
|
return &Handler{login: login}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.login.Login(r.Context(), req.Email, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: int(AccessTokenTTL.Seconds()),
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
|
||||||
|
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
|
||||||
|
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
|
||||||
|
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
|
||||||
|
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
|
||||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
|
||||||
|
|
||||||
|
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
|
||||||
|
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
|
||||||
|
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
|
||||||
|
type LoginService struct {
|
||||||
|
users *user.TenantUserStore
|
||||||
|
issuer *TokenIssuer
|
||||||
|
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
|
||||||
|
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
|
||||||
|
tenantSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
|
||||||
|
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
|
||||||
|
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
|
||||||
|
// eines Kontos zuzulassen (User-Enumeration-Schutz).
|
||||||
|
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
|
||||||
|
creds, err := s.users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
|
||||||
|
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
|
||||||
|
// ist als bei falschem Passwort (Timing-Seitenkanal).
|
||||||
|
VerifyPassword(dummyHash, password)
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds.User.Status != user.StatusActive {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if !VerifyPassword(creds.PasswordHash, password) {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
|
||||||
|
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
|
||||||
|
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
|
||||||
|
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
|
||||||
|
// Timing-Angleichung damit wirkungslos waere.
|
||||||
|
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
const usersSchema = `
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);`
|
||||||
|
|
||||||
|
func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin pool: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||||
|
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
||||||
|
t.Fatalf("testdatenbank anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect testdatenbank: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, usersSchema); err != nil {
|
||||||
|
t.Fatalf("schema anwenden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Close()
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||||
|
adminPool.Close()
|
||||||
|
})
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
u, err := store.Create(ctx, email, "Test User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
|
||||||
|
t.Fatalf("set password: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "test_iam02_login")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
login := NewLoginService(store, issuer, "acme")
|
||||||
|
|
||||||
|
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("erwartet nicht-leeres token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
|
||||||
|
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
|
||||||
|
// existiert.
|
||||||
|
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
|
||||||
|
poolA := setupTenantDB(t, "test_iam02_tenant_a")
|
||||||
|
poolB := setupTenantDB(t, "test_iam02_tenant_b")
|
||||||
|
|
||||||
|
storeA := user.NewTenantUserStore(poolA)
|
||||||
|
storeB := user.NewTenantUserStore(poolB)
|
||||||
|
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
|
||||||
|
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
loginA := NewLoginService(storeA, issuer, "tenant-a")
|
||||||
|
|
||||||
|
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
|
||||||
|
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
|
||||||
|
// strukturell nur die Zeilen ihrer eigenen Datenbank.
|
||||||
|
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := issuer.Verify(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.TenantSlug != "tenant-a" {
|
||||||
|
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
|
||||||
|
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Kein Cookie.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
protected(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manipuliertes Cookie.
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
protected(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gueltiges Token.
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
protected(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CookieName = "nexarch_session"
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const claimsContextKey contextKey = iota
|
||||||
|
|
||||||
|
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
|
||||||
|
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
|
||||||
|
// (IAM-02 Akzeptanzkriterium 3).
|
||||||
|
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(CookieName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := issuer.Verify(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
|
||||||
|
// gelegt hat.
|
||||||
|
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
||||||
|
c, ok := ctx.Value(claimsContextKey).(*Claims)
|
||||||
|
return c, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
|
||||||
|
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
|
||||||
|
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
|
||||||
|
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
|
||||||
|
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
|
||||||
|
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
|
||||||
|
// und den Pruefungs-Eintrag in der Commit-Nachricht.
|
||||||
|
const BcryptCost = 12
|
||||||
|
|
||||||
|
func HashPassword(plain string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
|
||||||
|
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
|
||||||
|
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
|
||||||
|
func VerifyPassword(hash, plain string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
|
||||||
|
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
|
||||||
|
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
|
||||||
|
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
|
||||||
|
// zuverlaessig aufzudecken.
|
||||||
|
const TargetLoginLatency = 400 * time.Millisecond
|
||||||
|
|
||||||
|
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
|
||||||
|
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
|
||||||
|
// Ergebnis (IAM-02 Pruefung 4).
|
||||||
|
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
|
||||||
|
hash, err := HashPassword("benchmark-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if !VerifyPassword(hash, "benchmark-passwort") {
|
||||||
|
t.Fatal("verifikation haette erfolgreich sein muessen")
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
|
||||||
|
if elapsed > TargetLoginLatency {
|
||||||
|
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkVerifyPassword(b *testing.B) {
|
||||||
|
hash, _ := HashPassword("benchmark-passwort")
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
VerifyPassword(hash, "benchmark-passwort")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHashAndVerifyPassword(t *testing.T) {
|
||||||
|
hash, err := HashPassword("s3hr-geheim!")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
if hash == "s3hr-geheim!" {
|
||||||
|
t.Fatal("passwort wurde nicht gehasht")
|
||||||
|
}
|
||||||
|
if !VerifyPassword(hash, "s3hr-geheim!") {
|
||||||
|
t.Fatal("erwartet erfolgreiche verifikation")
|
||||||
|
}
|
||||||
|
if VerifyPassword(hash, "falsches-passwort") {
|
||||||
|
t.Fatal("erwartet fehlgeschlagene verifikation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDummyHashIsValidBcryptHash(t *testing.T) {
|
||||||
|
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
|
||||||
|
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
|
||||||
|
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
|
||||||
|
if VerifyPassword(dummyHash, "irgendein-text") {
|
||||||
|
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
|
||||||
|
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
|
||||||
|
const AccessTokenTTL = 30 * time.Minute
|
||||||
|
|
||||||
|
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"uid"`
|
||||||
|
TenantSlug string `json:"tenant"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
|
||||||
|
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
|
||||||
|
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
|
||||||
|
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
|
||||||
|
type TokenIssuer struct {
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenIssuer(secret string) *TokenIssuer {
|
||||||
|
return &TokenIssuer{secret: []byte(secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
TenantSlug: tenantSlug,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(i.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
|
||||||
|
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
|
||||||
|
// timing-safe (hmac.Equal).
|
||||||
|
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return i.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenIssueAndVerify(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := issuer.Verify(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
|
||||||
|
t.Fatalf("claims unerwartet: %+v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 2: Token-Manipulationstest.
|
||||||
|
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
|
||||||
|
}
|
||||||
|
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
|
||||||
|
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
|
||||||
|
|
||||||
|
if _, err := issuer.Verify(tampered); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("secret-a")
|
||||||
|
other := NewTokenIssuer("secret-b")
|
||||||
|
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := other.Verify(token); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei falschem secret, habe nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
|
||||||
|
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
UserID: "user-1",
|
||||||
|
TenantSlug: "acme",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := issuer.Verify(expired); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Package loadtest implementiert Core QA-08: Last- und Leistungstests fuer
|
||||||
|
// die drei Mechanismen, die unter realistischer Mehrmodul-Last (sechs
|
||||||
|
// gleichzeitig zugreifende Fachmodule: DMS/Mail/Archive/Workflow/AI/Connect)
|
||||||
|
// am ehesten unter Druck geraten — JWT-Verifikationspfad (API-05),
|
||||||
|
// Tenant-Connection-Pooling (TEN-06) und Rate-Limiting (API-03). Jeder Test
|
||||||
|
// dokumentiert einen Zielwert UND das tatsaechlich gemessene Ergebnis
|
||||||
|
// (Ticket-Vorgabe: "Pruefen heisst messen. Behauptungen ohne Messprotokoll
|
||||||
|
// gelten nicht als erledigt.").
|
||||||
|
package loadtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/ratelimit"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
const simulatedModuleCount = 6 // DMS, Mail, Archive, Workflow, AI, Connect
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: definierte Ziel-Latenz fuer
|
||||||
|
// JWT-Verifikation unter Last aus sechs gleichzeitig zugreifenden Modulen.
|
||||||
|
//
|
||||||
|
// Zielwert: p95 < 10ms je Verify()-Aufruf. Begruendung des Zielwerts:
|
||||||
|
// Verify() ist eine rein lokale Operation gegen einen bereits im Speicher
|
||||||
|
// zwischengespeicherten Schluesselsatz (StaleCache, siehe API-05) — es
|
||||||
|
// findet kein Netzwerk-Roundtrip zu Core statt, daher ist ein niedriger
|
||||||
|
// Millisekunden-Zielwert realistisch, nicht willkuerlich hoch angesetzt.
|
||||||
|
func TestLoad_JWTVerificationLatencyUnderSixModuleLoad(t *testing.T) {
|
||||||
|
km, err := moduletrust.NewKeyManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("keymanager: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := km.Rotate(); err != nil {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier := moduletrust.NewVerifier(time.Minute, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
return km.PublicKeySet(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestsPerModule = 200
|
||||||
|
totalRequests := simulatedModuleCount * requestsPerModule
|
||||||
|
|
||||||
|
tokens := make([]string, totalRequests)
|
||||||
|
for i := 0; i < totalRequests; i++ {
|
||||||
|
tok, err := km.Issue("user-1", fmt.Sprintf("tenant-%d", i%50), 5*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
tokens[i] = tok
|
||||||
|
}
|
||||||
|
|
||||||
|
latencies := make([]time.Duration, totalRequests)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for module := 0; module < simulatedModuleCount; module++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(moduleIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < requestsPerModule; i++ {
|
||||||
|
idx := moduleIdx*requestsPerModule + i
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := verifier.Verify(context.Background(), tokens[idx]); err != nil {
|
||||||
|
t.Errorf("verify: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latencies[idx] = time.Since(start)
|
||||||
|
}
|
||||||
|
}(module)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
|
||||||
|
p95 := latencies[int(float64(len(latencies))*0.95)]
|
||||||
|
max := latencies[len(latencies)-1]
|
||||||
|
|
||||||
|
t.Logf("JWT-Verifikation unter Last: %d module x %d anfragen = %d gesamt. p95=%s, max=%s, ziel=p95<10ms",
|
||||||
|
simulatedModuleCount, requestsPerModule, totalRequests, p95, max)
|
||||||
|
|
||||||
|
if p95 >= 10*time.Millisecond {
|
||||||
|
t.Fatalf("p95-latenz = %s, ziel war unter 10ms", p95)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Connection-Pooling bleibt unter der
|
||||||
|
// Postgres-Verbindungsobergrenze bei simulierter Vielzahl an Mandanten.
|
||||||
|
//
|
||||||
|
// Zielwert: bei 200 simulierten Mandanten, auf die sechs Module gleichzeitig
|
||||||
|
// zugreifen, bleiben NIE mehr als maxOpen=20 Pools gleichzeitig offen (LRU-
|
||||||
|
// Verdraengung greift) — weit unter einer typischen Postgres
|
||||||
|
// max_connections-Grenze (Default 100).
|
||||||
|
func TestLoad_ConnectionPoolingStaysUnderLimitWithManySimulatedTenants(t *testing.T) {
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||||
|
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(pool)
|
||||||
|
const simulatedTenants = 200
|
||||||
|
const maxOpen = 20
|
||||||
|
|
||||||
|
prefix := fmt.Sprintf("qa08_%d", time.Now().UnixNano())
|
||||||
|
slugs := make([]string, simulatedTenants)
|
||||||
|
for i := 0; i < simulatedTenants; i++ {
|
||||||
|
slug := fmt.Sprintf("%s_%d", prefix, i)
|
||||||
|
slugs[i] = slug
|
||||||
|
// DSN muss nur SYNTAKTISCH gueltig sein — pgxpool.New verbindet
|
||||||
|
// erst lazy bei tatsaechlicher Nutzung, fuer diesen Lasttest zaehlt
|
||||||
|
// ausschliesslich die Zahl offener *Pool-Objekte*, nicht ob die
|
||||||
|
// referenzierte Datenbank real existiert.
|
||||||
|
dsn := fmt.Sprintf("postgresql://nexarch_test:unused@localhost:5432/%s?sslmode=disable", slug)
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, $2)
|
||||||
|
`, slug, dsn); err != nil {
|
||||||
|
t.Fatalf("tenant %s anlegen: %v", slug, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE slug LIKE $1`, prefix+"%")
|
||||||
|
})
|
||||||
|
|
||||||
|
router := tenant.NewRouter(registry, maxOpen)
|
||||||
|
defer router.Close()
|
||||||
|
|
||||||
|
var maxObservedOpen int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for module := 0; module < simulatedModuleCount; module++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(moduleIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < simulatedTenants; i++ {
|
||||||
|
slug := slugs[(i+moduleIdx*37)%simulatedTenants] // module-uebergreifend gemischter zugriff
|
||||||
|
if _, err := router.Resolve(context.Background(), slug); err != nil {
|
||||||
|
t.Errorf("resolve %s: %v", slug, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current := int64(router.OpenCount()); current > atomic.LoadInt64(&maxObservedOpen) {
|
||||||
|
atomic.StoreInt64(&maxObservedOpen, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(module)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
t.Logf("connection-pooling unter last: %d simulierte mandanten, %d module, max. gleichzeitig beobachtete offene pools = %d (ziel: <= %d)",
|
||||||
|
simulatedTenants, simulatedModuleCount, maxObservedOpen, maxOpen)
|
||||||
|
|
||||||
|
if maxObservedOpen > int64(maxOpen) {
|
||||||
|
t.Fatalf("max. beobachtete offene pools = %d, ziel war <= %d", maxObservedOpen, maxOpen)
|
||||||
|
}
|
||||||
|
if router.OpenCount() > maxOpen {
|
||||||
|
t.Fatalf("finale offene pools = %d, ziel war <= %d", router.OpenCount(), maxOpen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Rate-Limiting funktioniert korrekt
|
||||||
|
// unter Mehrinstanz-Last (mehrere "Core-Instanzen" mit geteiltem Postgres-
|
||||||
|
// Zustand) aus sechs gleichzeitig zugreifenden Modulen, nicht nur im
|
||||||
|
// Einzelinstanz-Test (der bereits in API-03 abgedeckt ist).
|
||||||
|
//
|
||||||
|
// Zielwert: bei konfiguriertem Limit=500 und insgesamt 1200 Anfragen ueber
|
||||||
|
// DREI simulierte Core-Instanzen (separate Pools/Store-Objekte) hinweg
|
||||||
|
// werden EXAKT 500 Anfragen erlaubt — kein Overcounting durch fehlende
|
||||||
|
// Koordination zwischen den Instanzen, kein Undercounting durch verlorene
|
||||||
|
// Updates.
|
||||||
|
func TestLoad_RateLimitingCorrectAcrossMultipleInstancesUnderSixModuleLoad(t *testing.T) {
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const instanceCount = 3
|
||||||
|
pools := make([]*pgxpool.Pool, instanceCount)
|
||||||
|
stores := make([]*ratelimit.Store, instanceCount)
|
||||||
|
for i := 0; i < instanceCount; i++ {
|
||||||
|
p, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool %d: %v", i, err)
|
||||||
|
}
|
||||||
|
defer p.Close()
|
||||||
|
if i == 0 {
|
||||||
|
if _, err := p.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_configs (
|
||||||
|
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_counters (
|
||||||
|
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (key, window_start)
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pools[i] = p
|
||||||
|
stores[i] = ratelimit.NewStore(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("qa08-key-%d", time.Now().UnixNano())
|
||||||
|
const limit = 500
|
||||||
|
if err := stores[0].SetLimit(ctx, key, limit, time.Minute); err != nil {
|
||||||
|
t.Fatalf("setlimit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRequests = 1200 // simulierte last aus sechs modulen ueber drei instanzen, deutlich ueber dem limit von 500
|
||||||
|
var allowedCount int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < totalRequests; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
instance := stores[i%instanceCount] // request "kommt" reihum von einer der drei core-instanzen
|
||||||
|
go func(s *ratelimit.Store) {
|
||||||
|
defer wg.Done()
|
||||||
|
res, err := s.Allow(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("allow: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.Allowed {
|
||||||
|
atomic.AddInt64(&allowedCount, 1)
|
||||||
|
}
|
||||||
|
}(instance)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
t.Logf("rate-limiting unter mehrinstanz-last: %d anfragen ueber %d instanzen, limit=%d, tatsaechlich erlaubt=%d",
|
||||||
|
totalRequests, instanceCount, limit, allowedCount)
|
||||||
|
|
||||||
|
if allowedCount != limit {
|
||||||
|
t.Fatalf("erlaubte anfragen ueber alle instanzen = %d, ziel war exakt %d (geteilter, korrekter zustand)", allowedCount, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package moduletrust
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaleCache ist der generische Rechte-/Feature-Flag-Cache-Kontrakt
|
||||||
|
// (Akzeptanzkriterium 2): TTL-basiert, mit explizitem, benanntem Verhalten
|
||||||
|
// bei abgelaufenem Cache waehrend Core nicht erreichbar ist.
|
||||||
|
//
|
||||||
|
// - Get: FAIL-OPEN fuer Lesevorgaenge. Schlaegt der Refresh fehl, aber es
|
||||||
|
// gibt bereits einen (wenn auch abgelaufenen) Stand, wird dieser mit
|
||||||
|
// stale=true zurueckgegeben — Begruendung: ein bereits authentifiziertes
|
||||||
|
// Modul soll mit dem letztbekannten Stand weiterarbeiten koennen statt
|
||||||
|
// hart zu blockieren (siehe "Bekannte Fehler vermeiden" im Ticket).
|
||||||
|
// Existiert noch nie ein Stand, gibt es keinen sinnvollen Fallback —
|
||||||
|
// dann liefert auch Get einen Fehler.
|
||||||
|
// - RequireFresh: FAIL-CLOSED fuer sicherheitskritische Aktionen (z.B.
|
||||||
|
// ein komplett NEUER Login). Nutzt NIEMALS einen zwischengespeicherten
|
||||||
|
// Stand, ruft immer frisch ab — Begruendung: eine neue Vertrauens-
|
||||||
|
// entscheidung darf nicht auf veralteten Daten beruhen, auch wenn das
|
||||||
|
// bedeutet, dass die Aktion bei Core-Ausfall sichtbar fehlschlaegt statt
|
||||||
|
// unsicher "irgendwie" durchgelassen zu werden.
|
||||||
|
//
|
||||||
|
// LIC-02 (internal/flag.Service) implementiert bereits denselben Kontrakt
|
||||||
|
// fuer Feature-Flags — StaleCache verallgemeinert dasselbe Muster fuer
|
||||||
|
// JWT-Signaturschluessel, damit beide Faelle derselben dokumentierten
|
||||||
|
// Policy folgen.
|
||||||
|
type StaleCache[T any] struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
value T
|
||||||
|
hasValue bool
|
||||||
|
fetchedAt time.Time
|
||||||
|
ttl time.Duration
|
||||||
|
fetch func(ctx context.Context) (T, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStaleCache[T any](ttl time.Duration, fetch func(ctx context.Context) (T, error)) *StaleCache[T] {
|
||||||
|
return &StaleCache[T]{ttl: ttl, fetch: fetch}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get liefert den Cache-Wert. FAIL-OPEN: bei Refresh-Fehler wird ein
|
||||||
|
// vorhandener, ggf. abgelaufener Stand zurueckgegeben (stale=true).
|
||||||
|
func (c *StaleCache[T]) Get(ctx context.Context) (value T, stale bool, err error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
fresh := c.hasValue && time.Since(c.fetchedAt) < c.ttl
|
||||||
|
if fresh {
|
||||||
|
v := c.value
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return v, false, nil
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
newVal, fetchErr := c.fetch(ctx)
|
||||||
|
if fetchErr == nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.value, c.hasValue, c.fetchedAt = newVal, true, time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
return newVal, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if c.hasValue {
|
||||||
|
return c.value, true, nil
|
||||||
|
}
|
||||||
|
var zero T
|
||||||
|
return zero, false, fmt.Errorf("cache leer und refresh fehlgeschlagen: %w", fetchErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireFresh ruft IMMER frisch ab (FAIL-CLOSED) — fuer sicherheitskritische
|
||||||
|
// Aktionen, die niemals auf einem zwischengespeicherten Stand basieren duerfen.
|
||||||
|
func (c *StaleCache[T]) RequireFresh(ctx context.Context) (T, error) {
|
||||||
|
v, err := c.fetch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, fmt.Errorf("core nicht erreichbar, sicherheitskritische aktion abgelehnt: %w", err)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.value, c.hasValue, c.fetchedAt = v, true, time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package moduletrust
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
TenantSlug string `json:"tenant"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue signiert ein Token mit dem aktuellen Signierschluessel und traegt
|
||||||
|
// dessen KID im JWT-Header ein — der Verifier auf Modulseite waehlt darueber
|
||||||
|
// den passenden oeffentlichen Schluessel aus PublicKeySet() aus.
|
||||||
|
func (m *KeyManager) Issue(subject, tenantSlug string, ttl time.Duration) (string, error) {
|
||||||
|
key, err := m.SigningKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
Subject: subject,
|
||||||
|
TenantSlug: tenantSlug,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||||
|
token.Header["kid"] = key.KID
|
||||||
|
return token.SignedString(key.Private)
|
||||||
|
}
|
||||||
|
|
||||||
|
type jwksResponse struct {
|
||||||
|
Keys []jwksKey `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jwksKey struct {
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
PublicKey string `json:"public_key"` // base64 (raw Ed25519, 32 Byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeJWKS liefert alle bekannten oeffentlichen Schluessel als JSON —
|
||||||
|
// Module fragen dies periodisch ab (nicht pro Request), siehe Verifier.
|
||||||
|
func (m *KeyManager) ServeJWKS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
set := m.PublicKeySet()
|
||||||
|
resp := jwksResponse{Keys: make([]jwksKey, 0, len(set))}
|
||||||
|
for kid, pub := range set {
|
||||||
|
resp.Keys = append(resp.Keys, jwksKey{Kid: kid, PublicKey: base64.StdEncoding.EncodeToString(pub)})
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseJWKS dekodiert die JSON-Antwort von ServeJWKS zurueck in kid->PublicKey
|
||||||
|
// — Hilfsfunktion fuer Module, die JWKS per HTTP abrufen.
|
||||||
|
func ParseJWKS(data []byte) (map[string][]byte, error) {
|
||||||
|
var resp jwksResponse
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("jwks parsen: %w", err)
|
||||||
|
}
|
||||||
|
out := make(map[string][]byte, len(resp.Keys))
|
||||||
|
for _, k := range resp.Keys {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(k.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oeffentlichen schluessel %q dekodieren: %w", k.Kid, err)
|
||||||
|
}
|
||||||
|
out[k.Kid] = raw
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Package moduletrust implementiert Core API-05: asymmetrische JWT-Signatur
|
||||||
|
// mit Schluesselverteilung (JWKS), damit DMS/Mail/Archive/Workflow JWTs
|
||||||
|
// LOKAL verifizieren koennen, ohne pro Aufruf einen synchronen Request an
|
||||||
|
// Core zu stellen — Core darf Fundament sein, ohne zum Flaschenhals zu
|
||||||
|
// werden (siehe Entscheidungsverlauf "Vertrauensstellung Core<->Module" in
|
||||||
|
// nexarch-state.json). IAM-02s HS256-Session-Cookie (Browser-Login) bleibt
|
||||||
|
// unangetastet — dies ist ein zusaetzlicher, getrennter Vertrauensmechanismus
|
||||||
|
// fuer Modul-zu-Modul/Modul-zu-Core-Aufrufe.
|
||||||
|
package moduletrust
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KeyPair struct {
|
||||||
|
KID string
|
||||||
|
Private ed25519.PrivateKey
|
||||||
|
Public ed25519.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyManager haelt ALLE noch gueltigen Schluesselpaare — nicht nur das
|
||||||
|
// aktuell signierende. Rotate erzeugt ein neues Paar und behaelt die alten
|
||||||
|
// fuer die Verifikation bereits ausgestellter Tokens (Akzeptanzkriterium 3:
|
||||||
|
// Rotation ohne Ausfallzeit fuer andere Module).
|
||||||
|
type KeyManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
keys []KeyPair // aeltestes zuerst, neuestes zuletzt
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKeyManager() (*KeyManager, error) {
|
||||||
|
m := &KeyManager{}
|
||||||
|
if _, err := m.Rotate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate erzeugt ein neues Ed25519-Schluesselpaar mit eigener KID und macht
|
||||||
|
// es zum aktuellen Signierschluessel. Aeltere Schluessel bleiben in
|
||||||
|
// PublicKeySet() erhalten, damit bereits ausgestellte Tokens weiterhin
|
||||||
|
// verifizierbar sind.
|
||||||
|
func (m *KeyManager) Rotate() (KeyPair, error) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, fmt.Errorf("schluesselpaar erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
kidBytes := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(kidBytes); err != nil {
|
||||||
|
return KeyPair{}, fmt.Errorf("kid erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
kp := KeyPair{KID: hex.EncodeToString(kidBytes), Private: priv, Public: pub}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.keys = append(m.keys, kp)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return kp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SigningKey liefert den aktuellen (neuesten) Schluessel zum Signieren neuer Tokens.
|
||||||
|
func (m *KeyManager) SigningKey() (KeyPair, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if len(m.keys) == 0 {
|
||||||
|
return KeyPair{}, fmt.Errorf("moduletrust: kein schluessel vorhanden")
|
||||||
|
}
|
||||||
|
return m.keys[len(m.keys)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKeySet liefert ALLE bekannten oeffentlichen Schluessel (kid ->
|
||||||
|
// public key) — die Grundlage fuer den JWKS-Endpunkt.
|
||||||
|
func (m *KeyManager) PublicKeySet() map[string]ed25519.PublicKey {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make(map[string]ed25519.PublicKey, len(m.keys))
|
||||||
|
for _, k := range m.keys {
|
||||||
|
out[k.KID] = k.Public
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package moduletrust
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssueAndVerify_RoundTrip(t *testing.T) {
|
||||||
|
km, err := NewKeyManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new key manager: %v", err)
|
||||||
|
}
|
||||||
|
token, err := km.Issue("user-1", "acme", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
return km.PublicKeySet(), nil
|
||||||
|
})
|
||||||
|
claims, err := v.Verify(context.Background(), token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.Subject != "user-1" || claims.TenantSlug != "acme" {
|
||||||
|
t.Fatalf("claims unerwartet: %+v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Verifikation lokal, kein Request pro Aufruf.
|
||||||
|
func TestVerify_DoesNotFetchPerCall(t *testing.T) {
|
||||||
|
km, err := NewKeyManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new key manager: %v", err)
|
||||||
|
}
|
||||||
|
token, err := km.Issue("user-1", "acme", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
fetchCalls := 0
|
||||||
|
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
mu.Lock()
|
||||||
|
fetchCalls++
|
||||||
|
mu.Unlock()
|
||||||
|
return km.PublicKeySet(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if _, err := v.Verify(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("verify %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if fetchCalls != 1 {
|
||||||
|
t.Fatalf("erwartet genau 1 fetch fuer 10 Verify-Aufrufe innerhalb der TTL, habe %d", fetchCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: Core simuliert nicht erreichbar,
|
||||||
|
// bereits authentifizierte Nutzer bleiben funktionsfaehig (Fail-Open mit
|
||||||
|
// letztbekanntem Schluesselstand).
|
||||||
|
func TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist(t *testing.T) {
|
||||||
|
km, err := NewKeyManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new key manager: %v", err)
|
||||||
|
}
|
||||||
|
token, err := km.Issue("user-1", "acme", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
coreDown := false
|
||||||
|
v := NewVerifier(30*time.Millisecond, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
if coreDown {
|
||||||
|
return nil, errors.New("core nicht erreichbar (simuliert)")
|
||||||
|
}
|
||||||
|
return km.PublicKeySet(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cache vorwaermen, waehrend Core noch erreichbar ist.
|
||||||
|
if _, err := v.Verify(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("verify (warm): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Core abschalten" und TTL ablaufen lassen.
|
||||||
|
coreDown = true
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := v.Verify(context.Background(), token); err != nil {
|
||||||
|
t.Fatalf("verify sollte trotz core-ausfall mit letztbekanntem stand funktionieren: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: neue sicherheitskritische Aktionen
|
||||||
|
// (z.B. neuer Login) schlagen bei Core-Ausfall klar fehl statt unsicher
|
||||||
|
// durchgelassen zu werden — auch wenn ein (aelterer) Cache-Stand existiert.
|
||||||
|
func TestRequireFreshKeys_FailsClosedWhenCoreUnreachable(t *testing.T) {
|
||||||
|
km, err := NewKeyManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new key manager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
coreDown := false
|
||||||
|
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
if coreDown {
|
||||||
|
return nil, errors.New("core nicht erreichbar (simuliert)")
|
||||||
|
}
|
||||||
|
return km.PublicKeySet(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cache vorwaermen (existiert jetzt ein "veralteter" gueltiger Stand).
|
||||||
|
if _, _, err := v.cache.Get(context.Background()); err != nil {
|
||||||
|
t.Fatalf("warm cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
coreDown = true
|
||||||
|
if err := v.RequireFreshKeys(context.Background()); err == nil {
|
||||||
|
t.Fatal("erwartet fehler (fail-closed) bei core-ausfall, habe nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Schluesselrotation ohne Ausfallzeit —
|
||||||
|
// ein bereits ausgestelltes Token bleibt nach Rotation weiterhin
|
||||||
|
// verifizierbar, ein zweites (simuliertes) Modul bekommt beide Schluessel.
|
||||||
|
func TestRotate_NoDowntimeForAlreadyIssuedTokens(t *testing.T) {
|
||||||
|
km, err := NewKeyManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new key manager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldToken, err := km.Issue("user-1", "acme", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue (alt): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := km.Rotate(); err != nil {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newToken, err := km.Issue("user-2", "acme", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue (neu): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simuliertes zweites Modul: fragt den vollstaendigen Schluesselsatz ab.
|
||||||
|
moduleB := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
return km.PublicKeySet(), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := moduleB.Verify(context.Background(), oldToken); err != nil {
|
||||||
|
t.Fatalf("altes token sollte nach rotation weiterhin gueltig sein: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := moduleB.Verify(context.Background(), newToken); err != nil {
|
||||||
|
t.Fatalf("neues token sollte gueltig sein: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_RejectsUnknownKid(t *testing.T) {
|
||||||
|
km1, _ := NewKeyManager()
|
||||||
|
km2, _ := NewKeyManager() // komplett anderer, unbekannter schluessel
|
||||||
|
|
||||||
|
token, err := km1.Issue("user-1", "acme", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
return km2.PublicKeySet(), nil // kennt km1s schluessel nicht
|
||||||
|
})
|
||||||
|
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrInvalidToken) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWKSRoundTrip(t *testing.T) {
|
||||||
|
km, _ := NewKeyManager()
|
||||||
|
km.Rotate()
|
||||||
|
|
||||||
|
var buf []byte
|
||||||
|
rec := &captureWriter{}
|
||||||
|
km.ServeJWKS(rec, nil)
|
||||||
|
buf = rec.body
|
||||||
|
|
||||||
|
parsed, err := ParseJWKS(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse jwks: %v", err)
|
||||||
|
}
|
||||||
|
if len(parsed) != len(km.PublicKeySet()) {
|
||||||
|
t.Fatalf("erwartet %d schluessel, habe %d", len(km.PublicKeySet()), len(parsed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureWriter struct {
|
||||||
|
body []byte
|
||||||
|
header http.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *captureWriter) Header() http.Header {
|
||||||
|
if w.header == nil {
|
||||||
|
w.header = http.Header{}
|
||||||
|
}
|
||||||
|
return w.header
|
||||||
|
}
|
||||||
|
func (w *captureWriter) Write(p []byte) (int, error) { w.body = append(w.body, p...); return len(p), nil }
|
||||||
|
func (w *captureWriter) WriteHeader(statusCode int) {}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package moduletrust
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidToken = errors.New("moduletrust: ungueltiges token")
|
||||||
|
|
||||||
|
// KeyFetchFunc holt den aktuellen Schluesselsatz von Core (z.B. per HTTP-GET
|
||||||
|
// auf ServeJWKS + ParseJWKS). Wird vom Verifier nur bei abgelaufener TTL
|
||||||
|
// aufgerufen — NICHT bei jeder Verify()-Anfrage (Akzeptanzkriterium 1).
|
||||||
|
type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error)
|
||||||
|
|
||||||
|
// Verifier ist die Modulseite von API-05: verifiziert JWTs LOKAL gegen einen
|
||||||
|
// per StaleCache zwischengespeicherten Schluesselsatz, ohne pro Aufruf einen
|
||||||
|
// synchronen Request an Core zu stellen.
|
||||||
|
type Verifier struct {
|
||||||
|
cache *StaleCache[map[string]ed25519.PublicKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVerifier(ttl time.Duration, fetch KeyFetchFunc) *Verifier {
|
||||||
|
return &Verifier{cache: NewStaleCache(ttl, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||||
|
return fetch(ctx)
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify prueft die Signatur LOKAL gegen den (ggf. abgelaufenen, aber
|
||||||
|
// vorhandenen) Schluesselsatz — FAIL-OPEN fuer bereits ausgestellte Tokens
|
||||||
|
// (Akzeptanzkriterium 2): ist Core nicht erreichbar, aber ein alter
|
||||||
|
// Schluesselsatz bekannt, wird damit weiter verifiziert.
|
||||||
|
func (v *Verifier) Verify(ctx context.Context, tokenString string) (*Claims, error) {
|
||||||
|
keys, _, err := v.cache.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
kid, _ := t.Header["kid"].(string)
|
||||||
|
pub, ok := keys[kid]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireFreshKeys ruft IMMER frisch von Core ab (FAIL-CLOSED) — fuer
|
||||||
|
// sicherheitskritische Aktionen wie einen komplett neuen Login
|
||||||
|
// (Akzeptanzkriterium 2): schlaegt klar fehl, wenn Core nicht erreichbar
|
||||||
|
// ist, statt auf einem veralteten Schluesselsatz zu vertrauen.
|
||||||
|
func (v *Verifier) RequireFreshKeys(ctx context.Context) error {
|
||||||
|
_, err := v.cache.RequireFresh(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/apiserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyFunc bestimmt den Rate-Limit-Schluessel fuer eine Anfrage — typischerweise
|
||||||
|
// der Tenant-Slug aus apiserver.RequestContext, siehe KeyByTenant. Als
|
||||||
|
// eigenstaendiger Typ austauschbar (z.B. spaeter API-Token-basiert), ohne
|
||||||
|
// internal/apiserver aendern zu muessen (Kein Umbau angrenzender Bereiche).
|
||||||
|
type KeyFunc func(r *http.Request) (string, bool)
|
||||||
|
|
||||||
|
// KeyByTenant liest den Tenant aus dem von internal/apiserver.authAndTenantContext
|
||||||
|
// gesetzten RequestContext (IAM-02/API-01) — dieselbe Middleware-Kette,
|
||||||
|
// keine zweite Authentifizierung.
|
||||||
|
func KeyByTenant(r *http.Request) (string, bool) {
|
||||||
|
rc, ok := apiserver.FromContext(r.Context())
|
||||||
|
if !ok || rc.TenantSlug == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rc.TenantSlug, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware setzt sich VOR den eigentlichen Handler (nach Auth+Tenant-
|
||||||
|
// Kontext, siehe KeyByTenant) und lehnt Anfragen ueber dem konfigurierten
|
||||||
|
// Limit mit 429 + Retry-After ab (Akzeptanzkriterium 3).
|
||||||
|
func Middleware(store *Store, keyFn KeyFunc, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key, ok := keyFn(r)
|
||||||
|
if !ok {
|
||||||
|
// Kein Schluessel ermittelbar (z.B. kein Tenant-Kontext) — die
|
||||||
|
// Auth-Pruefung selbst ist Sache von authAndTenantContext, hier
|
||||||
|
// wird nur nicht limitiert, wenn schon kein Kontext vorliegt.
|
||||||
|
next(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := store.Allow(r.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
apiserver.WriteError(w, http.StatusInternalServerError, "rate_limit_error", "rate-limit-pruefung fehlgeschlagen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", result.Limit))
|
||||||
|
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", result.Remaining))
|
||||||
|
|
||||||
|
if !result.Allowed {
|
||||||
|
retryAfterSeconds := int(math.Ceil(result.RetryAfter.Seconds()))
|
||||||
|
if retryAfterSeconds < 1 {
|
||||||
|
retryAfterSeconds = 1
|
||||||
|
}
|
||||||
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfterSeconds))
|
||||||
|
apiserver.WriteError(w, http.StatusTooManyRequests, "rate_limit_exceeded",
|
||||||
|
"zu viele anfragen, bitte spaeter erneut versuchen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/apiserver"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupMiddlewareTest(t *testing.T) (*apiserver.Server, *auth.TokenIssuer, *Store, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_configs (
|
||||||
|
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_counters (
|
||||||
|
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (key, window_start)
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
srv := apiserver.NewServer(issuer)
|
||||||
|
store := NewStore(pool)
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return srv, issuer, store, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: Ueberschreitung liefert 429 mit
|
||||||
|
// Retry-After-Header, ueber die vollstaendige Middleware-Kette (Auth ->
|
||||||
|
// Tenant-Kontext -> Rate-Limit -> Handler) hinweg.
|
||||||
|
func TestMiddleware_Returns429WithRetryAfterOnExceeded(t *testing.T) {
|
||||||
|
srv, issuer, store, cleanup := setupMiddlewareTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tenantSlug := newTestKey()
|
||||||
|
if err := store.SetLimit(ctx, tenantSlug, 1, time.Minute); err != nil {
|
||||||
|
t.Fatalf("setlimit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
called := 0
|
||||||
|
srv.HandleV1("/limited", Middleware(store, KeyByTenant, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called++
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
token, err := issuer.Issue("user-1", tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/limited", nil)
|
||||||
|
req1.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
|
||||||
|
rec1 := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec1, req1)
|
||||||
|
if rec1.Code != http.StatusOK {
|
||||||
|
t.Fatalf("1. anfrage: status = %d, want 200", rec1.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/limited", nil)
|
||||||
|
req2.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("2. anfrage: status = %d, want 429", rec2.Code)
|
||||||
|
}
|
||||||
|
if rec2.Header().Get("Retry-After") == "" {
|
||||||
|
t.Fatal("erwartet gesetzten Retry-After-Header bei 429")
|
||||||
|
}
|
||||||
|
if called != 1 {
|
||||||
|
t.Fatalf("handler wurde %d mal aufgerufen, want genau 1 (2. anfrage haette nicht durchgereicht werden duerfen)", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: das Limit ist pro Tenant konfigurierbar — ein
|
||||||
|
// anderer Tenant (anderer Key) bleibt von der Ausschoepfung unberuehrt.
|
||||||
|
func TestMiddleware_DifferentTenantsHaveIndependentLimits(t *testing.T) {
|
||||||
|
srv, issuer, store, cleanup := setupMiddlewareTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tenantA := newTestKey()
|
||||||
|
tenantB := newTestKey()
|
||||||
|
if err := store.SetLimit(ctx, tenantA, 1, time.Minute); err != nil {
|
||||||
|
t.Fatalf("setlimit a: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetLimit(ctx, tenantB, 1, time.Minute); err != nil {
|
||||||
|
t.Fatalf("setlimit b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.HandleV1("/limited2", Middleware(store, KeyByTenant, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
tokenA, _ := issuer.Issue("user-a", tenantA)
|
||||||
|
tokenB, _ := issuer.Issue("user-b", tenantB)
|
||||||
|
|
||||||
|
// Tenant A schoepft sein Limit aus.
|
||||||
|
reqA := httptest.NewRequest(http.MethodGet, "/api/v1/limited2", nil)
|
||||||
|
reqA.AddCookie(&http.Cookie{Name: auth.CookieName, Value: tokenA})
|
||||||
|
recA := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(recA, reqA)
|
||||||
|
if recA.Code != http.StatusOK {
|
||||||
|
t.Fatalf("tenant a, 1. anfrage: status = %d, want 200", recA.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant B ist von der Ausschoepfung bei A unberuehrt.
|
||||||
|
reqB := httptest.NewRequest(http.MethodGet, "/api/v1/limited2", nil)
|
||||||
|
reqB.AddCookie(&http.Cookie{Name: auth.CookieName, Value: tokenB})
|
||||||
|
recB := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(recB, reqB)
|
||||||
|
if recB.Code != http.StatusOK {
|
||||||
|
t.Fatalf("tenant b haette trotz ausgeschoepftem limit von tenant a erlaubt sein muessen, status = %d", recB.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Package ratelimit implementiert Core API-03: eine zentrale Rate-Limiting-
|
||||||
|
// Schicht fuer die gesamte Core-API mit GETEILTEM, EXTERNEM Zustand in
|
||||||
|
// Postgres — kein In-Process-Zaehler (siehe "Bekannte Fehler vermeiden" im
|
||||||
|
// Ticket: dasselbe Risiko wie bei IAM-07s In-Memory-Lockout). Fixed-Window-
|
||||||
|
// Algorithmus: einfach, korrekt unter nebenlaeufigem Zugriff (atomares
|
||||||
|
// UPSERT wie internal/usage.Store.Increment), und fuer ein API-Gateway
|
||||||
|
// ausreichend praezise — kein Sliding-Window-Overhead noetig.
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLimit/DefaultWindow gelten fuer jeden Schluessel (Tenant oder
|
||||||
|
// API-Token), fuer den keine eigene Konfiguration existiert
|
||||||
|
// (Akzeptanzkriterium 1: "konfigurierbar", nicht "verpflichtend konfiguriert").
|
||||||
|
const (
|
||||||
|
DefaultLimit = 100
|
||||||
|
DefaultWindow = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config ist das je Schluessel konfigurierbare Limit.
|
||||||
|
type Config struct {
|
||||||
|
Limit int
|
||||||
|
Window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store haelt Konfiguration UND Zaehlerstand in Postgres — beides ueber
|
||||||
|
// dieselbe Verbindung erreichbar, damit mehrere Dienstinstanzen denselben
|
||||||
|
// Zustand sehen (Akzeptanzkriterium 2).
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLimit setzt die Konfiguration fuer EINEN Schluessel (z.B. einen
|
||||||
|
// Tenant-Slug oder eine API-Token-ID) — wirkt sich nicht auf andere
|
||||||
|
// Schluessel aus (Akzeptanzkriterium 1 / Pruefung 3).
|
||||||
|
func (s *Store) SetLimit(ctx context.Context, key string, limit int, window time.Duration) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO rate_limit_configs (key, limit_value, window_seconds)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET limit_value = $2, window_seconds = $3
|
||||||
|
`, key, limit, int(window.Seconds()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rate-limit-konfiguration speichern: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getConfig(ctx context.Context, key string) (Config, error) {
|
||||||
|
var limit, windowSeconds int
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT limit_value, window_seconds FROM rate_limit_configs WHERE key = $1
|
||||||
|
`, key).Scan(&limit, &windowSeconds)
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return Config{Limit: DefaultLimit, Window: DefaultWindow}, nil
|
||||||
|
}
|
||||||
|
return Config{}, fmt.Errorf("rate-limit-konfiguration lesen: %w", err)
|
||||||
|
}
|
||||||
|
return Config{Limit: limit, Window: time.Duration(windowSeconds) * time.Second}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result ist der Ausgang einer Allow-Pruefung (Akzeptanzkriterium 3).
|
||||||
|
type Result struct {
|
||||||
|
Allowed bool
|
||||||
|
Limit int
|
||||||
|
Remaining int
|
||||||
|
RetryAfter time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow erhoeht den Zaehler fuer (key, aktuelles Zeitfenster) ATOMAR ueber
|
||||||
|
// ein einziges UPSERT (dasselbe Muster wie internal/usage.Store.Increment)
|
||||||
|
// und vergleicht das Ergebnis gegen die konfigurierte Grenze — kein
|
||||||
|
// Lesen-Erhoehen-Schreiben in Go, damit zwei Dienstinstanzen, die
|
||||||
|
// gleichzeitig gegen dieselbe Datenbank inkrementieren, sich niemals
|
||||||
|
// gegenseitig ueberschreiben (Akzeptanzkriterium 2 / Pruefung 1).
|
||||||
|
func (s *Store) Allow(ctx context.Context, key string) (Result, error) {
|
||||||
|
cfg, err := s.getConfig(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
windowSeconds := int64(cfg.Window.Seconds())
|
||||||
|
if windowSeconds <= 0 {
|
||||||
|
windowSeconds = int64(DefaultWindow.Seconds())
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
windowStart := time.Unix((now.Unix()/windowSeconds)*windowSeconds, 0).UTC()
|
||||||
|
windowEnd := windowStart.Add(time.Duration(windowSeconds) * time.Second)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO rate_limit_counters (key, window_start, count)
|
||||||
|
VALUES ($1, $2, 1)
|
||||||
|
ON CONFLICT (key, window_start) DO UPDATE
|
||||||
|
SET count = rate_limit_counters.count + 1
|
||||||
|
RETURNING count
|
||||||
|
`, key, windowStart).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("rate-limit-zaehler erhoehen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := cfg.Limit - count
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
if count > cfg.Limit {
|
||||||
|
return Result{
|
||||||
|
Allowed: false,
|
||||||
|
Limit: cfg.Limit,
|
||||||
|
Remaining: 0,
|
||||||
|
RetryAfter: windowEnd.Sub(now),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return Result{Allowed: true, Limit: cfg.Limit, Remaining: remaining}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*pgxpool.Pool, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_configs (
|
||||||
|
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_counters (
|
||||||
|
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (key, window_start)
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestKey() string {
|
||||||
|
return fmt.Sprintf("test-key-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: Ueberschreitung des Limits wird
|
||||||
|
// nachweislich erkannt.
|
||||||
|
func TestAllow_RejectsAfterLimitExceeded(t *testing.T) {
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
store := NewStore(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
key := newTestKey()
|
||||||
|
|
||||||
|
if err := store.SetLimit(ctx, key, 3, time.Minute); err != nil {
|
||||||
|
t.Fatalf("setlimit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
res, err := store.Allow(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("allow %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if !res.Allowed {
|
||||||
|
t.Fatalf("anfrage %d haette erlaubt sein muessen, limit=3", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := store.Allow(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("allow (4.): %v", err)
|
||||||
|
}
|
||||||
|
if res.Allowed {
|
||||||
|
t.Fatal("4. anfrage haette abgelehnt werden muessen (limit=3)")
|
||||||
|
}
|
||||||
|
if res.RetryAfter <= 0 {
|
||||||
|
t.Fatalf("erwartet positive retry-after-dauer, habe %v", res.RetryAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 / Pruefung 3: Konfigurationsaenderung wirkt sich
|
||||||
|
// NICHT auf andere Schluessel (Tenants) aus.
|
||||||
|
func TestSetLimit_DoesNotAffectOtherKeys(t *testing.T) {
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
store := NewStore(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
keyA := newTestKey()
|
||||||
|
keyB := newTestKey()
|
||||||
|
|
||||||
|
if err := store.SetLimit(ctx, keyA, 1, time.Minute); err != nil {
|
||||||
|
t.Fatalf("setlimit a: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resA1, _ := store.Allow(ctx, keyA)
|
||||||
|
resA2, _ := store.Allow(ctx, keyA)
|
||||||
|
if !resA1.Allowed || resA2.Allowed {
|
||||||
|
t.Fatalf("key a: erwartet erlaubt,abgelehnt, habe %v,%v", resA1.Allowed, resA2.Allowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyB hat keine eigene Konfiguration -> DefaultLimit, unbeeinflusst von keyA.
|
||||||
|
resB, err := store.Allow(ctx, keyB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("allow b: %v", err)
|
||||||
|
}
|
||||||
|
if !resB.Allowed {
|
||||||
|
t.Fatal("key b haette trotz limit-ueberschreitung bei key a erlaubt sein muessen")
|
||||||
|
}
|
||||||
|
if resB.Limit != DefaultLimit {
|
||||||
|
t.Fatalf("key b limit = %d, want default %d", resB.Limit, DefaultLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: zwei "Dienstinstanzen" (zwei
|
||||||
|
// unabhaengige Pools/Store-Objekte) gegen dieselbe Datenbank setzen
|
||||||
|
// dasselbe Limit korrekt durch — kein In-Process-Zustand kann das leisten.
|
||||||
|
func TestAllow_TwoInstancesShareStateCorrectly(t *testing.T) {
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool2, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zweiter pool (simuliert zweite instanz): %v", err)
|
||||||
|
}
|
||||||
|
defer pool2.Close()
|
||||||
|
|
||||||
|
storeInstance1 := NewStore(pool)
|
||||||
|
storeInstance2 := NewStore(pool2)
|
||||||
|
|
||||||
|
key := newTestKey()
|
||||||
|
const limit = 20
|
||||||
|
if err := storeInstance1.SetLimit(ctx, key, limit, time.Minute); err != nil {
|
||||||
|
t.Fatalf("setlimit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRequests = 50
|
||||||
|
var allowedCount int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < totalRequests; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
store := storeInstance1
|
||||||
|
if i%2 == 0 {
|
||||||
|
store = storeInstance2 // Haelfte der Anfragen "kommt" von der zweiten Instanz
|
||||||
|
}
|
||||||
|
go func(s *Store) {
|
||||||
|
defer wg.Done()
|
||||||
|
res, err := s.Allow(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("allow: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.Allowed {
|
||||||
|
atomic.AddInt64(&allowedCount, 1)
|
||||||
|
}
|
||||||
|
}(store)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if allowedCount != limit {
|
||||||
|
t.Fatalf("erlaubte anfragen ueber beide instanzen = %d, want exakt %d (geteilter zustand)", allowedCount, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrMissingTenantContext wird geliefert, wenn keine Tenant-Kennung
|
||||||
|
// uebergeben wurde — es gibt bewusst keinen impliziten Default-Tenant
|
||||||
|
// (TEN-06 Akzeptanzkriterium 3).
|
||||||
|
var ErrMissingTenantContext = errors.New("tenant: kein tenant-kontext angegeben")
|
||||||
|
|
||||||
|
// ErrUnknownTenant wird geliefert, wenn die Tenant-Kennung in der Registry
|
||||||
|
// nicht existiert.
|
||||||
|
var ErrUnknownTenant = errors.New("tenant: unbekannter tenant")
|
||||||
|
|
||||||
|
// Router loest den Tenant-Kontext (Slug, aus dem JWT-Claim von API-05) in
|
||||||
|
// eine wiederverwendbare Verbindung zur richtigen Tenant-Datenbank auf.
|
||||||
|
// Ein LRU-verwalteter Cache begrenzt die Zahl gleichzeitig offener
|
||||||
|
// pgxpool.Pool-Instanzen, damit die Zahl offener Postgres-Verbindungen NICHT
|
||||||
|
// linear mit der Mandantenzahl waechst (Akzeptanzkriterium 2).
|
||||||
|
type Router struct {
|
||||||
|
registry *Registry
|
||||||
|
maxOpen int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
order *list.List // vorne = zuletzt benutzt
|
||||||
|
items map[string]*list.Element // slug -> element mit *routerEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type routerEntry struct {
|
||||||
|
slug string
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(registry *Registry, maxOpen int) *Router {
|
||||||
|
if maxOpen < 1 {
|
||||||
|
maxOpen = 1
|
||||||
|
}
|
||||||
|
return &Router{
|
||||||
|
registry: registry,
|
||||||
|
maxOpen: maxOpen,
|
||||||
|
order: list.New(),
|
||||||
|
items: make(map[string]*list.Element),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve liefert einen wiederverwendeten Pool fuer den angegebenen Tenant.
|
||||||
|
// Ist der Tenant bereits im Cache, wird KEINE neue Verbindung aufgebaut
|
||||||
|
// (Akzeptanzkriterium 2 / Pruefung 3).
|
||||||
|
func (r *Router) Resolve(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) {
|
||||||
|
if tenantSlug == "" {
|
||||||
|
return nil, ErrMissingTenantContext
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
if el, ok := r.items[tenantSlug]; ok {
|
||||||
|
r.order.MoveToFront(el)
|
||||||
|
pool := el.Value.(*routerEntry).pool
|
||||||
|
r.mu.Unlock()
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Registry-Lookup und Verbindungsaufbau bewusst ausserhalb des Locks,
|
||||||
|
// damit ein langsamer Verbindungsaufbau nicht alle anderen Tenants blockiert.
|
||||||
|
t, err := r.registry.GetBySlug(ctx, tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verbindung zu tenant %q aufbauen: %w", tenantSlug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// Zwischen Unlock oben und hier koennte ein paralleler Aufruf denselben
|
||||||
|
// Tenant bereits eingefuegt haben — dann die eigene, ueberzaehlige
|
||||||
|
// Verbindung wieder schliessen und die vorhandene verwenden.
|
||||||
|
if el, ok := r.items[tenantSlug]; ok {
|
||||||
|
r.order.MoveToFront(el)
|
||||||
|
existing := el.Value.(*routerEntry).pool
|
||||||
|
pool.Close()
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
el := r.order.PushFront(&routerEntry{slug: tenantSlug, pool: pool})
|
||||||
|
r.items[tenantSlug] = el
|
||||||
|
|
||||||
|
if r.order.Len() > r.maxOpen {
|
||||||
|
r.evictOldest()
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictOldest schliesst den am laengsten nicht genutzten Pool. Muss mit
|
||||||
|
// gehaltenem r.mu aufgerufen werden.
|
||||||
|
func (r *Router) evictOldest() {
|
||||||
|
oldest := r.order.Back()
|
||||||
|
if oldest == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := oldest.Value.(*routerEntry)
|
||||||
|
r.order.Remove(oldest)
|
||||||
|
delete(r.items, entry.slug)
|
||||||
|
entry.pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCount liefert die aktuelle Zahl offen gehaltener Tenant-Pools —
|
||||||
|
// dient Tests/Monitoring, um AC2 nachzuweisen.
|
||||||
|
func (r *Router) OpenCount() int {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.order.Len()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close schliesst alle offen gehaltenen Tenant-Pools, z.B. beim
|
||||||
|
// Herunterfahren des Core-Prozesses.
|
||||||
|
func (r *Router) Close() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for el := r.order.Front(); el != nil; el = el.Next() {
|
||||||
|
el.Value.(*routerEntry).pool.Close()
|
||||||
|
}
|
||||||
|
r.order.Init()
|
||||||
|
r.items = make(map[string]*list.Element)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin pool: %v", err)
|
||||||
|
}
|
||||||
|
registryPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := registryPool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
db_name TEXT NOT NULL UNIQUE,
|
||||||
|
db_dsn TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("registry-schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := NewRegistry(registryPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
|
||||||
|
var tenants []Tenant
|
||||||
|
var slugs []string
|
||||||
|
for i := 0; i < tenantCount; i++ {
|
||||||
|
slug := fmt.Sprintf("router_t%d", i)
|
||||||
|
slugs = append(slugs, slug)
|
||||||
|
tn, err := provisioner.Provision(ctx, slug, slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
tenants = append(tenants, tn)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := NewRouter(registry, 2) // klein gewaehlt, um Eviction im Test zu erzwingen
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
router.Close()
|
||||||
|
for _, slug := range slugs {
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
||||||
|
}
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
||||||
|
registryPool.Close()
|
||||||
|
adminPool.Close()
|
||||||
|
}
|
||||||
|
return router, tenants, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Verbindung wird zuverlaessig anhand des Tenant-Kontexts aufgeloest.
|
||||||
|
func TestRouter_ResolvesCorrectTenantDatabase(t *testing.T) {
|
||||||
|
router, tenants, cleanup := newTestRouterSetup(t, 2)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
var dbName string
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&dbName); err != nil {
|
||||||
|
t.Fatalf("current_database: %v", err)
|
||||||
|
}
|
||||||
|
if dbName != tenants[0].DBName {
|
||||||
|
t.Fatalf("current_database() = %q, want %q", dbName, tenants[0].DBName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: fehlender/unbekannter Tenant-Kontext
|
||||||
|
// wird explizit abgewiesen statt irgendeine Verbindung zu liefern.
|
||||||
|
func TestRouter_RejectsMissingOrUnknownTenant(t *testing.T) {
|
||||||
|
router, _, cleanup := newTestRouterSetup(t, 1)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := router.Resolve(ctx, ""); !errors.Is(err, ErrMissingTenantContext) {
|
||||||
|
t.Fatalf("erwartet ErrMissingTenantContext, habe %v", err)
|
||||||
|
}
|
||||||
|
if _, err := router.Resolve(ctx, "nie-registrierter-slug"); !errors.Is(err, ErrUnknownTenant) {
|
||||||
|
t.Fatalf("erwartet ErrUnknownTenant, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 3: Verbindungswiederverwendung nachweislich
|
||||||
|
// gemessen — zweiter Resolve-Aufruf liefert exakt denselben Pool, kein
|
||||||
|
// erneuter Verbindungsaufbau.
|
||||||
|
func TestRouter_ReusesConnectionForSameTenant(t *testing.T) {
|
||||||
|
router, tenants, cleanup := newTestRouterSetup(t, 1)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
first, err := router.Resolve(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve 1: %v", err)
|
||||||
|
}
|
||||||
|
second, err := router.Resolve(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve 2: %v", err)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Fatal("erwartet identische pool-instanz bei wiederholtem resolve, habe unterschiedliche")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: Lasttest mit mehr simulierten Mandanten
|
||||||
|
// als maxOpen — die Zahl gleichzeitig offener Tenant-Pools bleibt begrenzt
|
||||||
|
// (LRU-Eviction), waechst also NICHT linear mit der Mandantenzahl.
|
||||||
|
func TestRouter_BoundsOpenConnectionsUnderLoad(t *testing.T) {
|
||||||
|
const tenantCount = 6
|
||||||
|
router, tenants, cleanup := newTestRouterSetup(t, tenantCount)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, tn := range tenants {
|
||||||
|
if _, err := router.Resolve(ctx, tn.Slug); err != nil {
|
||||||
|
t.Fatalf("resolve %s: %v", tn.Slug, err)
|
||||||
|
}
|
||||||
|
if router.OpenCount() > 2 {
|
||||||
|
t.Fatalf("OpenCount() = %d, erwartet <= maxOpen (2) nach jedem Resolve", router.OpenCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if router.OpenCount() != 2 {
|
||||||
|
t.Fatalf("erwartet genau maxOpen=2 offene pools nach %d tenants, habe %d", tenantCount, router.OpenCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evictete Tenants sind wieder ganz normal ueber die Registry aufloesbar
|
||||||
|
// (Cache-Miss fuehrt zu neuem, funktionierendem Pool, kein Fehlerzustand).
|
||||||
|
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve nach eviction: %v", err)
|
||||||
|
}
|
||||||
|
var one int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil {
|
||||||
|
t.Fatalf("query nach re-resolve: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
|
||||||
|
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
|
||||||
|
// dieser Kachel und daher hier noch nicht angebunden.
|
||||||
|
type Handler struct {
|
||||||
|
users *TenantUserStore
|
||||||
|
superadmins *SuperadminStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
|
||||||
|
return &Handler{users: users, superadmins: superadmins}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createUserRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createUserRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.users.Create(r.Context(), req.Email, req.Name)
|
||||||
|
writeUserResult(w, u, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
|
||||||
|
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
|
||||||
|
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createUserRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
|
||||||
|
writeUserResult(w, u, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUserResult(w http.ResponseWriter, u User, err error) {
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
default:
|
||||||
|
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(u)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
|
||||||
|
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
|
||||||
|
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
|
||||||
|
// fuer dasselbe Muster.
|
||||||
|
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin pool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||||
|
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
||||||
|
t.Fatalf("testdatenbank anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect testdatenbank: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
|
||||||
|
t.Fatalf("schema anwenden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Close()
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
||||||
|
adminPool.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersSchema = `
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);`
|
||||||
|
|
||||||
|
const superadminsSchema = `
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE TABLE superadmins (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);`
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
|
||||||
|
func TestTenantUserStore_CRUD(t *testing.T) {
|
||||||
|
pool := setupTestDB(t, "test_iam01_users", usersSchema)
|
||||||
|
store := NewTenantUserStore(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := store.Create(ctx, "alice@example.com", "Alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if created.Status != StatusActive {
|
||||||
|
t.Fatalf("erwartet status active, hat %q", created.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.Get(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Email != "alice@example.com" {
|
||||||
|
t.Fatalf("get email = %q", got.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
|
||||||
|
t.Fatalf("update ergebnis unerwartet: %+v", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := store.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivated, err := store.Deactivate(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deactivate: %v", err)
|
||||||
|
}
|
||||||
|
if deactivated.Status != StatusInactive {
|
||||||
|
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negativfall: doppelte E-Mail-Adresse.
|
||||||
|
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
|
||||||
|
t.Fatalf("create second: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
|
||||||
|
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negativfall: fehlender Benutzer.
|
||||||
|
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
|
||||||
|
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
|
||||||
|
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
|
||||||
|
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
|
||||||
|
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
|
||||||
|
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
|
||||||
|
store := NewSuperadminStore(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create superadmin: %v", err)
|
||||||
|
}
|
||||||
|
if created.Status != StatusActive {
|
||||||
|
t.Fatalf("erwartet status active, hat %q", created.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.Get(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Email != "root@nexarch.internal" {
|
||||||
|
t.Fatalf("get email = %q", got.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
|
||||||
|
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivated, err := store.Deactivate(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deactivate: %v", err)
|
||||||
|
}
|
||||||
|
if deactivated.Status != StatusInactive {
|
||||||
|
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
|
||||||
|
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
|
||||||
|
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
|
||||||
|
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
|
||||||
|
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
|
||||||
|
type SuperadminStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
|
||||||
|
return &SuperadminStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
|
||||||
|
if err := ValidateEmail(email); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var u User
|
||||||
|
u.Email, u.Name, u.Status = email, name, StatusActive
|
||||||
|
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO superadmins (email, name, status)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`, u.Email, u.Name, u.Status)
|
||||||
|
|
||||||
|
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return User{}, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at
|
||||||
|
FROM superadmins WHERE id = $1
|
||||||
|
`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at
|
||||||
|
FROM superadmins ORDER BY created_at
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("superadmins auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("superadmin lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE superadmins SET status = $2, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, name, status, created_at, updated_at
|
||||||
|
`, id, StatusInactive))
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
|
||||||
|
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
|
||||||
|
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
|
||||||
|
type TenantUserStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
|
||||||
|
return &TenantUserStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
|
||||||
|
if err := ValidateEmail(email); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var u User
|
||||||
|
u.Email, u.Name, u.Status = email, name, StatusActive
|
||||||
|
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (email, name, status)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, created_at, updated_at
|
||||||
|
`, u.Email, u.Name, u.Status)
|
||||||
|
|
||||||
|
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return User{}, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
`, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at
|
||||||
|
FROM users ORDER BY created_at
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("benutzer auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []User
|
||||||
|
for rows.Next() {
|
||||||
|
var u User
|
||||||
|
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("benutzer lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
|
||||||
|
// Feld unveraendert.
|
||||||
|
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
|
||||||
|
if email != "" {
|
||||||
|
if err := ValidateEmail(email); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET email = COALESCE(NULLIF($2, ''), email),
|
||||||
|
name = COALESCE(NULLIF($3, ''), name),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, name, status, created_at, updated_at
|
||||||
|
`, id, email, name)
|
||||||
|
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, mapWriteErr(err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
|
||||||
|
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
|
||||||
|
return scanUser(s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE users SET status = $2, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, name, status, created_at, updated_at
|
||||||
|
`, id, StatusInactive))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe
|
||||||
|
// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort.
|
||||||
|
func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
|
||||||
|
`, id, hash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("passwort setzen: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth)
|
||||||
|
// verwendet und traegt bewusst den password_hash, damit er nicht ueber den
|
||||||
|
// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann.
|
||||||
|
type AuthCredentials struct {
|
||||||
|
User User
|
||||||
|
PasswordHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
|
||||||
|
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
|
||||||
|
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
|
||||||
|
// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe
|
||||||
|
// IAM-02 "Bekannte Fehler vermeiden").
|
||||||
|
func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) {
|
||||||
|
var c AuthCredentials
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at, password_hash
|
||||||
|
FROM users WHERE email = $1
|
||||||
|
`, email)
|
||||||
|
|
||||||
|
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
|
||||||
|
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return AuthCredentials{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanUser(row pgx.Row) (User, error) {
|
||||||
|
var u User
|
||||||
|
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return User{}, fmt.Errorf("benutzer lesen: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
|
||||||
|
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
|
||||||
|
func mapWriteErr(err error) error {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return ErrEmailTaken
|
||||||
|
}
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("benutzer schreiben: %w", err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
|
||||||
|
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
|
||||||
|
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
|
||||||
|
// dagegen mandantenuebergreifend in der Registry und sind ueber
|
||||||
|
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
|
||||||
|
// tenant_id-NULL-Sonderfall in User.
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActive Status = "active"
|
||||||
|
StatusInactive Status = "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Status Status
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
||||||
|
|
||||||
|
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
|
||||||
|
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
|
||||||
|
var ErrNotFound = errors.New("user: nicht gefunden")
|
||||||
|
|
||||||
|
func ValidateEmail(email string) error {
|
||||||
|
if !emailPattern.MatchString(email) {
|
||||||
|
return ErrInvalidEmail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateEmail(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
email string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"a@b.de", false},
|
||||||
|
{"a.b+c@sub.example.com", false},
|
||||||
|
{"", true},
|
||||||
|
{"keine-email", true},
|
||||||
|
{"a@b", true},
|
||||||
|
{"@b.de", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
err := ValidateEmail(c.email)
|
||||||
|
if (err != nil) != c.wantErr {
|
||||||
|
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS superadmins;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
|
||||||
|
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
|
||||||
|
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
|
||||||
|
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
|
||||||
|
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
|
||||||
|
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
|
||||||
|
CREATE TABLE superadmins (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS audit_events;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md).
|
|
||||||
-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene
|
|
||||||
-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework.
|
|
||||||
-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 /
|
|
||||||
-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten
|
|
||||||
-- Wert 'system', niemals NULL oder leeren String.
|
|
||||||
CREATE TABLE audit_events (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
|
|
||||||
actor TEXT NOT NULL CHECK (actor <> ''),
|
|
||||||
action TEXT NOT NULL CHECK (action <> ''),
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at);
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS users;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
|
||||||
|
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
|
||||||
|
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
|
||||||
|
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
|
||||||
|
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
|
||||||
|
-- nur innerhalb dieser einen Tenant-Datenbank.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN password_hash;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Passwort-Hash-Spalte fuer Login (IAM-02, siehe core-kanban/tickets/IAM-02.md).
|
||||||
|
-- Enthaelt AUSSCHLIESSLICH den bcrypt-Hash, niemals das Klartext-Passwort.
|
||||||
|
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
|
|
||||||
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
|
|
||||||
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
|
|
||||||
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
|
|
||||||
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
|
||||||
#
|
|
||||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||||
@@ -14,7 +7,6 @@ ROLE="nexarch_test"
|
|||||||
export PGPASSWORD="$PASS"
|
export PGPASSWORD="$PASS"
|
||||||
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
|
|
||||||
|
|
||||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||||
for db in $dbs; do
|
for db in $dbs; do
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
|
|
||||||
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
|
|
||||||
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
|
|
||||||
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
|
||||||
#
|
|
||||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "NEXARCH Audit-Log",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="de">
|
|
||||||
<body
|
|
||||||
style={{
|
|
||||||
fontFamily: typography.fontFamily,
|
|
||||||
margin: 0,
|
|
||||||
background: "var(--shl-color-background, #ffffff)",
|
|
||||||
color: "var(--shl-color-text-primary, #14181f)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ThemeProvider>
|
|
||||||
<I18nProvider initialLocale="de">
|
|
||||||
<ToastProvider>{children}</ToastProvider>
|
|
||||||
</I18nProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { fetchRecords, exportURL, type AuditRecord, type AuditFilter } from "@/lib/api";
|
|
||||||
|
|
||||||
const EMPTY_FILTER: AuditFilter = { tenant: "", actor: "", action: "", from: "", to: "" };
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const [caller, setCaller] = useState("");
|
|
||||||
const [filter, setFilter] = useState<AuditFilter>(EMPTY_FILTER);
|
|
||||||
const [records, setRecords] = useState<AuditRecord[] | null>(null);
|
|
||||||
const [selected, setSelected] = useState<AuditRecord | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function onSearch() {
|
|
||||||
setError(null);
|
|
||||||
setSelected(null);
|
|
||||||
try {
|
|
||||||
const data = await fetchRecords(caller.trim(), filter);
|
|
||||||
setRecords(data);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message ?? "Unbekannter Fehler");
|
|
||||||
setRecords(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onExport(format: "csv" | "json") {
|
|
||||||
const url = exportURL(caller.trim(), filter, format);
|
|
||||||
// Echter Datei-Download ueber Browser-Navigation, KEIN erneutes Parsen
|
|
||||||
// im Frontend (Akzeptanzkriterium 2: Export aus AUD-03 direkt ausloesbar).
|
|
||||||
window.open(url, "_blank");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main style={{ maxWidth: 1000, margin: "0 auto", padding: "2rem 1rem" }}>
|
|
||||||
<h1>Audit-Log</h1>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "0.5rem", gridTemplateColumns: "repeat(3, 1fr)", marginBottom: "1rem" }}>
|
|
||||||
<input
|
|
||||||
value={caller}
|
|
||||||
onChange={(e) => setCaller(e.target.value)}
|
|
||||||
placeholder="Admin-Token (Berechtigung)"
|
|
||||||
style={{ padding: "0.5rem", gridColumn: "span 3" }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={filter.tenant}
|
|
||||||
onChange={(e) => setFilter({ ...filter, tenant: e.target.value })}
|
|
||||||
placeholder="Tenant"
|
|
||||||
style={{ padding: "0.5rem" }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={filter.actor}
|
|
||||||
onChange={(e) => setFilter({ ...filter, actor: e.target.value })}
|
|
||||||
placeholder="Akteur"
|
|
||||||
style={{ padding: "0.5rem" }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={filter.action}
|
|
||||||
onChange={(e) => setFilter({ ...filter, action: e.target.value })}
|
|
||||||
placeholder="Aktion"
|
|
||||||
style={{ padding: "0.5rem" }}
|
|
||||||
/>
|
|
||||||
<label>
|
|
||||||
Von
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={filter.from}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilter({ ...filter, from: e.target.value ? new Date(e.target.value).toISOString() : "" })
|
|
||||||
}
|
|
||||||
style={{ width: "100%", padding: "0.4rem" }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Bis
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={filter.to}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilter({ ...filter, to: e.target.value ? new Date(e.target.value).toISOString() : "" })
|
|
||||||
}
|
|
||||||
style={{ width: "100%", padding: "0.4rem" }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
|
||||||
<button onClick={onSearch}>Suchen</button>
|
|
||||||
<button onClick={() => onExport("csv")}>Export als CSV</button>
|
|
||||||
<button onClick={() => onExport("json")}>Export als JSON</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p style={{ color: "#c62828" }} role="alert">
|
|
||||||
Fehler: {error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{records && (
|
|
||||||
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
|
|
||||||
<th style={{ padding: "0.5rem" }}>Zeitpunkt</th>
|
|
||||||
<th style={{ padding: "0.5rem" }}>Tenant</th>
|
|
||||||
<th style={{ padding: "0.5rem" }}>Akteur</th>
|
|
||||||
<th style={{ padding: "0.5rem" }}>Aktion</th>
|
|
||||||
<th style={{ padding: "0.5rem" }}></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{records.map((r, i) => (
|
|
||||||
<tr key={i} style={{ borderBottom: "1px solid #eee" }}>
|
|
||||||
<td style={{ padding: "0.5rem" }}>{new Date(r.occurred_at).toLocaleString("de-DE")}</td>
|
|
||||||
<td style={{ padding: "0.5rem" }}>{r.tenant_slug}</td>
|
|
||||||
<td style={{ padding: "0.5rem" }}>{r.actor}</td>
|
|
||||||
<td style={{ padding: "0.5rem" }}>{r.action}</td>
|
|
||||||
<td style={{ padding: "0.5rem" }}>
|
|
||||||
<button onClick={() => setSelected(r)}>Details</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{records.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} style={{ padding: "0.5rem" }}>
|
|
||||||
Keine Einträge gefunden.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selected && (
|
|
||||||
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
|
|
||||||
<h2>Eintrag-Details</h2>
|
|
||||||
<dl>
|
|
||||||
<dt>Zeitpunkt</dt>
|
|
||||||
<dd>{new Date(selected.occurred_at).toLocaleString("de-DE")}</dd>
|
|
||||||
<dt>Tenant</dt>
|
|
||||||
<dd>{selected.tenant_slug}</dd>
|
|
||||||
<dt>Akteur</dt>
|
|
||||||
<dd>{selected.actor}</dd>
|
|
||||||
<dt>Aktion</dt>
|
|
||||||
<dd>{selected.action}</dd>
|
|
||||||
<dt>Ziel</dt>
|
|
||||||
<dd>{selected.target}</dd>
|
|
||||||
<dt>Metadaten</dt>
|
|
||||||
<dd>
|
|
||||||
<pre style={{ whiteSpace: "pre-wrap", background: "#f5f6f8", padding: "0.5rem" }}>
|
|
||||||
{JSON.stringify(selected.metadata, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
<button onClick={() => setSelected(null)}>Schließen</button>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// Duenner Client der AUD-03-Export-API (internal/audit.ExportHandler) —
|
|
||||||
// keine eigene Aggregations-/Filterlogik im Frontend (Ticket-Vorgabe:
|
|
||||||
// "reiner Konsument der Export-API"). Die JSON-Lines-Antwort wird nur
|
|
||||||
// dekodiert, nicht neu berechnet oder gefiltert.
|
|
||||||
export type AuditRecord = {
|
|
||||||
occurred_at: string;
|
|
||||||
tenant_slug: string;
|
|
||||||
actor: string;
|
|
||||||
action: string;
|
|
||||||
target: string;
|
|
||||||
metadata: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuditFilter = {
|
|
||||||
tenant: string;
|
|
||||||
actor: string;
|
|
||||||
action: string;
|
|
||||||
from: string; // RFC3339, leer = kein Filter
|
|
||||||
to: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function apiBase(): string {
|
|
||||||
const base = process.env.NEXT_PUBLIC_AUDITLOG_API_URL;
|
|
||||||
if (!base) {
|
|
||||||
throw new Error(
|
|
||||||
"NEXT_PUBLIC_AUDITLOG_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildParams(caller: string, filter: AuditFilter, format: "json" | "csv"): URLSearchParams {
|
|
||||||
const params = new URLSearchParams({ caller, format });
|
|
||||||
if (filter.tenant) params.set("tenant", filter.tenant);
|
|
||||||
if (filter.actor) params.set("actor", filter.actor);
|
|
||||||
if (filter.action) params.set("action", filter.action);
|
|
||||||
if (filter.from) params.set("from", filter.from);
|
|
||||||
if (filter.to) params.set("to", filter.to);
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
// exportURL liefert die Adresse desselben Endpunkts, den auch die
|
|
||||||
// Export-Datei-Funktion (Akzeptanzkriterium 2) verwendet — Liste und Export
|
|
||||||
// sind bewusst DERSELBE API-Aufruf mit unterschiedlichem "format".
|
|
||||||
export function exportURL(caller: string, filter: AuditFilter, format: "json" | "csv"): string {
|
|
||||||
return `${apiBase()}/audit/export?${buildParams(caller, filter, format)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchRecords laedt die Liste als JSON-Lines und dekodiert Zeile fuer
|
|
||||||
// Zeile — dieselben Filterparameter wie ein direkter API-Aufruf
|
|
||||||
// (Akzeptanzkriterium 1 / Pruefung 1).
|
|
||||||
export async function fetchRecords(caller: string, filter: AuditFilter): Promise<AuditRecord[]> {
|
|
||||||
const res = await fetch(exportURL(caller, filter, "json"), { cache: "no-store" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(text || `Anfrage fehlgeschlagen (${res.status})`);
|
|
||||||
}
|
|
||||||
const text = await res.text();
|
|
||||||
return text
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.trim() !== "")
|
|
||||||
.map((line) => JSON.parse(line) as AuditRecord);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
|
|
||||||
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
|
|
||||||
transpilePackages: ["@nexarch/shl"],
|
|
||||||
};
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "nexarch-audit-log",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nexarch/shl": "file:../shl",
|
|
||||||
"next": "14.2.35",
|
|
||||||
"react": "18.3.1",
|
|
||||||
"react-dom": "18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "20.14.9",
|
|
||||||
"@types/react": "18.3.3",
|
|
||||||
"@types/react-dom": "18.3.0",
|
|
||||||
"typescript": "5.5.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es2017",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [{ "name": "next" }],
|
|
||||||
"paths": { "@/*": ["./*"] }
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
|
|
||||||
|
|
||||||
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
|
|
||||||
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
|
|
||||||
|
|
||||||
## Enthält
|
|
||||||
|
|
||||||
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
|
|
||||||
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
|
|
||||||
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
|
|
||||||
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
|
|
||||||
|
|
||||||
## Verwendung in einem Modul-Frontend
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<I18nProvider initialLocale="de">
|
|
||||||
<ToastProvider>
|
|
||||||
<Shell modules={[]} tenantLabel="Mandant XY">
|
|
||||||
{children}
|
|
||||||
</Shell>
|
|
||||||
</ToastProvider>
|
|
||||||
</I18nProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bekannter offener Punkt
|
|
||||||
|
|
||||||
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm test
|
|
||||||
npm run typecheck
|
|
||||||
```
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { Dialog } from "../components/Dialog";
|
|
||||||
import { I18nProvider } from "../i18n/i18n";
|
|
||||||
|
|
||||||
function renderDialog(onClose: () => void) {
|
|
||||||
return render(
|
|
||||||
<I18nProvider>
|
|
||||||
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
|
|
||||||
<button type="button">Erste Aktion</button>
|
|
||||||
<button type="button">Zweite Aktion</button>
|
|
||||||
</Dialog>
|
|
||||||
</I18nProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Dialog: Tastaturbedienung", () => {
|
|
||||||
it("schließt sich bei ESC", () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
renderDialog(onClose);
|
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
|
|
||||||
renderDialog(vi.fn());
|
|
||||||
const closeButton = screen.getByRole("button", { name: /schließen/i });
|
|
||||||
expect(document.activeElement).toBe(closeButton);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
|
|
||||||
renderDialog(vi.fn());
|
|
||||||
const dialog = screen.getByRole("dialog");
|
|
||||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
|
||||||
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { colorTokens } from "../tokens/tokens";
|
|
||||||
|
|
||||||
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
|
|
||||||
function relLuminance(hex: string): number {
|
|
||||||
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
||||||
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
|
||||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contrastRatio(a: string, b: string): number {
|
|
||||||
const l1 = relLuminance(a);
|
|
||||||
const l2 = relLuminance(b);
|
|
||||||
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
|
|
||||||
return (lighter + 0.05) / (darker + 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
|
|
||||||
for (const scheme of ["light", "dark"] as const) {
|
|
||||||
const c = colorTokens[scheme];
|
|
||||||
|
|
||||||
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
|
|
||||||
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
|
|
||||||
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
|
|
||||||
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
|
|
||||||
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useI18n } from "../i18n/i18n";
|
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR =
|
|
||||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
||||||
|
|
||||||
export interface DialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
titleId: string;
|
|
||||||
title: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
|
||||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
||||||
|
|
||||||
const node = dialogRef.current;
|
|
||||||
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
||||||
focusables?.[0]?.focus();
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== "Tab" || !node) return;
|
|
||||||
|
|
||||||
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
|
||||||
if (items.length === 0) return;
|
|
||||||
const first = items[0];
|
|
||||||
const last = items[items.length - 1];
|
|
||||||
|
|
||||||
if (event.shiftKey && document.activeElement === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
previouslyFocused.current?.focus();
|
|
||||||
};
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="shl-dialog-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.target === event.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={dialogRef}
|
|
||||||
className="shl-dialog"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby={titleId}
|
|
||||||
>
|
|
||||||
<div className="shl-dialog-header">
|
|
||||||
<h2 id={titleId}>{title}</h2>
|
|
||||||
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="shl-dialog-body">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
|
|
||||||
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
|
|
||||||
|
|
||||||
import { useId } from "react";
|
|
||||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
|
||||||
|
|
||||||
interface FieldWrapperProps {
|
|
||||||
label: string;
|
|
||||||
error?: string;
|
|
||||||
hint?: string;
|
|
||||||
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
|
|
||||||
const inputId = useId();
|
|
||||||
const hintId = hint ? `${inputId}-hint` : undefined;
|
|
||||||
const errorId = error ? `${inputId}-error` : undefined;
|
|
||||||
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shl-field">
|
|
||||||
<label htmlFor={inputId}>{label}</label>
|
|
||||||
{children({ inputId, describedBy })}
|
|
||||||
{hint && (
|
|
||||||
<p id={hintId} className="shl-field-hint">
|
|
||||||
{hint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<p id={errorId} className="shl-field-error" role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextFieldProps
|
|
||||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
|
|
||||||
label: string;
|
|
||||||
error?: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
|
|
||||||
return (
|
|
||||||
<FieldWrapper label={label} error={error} hint={hint}>
|
|
||||||
{({ inputId, describedBy }) => (
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
aria-describedby={describedBy}
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
{...inputProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FieldWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectFieldProps
|
|
||||||
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
|
|
||||||
label: string;
|
|
||||||
error?: string;
|
|
||||||
hint?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
|
|
||||||
return (
|
|
||||||
<FieldWrapper label={label} error={error} hint={hint}>
|
|
||||||
{({ inputId, describedBy }) => (
|
|
||||||
<select
|
|
||||||
id={inputId}
|
|
||||||
aria-describedby={describedBy}
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
{...selectProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</FieldWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CheckboxFieldProps
|
|
||||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
|
|
||||||
const inputId = useId();
|
|
||||||
return (
|
|
||||||
<div className="shl-field shl-field-checkbox">
|
|
||||||
<input id={inputId} type="checkbox" {...inputProps} />
|
|
||||||
<label htmlFor={inputId}>{label}</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
|
|
||||||
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useI18n } from "../i18n/i18n";
|
|
||||||
import { useTheme } from "../theme/ThemeProvider";
|
|
||||||
|
|
||||||
export interface ModuleLink {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShellProps {
|
|
||||||
modules: ModuleLink[];
|
|
||||||
tenantLabel: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Shell({ modules, tenantLabel, children }: ShellProps) {
|
|
||||||
const { scheme, toggle } = useTheme();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shl-shell">
|
|
||||||
<a className="shl-skip-link" href="#shl-main-content">
|
|
||||||
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
|
|
||||||
</a>
|
|
||||||
<header className="shl-shell-header">
|
|
||||||
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
|
|
||||||
<ul>
|
|
||||||
{modules.map((mod) => (
|
|
||||||
<li key={mod.key}>
|
|
||||||
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
|
|
||||||
{mod.label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<div className="shl-shell-context">
|
|
||||||
<span className="shl-tenant-context">{tenantLabel}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
aria-label={
|
|
||||||
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{scheme === "light" ? "🌙" : "☀️"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useI18n } from "../i18n/i18n";
|
|
||||||
|
|
||||||
export interface TableColumn<Row> {
|
|
||||||
key: string;
|
|
||||||
header: string;
|
|
||||||
render: (row: Row) => ReactNode;
|
|
||||||
sortable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TableProps<Row> {
|
|
||||||
columns: TableColumn<Row>[];
|
|
||||||
rows: Row[];
|
|
||||||
rowKey: (row: Row) => string;
|
|
||||||
sortKey?: string;
|
|
||||||
sortDirection?: "asc" | "desc";
|
|
||||||
onSort?: (key: string) => void;
|
|
||||||
caption?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Table<Row>({
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
rowKey,
|
|
||||||
sortKey,
|
|
||||||
sortDirection,
|
|
||||||
onSort,
|
|
||||||
caption,
|
|
||||||
}: TableProps<Row>) {
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<table className="shl-table">
|
|
||||||
{caption && <caption>{caption}</caption>}
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{columns.map((column) => {
|
|
||||||
const isSorted = column.key === sortKey;
|
|
||||||
const ariaSort = column.sortable
|
|
||||||
? isSorted
|
|
||||||
? sortDirection === "asc"
|
|
||||||
? "ascending"
|
|
||||||
: "descending"
|
|
||||||
: "none"
|
|
||||||
: undefined;
|
|
||||||
return (
|
|
||||||
<th key={column.key} scope="col" aria-sort={ariaSort}>
|
|
||||||
{column.sortable ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSort?.(column.key)}
|
|
||||||
className="shl-table-sort-button"
|
|
||||||
>
|
|
||||||
{column.header}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
column.header
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
rows.map((row) => (
|
|
||||||
<tr key={rowKey(row)}>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<td key={column.key}>{column.render(row)}</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useI18n } from "../i18n/i18n";
|
|
||||||
|
|
||||||
export type ToastVariant = "info" | "success" | "danger" | "warning";
|
|
||||||
|
|
||||||
export interface ToastMessage {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
variant: ToastVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToastContextValue {
|
|
||||||
toasts: ToastMessage[];
|
|
||||||
push: (text: string, variant?: ToastVariant) => void;
|
|
||||||
dismiss: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const dismiss = useCallback((id: string) => {
|
|
||||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const push = useCallback((text: string, variant: ToastVariant = "info") => {
|
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
setToasts((current) => [...current, { id, text, variant }]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
|
|
||||||
{toasts.map((toast) => (
|
|
||||||
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
|
|
||||||
<span>{toast.text}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => dismiss(toast.id)}
|
|
||||||
aria-label={t("shl.toast.dismiss")}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ToastContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToast(): ToastContextValue {
|
|
||||||
const ctx = useContext(ToastContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
|
|
||||||
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
|
|
||||||
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
|
|
||||||
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
|
|
||||||
|
|
||||||
import { createContext, useContext, useMemo, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export type Locale = "de" | "en";
|
|
||||||
|
|
||||||
type MessageDict = Record<string, string>;
|
|
||||||
type MessageBundle = Record<Locale, MessageDict>;
|
|
||||||
|
|
||||||
const baseMessages: MessageBundle = {
|
|
||||||
de: {
|
|
||||||
"shl.dialog.close": "Schließen",
|
|
||||||
"shl.toast.dismiss": "Meldung schließen",
|
|
||||||
"shl.table.noRows": "Keine Einträge vorhanden",
|
|
||||||
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
|
|
||||||
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
"shl.dialog.close": "Close",
|
|
||||||
"shl.toast.dismiss": "Dismiss message",
|
|
||||||
"shl.table.noRows": "No entries",
|
|
||||||
"shl.theme.toggleToLight": "Switch to light theme",
|
|
||||||
"shl.theme.toggleToDark": "Switch to dark theme",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
|
|
||||||
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
|
|
||||||
|
|
||||||
export function registerMessages(locale: Locale, messages: MessageDict): void {
|
|
||||||
registry[locale] = { ...registry[locale], ...messages };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface I18nContextValue {
|
|
||||||
locale: Locale;
|
|
||||||
setLocale: (locale: Locale) => void;
|
|
||||||
t: (key: string, fallback?: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
|
||||||
|
|
||||||
export function I18nProvider({
|
|
||||||
initialLocale = "de",
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
initialLocale?: Locale;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const [locale, setLocale] = useState<Locale>(initialLocale);
|
|
||||||
|
|
||||||
const value = useMemo<I18nContextValue>(
|
|
||||||
() => ({
|
|
||||||
locale,
|
|
||||||
setLocale,
|
|
||||||
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
|
|
||||||
}),
|
|
||||||
[locale],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useI18n(): I18nContextValue {
|
|
||||||
const ctx = useContext(I18nContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
|
|
||||||
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
|
|
||||||
|
|
||||||
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
|
|
||||||
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
|
|
||||||
|
|
||||||
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
|
|
||||||
|
|
||||||
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
|
|
||||||
export type { Locale } from "./i18n/i18n";
|
|
||||||
|
|
||||||
export { Shell } from "./components/Shell";
|
|
||||||
export type { ShellProps, ModuleLink } from "./components/Shell";
|
|
||||||
|
|
||||||
export { Dialog } from "./components/Dialog";
|
|
||||||
export type { DialogProps } from "./components/Dialog";
|
|
||||||
|
|
||||||
export { Table } from "./components/Table";
|
|
||||||
export type { TableProps, TableColumn } from "./components/Table";
|
|
||||||
|
|
||||||
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
|
|
||||||
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
|
|
||||||
|
|
||||||
export { ToastProvider, useToast } from "./components/Toast";
|
|
||||||
export type { ToastMessage, ToastVariant } from "./components/Toast";
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@nexarch/shl",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
|
|
||||||
"main": "index.ts",
|
|
||||||
"types": "index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest run",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@testing-library/jest-dom": "^6.4.8",
|
|
||||||
"@testing-library/react": "^16.0.0",
|
|
||||||
"@types/react": "18.3.3",
|
|
||||||
"@types/react-dom": "18.3.0",
|
|
||||||
"jsdom": "^24.1.1",
|
|
||||||
"typescript": "5.5.3",
|
|
||||||
"vitest": "^2.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
|
|
||||||
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
|
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "nexarch-shl-theme";
|
|
||||||
|
|
||||||
interface ThemeContextValue {
|
|
||||||
scheme: ColorScheme;
|
|
||||||
setScheme: (scheme: ColorScheme) => void;
|
|
||||||
toggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
||||||
|
|
||||||
function readStoredScheme(): ColorScheme | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
return stored === "light" || stored === "dark" ? stored : null;
|
|
||||||
} catch {
|
|
||||||
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function systemPrefersDark(): boolean {
|
|
||||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [scheme, setSchemeState] = useState<ColorScheme>("light");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = readStoredScheme();
|
|
||||||
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setScheme = useCallback((next: ColorScheme) => {
|
|
||||||
setSchemeState(next);
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, next);
|
|
||||||
} catch {
|
|
||||||
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
|
||||||
setScheme(scheme === "light" ? "dark" : "light");
|
|
||||||
}, [scheme, setScheme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const vars = cssVariables(scheme);
|
|
||||||
const root = document.documentElement;
|
|
||||||
for (const [key, value] of Object.entries(vars)) {
|
|
||||||
root.style.setProperty(key, value);
|
|
||||||
}
|
|
||||||
root.dataset.shlTheme = scheme;
|
|
||||||
}, [scheme]);
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
|
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme(): ThemeContextValue {
|
|
||||||
const ctx = useContext(ThemeContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currentColors(scheme: ColorScheme) {
|
|
||||||
return colorTokens[scheme];
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
|
|
||||||
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
|
|
||||||
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
|
|
||||||
|
|
||||||
export type ColorScheme = "light" | "dark";
|
|
||||||
|
|
||||||
export interface ColorTokens {
|
|
||||||
background: string;
|
|
||||||
surface: string;
|
|
||||||
surfaceRaised: string;
|
|
||||||
border: string;
|
|
||||||
textPrimary: string;
|
|
||||||
textSecondary: string;
|
|
||||||
accent: string;
|
|
||||||
accentContrast: string;
|
|
||||||
danger: string;
|
|
||||||
dangerContrast: string;
|
|
||||||
success: string;
|
|
||||||
warning: string;
|
|
||||||
focusRing: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
|
|
||||||
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
|
|
||||||
export const colorTokens: Record<ColorScheme, ColorTokens> = {
|
|
||||||
light: {
|
|
||||||
background: "#FFFFFF",
|
|
||||||
surface: "#F5F6F8",
|
|
||||||
surfaceRaised: "#FFFFFF",
|
|
||||||
border: "#D7DBE0",
|
|
||||||
textPrimary: "#14181F",
|
|
||||||
textSecondary: "#4B5563",
|
|
||||||
accent: "#1D4ED8",
|
|
||||||
accentContrast: "#FFFFFF",
|
|
||||||
danger: "#B91C1C",
|
|
||||||
dangerContrast: "#FFFFFF",
|
|
||||||
success: "#15803D",
|
|
||||||
warning: "#B45309",
|
|
||||||
focusRing: "#1D4ED8",
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
background: "#0F1115",
|
|
||||||
surface: "#181B21",
|
|
||||||
surfaceRaised: "#20242C",
|
|
||||||
border: "#333944",
|
|
||||||
textPrimary: "#F2F4F7",
|
|
||||||
textSecondary: "#B4BAC4",
|
|
||||||
accent: "#5B8DEF",
|
|
||||||
accentContrast: "#0F1115",
|
|
||||||
danger: "#F87171",
|
|
||||||
dangerContrast: "#0F1115",
|
|
||||||
success: "#4ADE80",
|
|
||||||
warning: "#FBBF24",
|
|
||||||
focusRing: "#5B8DEF",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const spacing = {
|
|
||||||
xs: "4px",
|
|
||||||
sm: "8px",
|
|
||||||
md: "16px",
|
|
||||||
lg: "24px",
|
|
||||||
xl: "32px",
|
|
||||||
xxl: "48px",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const breakpoints = {
|
|
||||||
mobile: "0px",
|
|
||||||
tablet: "768px",
|
|
||||||
desktop: "1200px",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const typography = {
|
|
||||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
|
||||||
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
|
|
||||||
sizeSm: "13px",
|
|
||||||
sizeMd: "15px",
|
|
||||||
sizeLg: "18px",
|
|
||||||
sizeXl: "24px",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
weightRegular: 400,
|
|
||||||
weightMedium: 500,
|
|
||||||
weightBold: 600,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function cssVariables(scheme: ColorScheme): Record<string, string> {
|
|
||||||
const c = colorTokens[scheme];
|
|
||||||
const vars: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(c)) {
|
|
||||||
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(spacing)) {
|
|
||||||
vars[`--shl-spacing-${key}`] = value;
|
|
||||||
}
|
|
||||||
return vars;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"declaration": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true
|
|
||||||
},
|
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "jsdom",
|
|
||||||
setupFiles: ["./vitest.setup.ts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { afterEach } from "vitest";
|
|
||||||
import { cleanup } from "@testing-library/react";
|
|
||||||
|
|
||||||
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
|
|
||||||
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user