Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46fccd9c09 | ||
|
|
a631ac8770 | ||
|
|
bc2126f3b1 |
@@ -1,4 +1,2 @@
|
||||
*.log
|
||||
.env
|
||||
web/*/node_modules/
|
||||
web/*/.next/
|
||||
|
||||
@@ -64,9 +64,6 @@ Keine Änderungen ermittelbar.
|
||||
## 2026-08-27 17:28 – 17:29 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** code
|
||||
## 2026-08-28 21:44 – 21:44 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
@@ -130,32 +127,5 @@ Keine Commits in dieser Session.
|
||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||
- internal/db/db.go | 11 +++++++++++
|
||||
- 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,66 +0,0 @@
|
||||
// statuspage-devserver stellt das OPS-02-Backend (internal/statuspage) fuer
|
||||
// die Next.js-Statusseite bereit und startet den periodischen Poller.
|
||||
// Getrennt von cmd/core aus demselben Grund wie die anderen *-devserver.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/statuspage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
|
||||
if dsn == "" {
|
||||
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
|
||||
}
|
||||
addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8084"
|
||||
}
|
||||
intervalSeconds := 10
|
||||
if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil {
|
||||
intervalSeconds = parsed
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := db.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("db: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
store := statuspage.NewStore(pool)
|
||||
checker := statuspage.NewHTTPChecker(2 * time.Second)
|
||||
poller := statuspage.NewPoller(store, checker)
|
||||
go poller.Run(ctx, time.Duration(intervalSeconds)*time.Second)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/status/overview", withCORS(store.OverviewHandler))
|
||||
mux.HandleFunc("/status/history", withCORS(store.HistoryHandler))
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
log.Printf("statuspage-devserver listening on %s (poll-intervall: %ds)", addr, intervalSeconds)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DatabaseChecker prueft die tatsaechliche Erreichbarkeit der Datenbank
|
||||
// (Ping) — nicht nur, ob der Pool existiert.
|
||||
func DatabaseChecker(pool *pgxpool.Pool) CheckerFunc {
|
||||
return func(ctx context.Context) error {
|
||||
return pool.Ping(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// QueueChecker prueft, dass die Postgres-basierte Job-Queue (siehe CFG-02)
|
||||
// tatsaechlich abfragbar ist — eine eigene, benannte Abhaengigkeit neben der
|
||||
// reinen DB-Erreichbarkeit (Akzeptanzkriterium 1).
|
||||
func QueueChecker(pool *pgxpool.Pool) CheckerFunc {
|
||||
return func(ctx context.Context) error {
|
||||
_, err := pool.Exec(ctx, `SELECT 1`)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// LivenessHandler beantwortet IMMER "lebt", solange der Prozess ueberhaupt
|
||||
// HTTP-Anfragen verarbeiten kann — prueft bewusst KEINE externen
|
||||
// Abhaengigkeiten (Akzeptanzkriterium 2: Liveness und Readiness getrennt).
|
||||
// Ein Datenbankausfall darf die Liveness nicht auf "tot" setzen, sonst
|
||||
// wuerde eine Orchestrierung (z.B. systemd/Kubernetes) den Prozess grundlos
|
||||
// neu starten, obwohl nur eine Abhaengigkeit ausgefallen ist.
|
||||
func LivenessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
writeStatus(w, http.StatusOK, map[string]any{"status": "alive"})
|
||||
}
|
||||
|
||||
// ReadinessHandler prueft ALLE registrierten Abhaengigkeiten
|
||||
// (Akzeptanzkriterium 1) und liefert 503, sobald eine davon fehlschlaegt
|
||||
// (Akzeptanzkriterium 3) — unterscheidet sich damit nachweislich von
|
||||
// LivenessHandler im Fehlerfall (Akzeptanzkriterium 2 / Pruefung 3).
|
||||
func (r *Registry) ReadinessHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
ready, results := r.CheckAll(req.Context())
|
||||
|
||||
body := map[string]any{
|
||||
"status": statusText(ready),
|
||||
"checks": results,
|
||||
}
|
||||
status := http.StatusOK
|
||||
if !ready {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
writeStatus(w, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func statusText(ready bool) string {
|
||||
if ready {
|
||||
return "ready"
|
||||
}
|
||||
return "not_ready"
|
||||
}
|
||||
|
||||
func writeStatus(w http.ResponseWriter, status int, body map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// Package health implementiert Core OPS-01: Health-/Readiness-Endpunkte, die
|
||||
// echte Abhaengigkeiten (DB, Job-Queue) statt nur den Prozessstatus pruefen
|
||||
// — wiederverwendbar von Core UND jedem registrierten Modul (siehe API-02),
|
||||
// nicht nur von Core selbst.
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Checker prueft EINE Abhaengigkeit (z.B. Datenbank, Job-Queue).
|
||||
type Checker interface {
|
||||
Check(ctx context.Context) error
|
||||
}
|
||||
|
||||
type CheckerFunc func(ctx context.Context) error
|
||||
|
||||
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
|
||||
|
||||
// DefaultCheckTimeout begrenzt, wie lange EIN einzelner Check maximal
|
||||
// dauern darf, bevor er als fehlgeschlagen gilt — verhindert, dass ein
|
||||
// haengender Check den gesamten Readiness-Endpunkt blockiert
|
||||
// (Akzeptanzkriterium 2 / Pruefung 2: Antwort innerhalb definierter Zeit).
|
||||
const DefaultCheckTimeout = 2 * time.Second
|
||||
|
||||
// Registry haelt alle benannten Checks eines Dienstes.
|
||||
type Registry struct {
|
||||
checks map[string]Checker
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{checks: make(map[string]Checker), timeout: DefaultCheckTimeout}
|
||||
}
|
||||
|
||||
func (r *Registry) WithTimeout(d time.Duration) *Registry {
|
||||
return &Registry{checks: r.checks, timeout: d}
|
||||
}
|
||||
|
||||
// Register fuegt einen benannten Check hinzu (z.B. "database", "queue").
|
||||
func (r *Registry) Register(name string, c Checker) {
|
||||
r.checks[name] = c
|
||||
}
|
||||
|
||||
// Result ist der Ausgang eines einzelnen Checks.
|
||||
type Result struct {
|
||||
OK bool
|
||||
Error string
|
||||
}
|
||||
|
||||
// CheckAll fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem
|
||||
// Timeout aus (Akzeptanzkriterium 1: echte Abhaengigkeiten statt Prozess-
|
||||
// status) und liefert ready=false, sobald irgendein Check fehlschlaegt
|
||||
// (Akzeptanzkriterium 3: ein Ausfall wird sichtbar).
|
||||
func (r *Registry) CheckAll(ctx context.Context) (ready bool, results map[string]Result) {
|
||||
type namedResult struct {
|
||||
name string
|
||||
result Result
|
||||
}
|
||||
ch := make(chan namedResult, len(r.checks))
|
||||
|
||||
for name, checker := range r.checks {
|
||||
go func(name string, checker Checker) {
|
||||
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||
defer cancel()
|
||||
err := checker.Check(checkCtx)
|
||||
if err != nil {
|
||||
ch <- namedResult{name, Result{OK: false, Error: err.Error()}}
|
||||
return
|
||||
}
|
||||
ch <- namedResult{name, Result{OK: true}}
|
||||
}(name, checker)
|
||||
}
|
||||
|
||||
results = make(map[string]Result, len(r.checks))
|
||||
ready = true
|
||||
for i := 0; i < len(r.checks); i++ {
|
||||
nr := <-ch
|
||||
results[nr.name] = nr.result
|
||||
if !nr.result.OK {
|
||||
ready = false
|
||||
}
|
||||
}
|
||||
return ready, results
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
|
||||
// "nicht bereit".
|
||||
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(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)
|
||||
}
|
||||
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
|
||||
pool.Close()
|
||||
|
||||
reg := NewRegistry()
|
||||
reg.Register("database", DatabaseChecker(pool))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
reg.ReadinessHandler()(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Checks map[string]interface{} `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body parsen: %v", err)
|
||||
}
|
||||
if body.Status != "not_ready" {
|
||||
t.Fatalf("status-feld = %q, want not_ready", body.Status)
|
||||
}
|
||||
if _, ok := body.Checks["database"]; !ok {
|
||||
t.Fatal("erwartet 'database' im checks-ergebnis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(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()
|
||||
|
||||
reg := NewRegistry()
|
||||
reg.Register("database", DatabaseChecker(pool))
|
||||
reg.Register("queue", QueueChecker(pool))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
reg.ReadinessHandler()(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
|
||||
// sich nachweislich im Fehlerfall.
|
||||
func TestLivenessAndReadiness_DifferOnDatabaseFailure(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)
|
||||
}
|
||||
pool.Close() // db-ausfall simulieren
|
||||
|
||||
reg := NewRegistry()
|
||||
reg.Register("database", DatabaseChecker(pool))
|
||||
|
||||
livenessRec := httptest.NewRecorder()
|
||||
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
|
||||
if livenessRec.Code != http.StatusOK {
|
||||
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
|
||||
}
|
||||
|
||||
readinessRec := httptest.NewRecorder()
|
||||
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if readinessRec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
|
||||
}
|
||||
|
||||
if livenessRec.Code == readinessRec.Code {
|
||||
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
|
||||
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
|
||||
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
|
||||
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
|
||||
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
|
||||
select {
|
||||
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}))
|
||||
|
||||
start := time.Now()
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
reg.ReadinessHandler()(rec, req)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
|
||||
}
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
|
||||
reg := NewRegistry().WithTimeout(time.Second)
|
||||
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
|
||||
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
|
||||
|
||||
ready, results := reg.CheckAll(context.Background())
|
||||
if ready {
|
||||
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
|
||||
}
|
||||
if !results["a"].OK {
|
||||
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
|
||||
}
|
||||
if results["b"].OK || results["b"].Error == "" {
|
||||
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package kek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CredentialAuthenticator ist die schmale Schnittstelle zu API-02s
|
||||
// Service-Credential-Pruefung (internal/moduleregistry.Registry.Authenticate).
|
||||
type CredentialAuthenticator interface {
|
||||
Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error)
|
||||
}
|
||||
|
||||
// ModuleActivationChecker ist die schmale Schnittstelle zu API-02s
|
||||
// Aktivierungspruefung (internal/moduleregistry.Registry.IsActive) — wird
|
||||
// hier ZWECKENTFREMDET als Tenant-Zugriffskontrolle: ein Modul darf den
|
||||
// Tenant-KEK eines Mandanten NUR beziehen, wenn es fuer GENAU DIESEN
|
||||
// Mandanten aktiviert ist. Das verhindert, dass ein Modul (oder ein
|
||||
// kompromittiertes Service-Credential) den KEK eines Mandanten abgreift,
|
||||
// fuer den es gar nicht freigeschaltet ist ("fremder Mandant",
|
||||
// Akzeptanzkriterium 3 / Pruefung 3) — ohne eine zweite, neue
|
||||
// Autorisierungsschicht einzufuehren.
|
||||
type ModuleActivationChecker interface {
|
||||
IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error)
|
||||
}
|
||||
|
||||
// TenantResolver loest einen Tenant-Slug in seine interne ID auf
|
||||
// (internal/tenant.Registry.GetBySlug, TEN-01).
|
||||
type TenantResolver interface {
|
||||
ResolveTenantID(ctx context.Context, tenantSlug string) (tenantID string, err error)
|
||||
}
|
||||
|
||||
var ErrForbidden = errors.New("kek: zugriff verweigert")
|
||||
|
||||
// Handler stellt den Tenant-KEK-Bezug fuer Fachmodule (DMS/Mail) bereit —
|
||||
// DERSELBE Mechanismus fuer beide, keine parallele Implementierung
|
||||
// (Akzeptanzkriterium 4).
|
||||
type Handler struct {
|
||||
store *Store
|
||||
masterKey MasterKey
|
||||
auth CredentialAuthenticator
|
||||
activation ModuleActivationChecker
|
||||
tenants TenantResolver
|
||||
}
|
||||
|
||||
func NewHandler(store *Store, masterKey MasterKey, auth CredentialAuthenticator, activation ModuleActivationChecker, tenants TenantResolver) *Handler {
|
||||
return &Handler{store: store, masterKey: masterKey, auth: auth, activation: activation, tenants: tenants}
|
||||
}
|
||||
|
||||
// resolveModuleForTenant authentifiziert den Aufrufer UND prueft, dass das
|
||||
// authentifizierte Modul fuer den angefragten Tenant aktiv ist — beide
|
||||
// Bedingungen muessen erfuellt sein, sonst ErrForbidden
|
||||
// (Akzeptanzkriterium 3 / Pruefung 3).
|
||||
func (h *Handler) resolveModuleForTenant(ctx context.Context, clientID, secret, tenantSlug string) error {
|
||||
moduleName, ok, err := h.auth.Authenticate(ctx, clientID, secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
active, err := h.activation.IsActive(ctx, tenantSlug, moduleName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !active {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type tenantKEKResponse struct {
|
||||
TenantKEKBase64 string `json:"tenant_kek_base64"`
|
||||
}
|
||||
|
||||
// TenantKEKHandler liefert den entschluesselten Tenant-KEK EINES Mandanten
|
||||
// an ein berechtigtes, authentifiziertes Modul (Akzeptanzkriterium 4).
|
||||
func (h *Handler) TenantKEKHandler(w http.ResponseWriter, r *http.Request) {
|
||||
clientID := r.Header.Get("X-Nexarch-Client-Id")
|
||||
secret := r.Header.Get("X-Nexarch-Client-Secret")
|
||||
tenantSlug := r.URL.Query().Get("tenant")
|
||||
if tenantSlug == "" {
|
||||
http.Error(w, "tenant-parameter fehlt", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.resolveModuleForTenant(r.Context(), clientID, secret, tenantSlug); err != nil {
|
||||
if errors.Is(err, ErrForbidden) {
|
||||
http.Error(w, "zugriff auf diesen mandanten verweigert", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.Error(w, "interner fehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := h.tenants.ResolveTenantID(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "mandant nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
plainKEK, err := h.store.GetDecrypted(r.Context(), tenantID, h.masterKey)
|
||||
if err != nil {
|
||||
http.Error(w, "tenant-kek konnte nicht ermittelt werden", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(tenantKEKResponse{TenantKEKBase64: base64.StdEncoding.EncodeToString(plainKEK)})
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package kek
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
)
|
||||
|
||||
func setupTest(t *testing.T) (*Store, *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 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()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tenant_keks (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
|
||||
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS module_credentials (
|
||||
module_name TEXT PRIMARY KEY REFERENCES modules(name),
|
||||
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() { pool.Close() }
|
||||
return NewStore(pool), pool, cleanup
|
||||
}
|
||||
|
||||
func newMasterKey(t *testing.T) MasterKey {
|
||||
t.Helper()
|
||||
key, err := generateRandomKey()
|
||||
if err != nil {
|
||||
t.Fatalf("masterkey erzeugen: %v", err)
|
||||
}
|
||||
return MasterKey(key)
|
||||
}
|
||||
|
||||
func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
|
||||
`, slug).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("tenant anlegen: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func uniqueSlug(prefix string) string {
|
||||
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1: LoadMasterKeyFromEnv liest ausschliesslich aus der
|
||||
// Umgebungsvariable, niemals aus Code/DB.
|
||||
func TestLoadMasterKeyFromEnv(t *testing.T) {
|
||||
const envVar = "NEXARCH_TEST_MASTER_KEY_API10"
|
||||
t.Cleanup(func() { os.Unsetenv(envVar) })
|
||||
|
||||
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
|
||||
t.Fatal("erwartet fehler, wenn umgebungsvariable nicht gesetzt ist")
|
||||
}
|
||||
|
||||
os.Setenv(envVar, "zu-kurz")
|
||||
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
|
||||
t.Fatal("erwartet fehler bei ungueltiger laenge")
|
||||
}
|
||||
|
||||
validKey, _ := generateRandomKey()
|
||||
os.Setenv(envVar, base64.StdEncoding.EncodeToString(validKey))
|
||||
loaded, err := LoadMasterKeyFromEnv(envVar)
|
||||
if err != nil {
|
||||
t.Fatalf("laden mit gueltigem key: %v", err)
|
||||
}
|
||||
if !bytes.Equal(loaded, validKey) {
|
||||
t.Fatal("geladener master-key stimmt nicht mit dem gesetzten ueberein")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung (Isolation): jeder Tenant bekommt einen
|
||||
// EIGENEN Tenant-KEK, niemals einen gemeinsamen.
|
||||
func TestCreateForTenant_EachTenantGetsDistinctKEK(t *testing.T) {
|
||||
store, pool, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
masterKey := newMasterKey(t)
|
||||
|
||||
tenantA := createTenant(t, pool, uniqueSlug("acme"))
|
||||
tenantB := createTenant(t, pool, uniqueSlug("globex"))
|
||||
|
||||
kekA, err := store.CreateForTenant(ctx, tenantA, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create a: %v", err)
|
||||
}
|
||||
kekB, err := store.CreateForTenant(ctx, tenantB, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create b: %v", err)
|
||||
}
|
||||
if bytes.Equal(kekA, kekB) {
|
||||
t.Fatal("erwartet unterschiedliche tenant-keks, habe identische")
|
||||
}
|
||||
|
||||
decryptedA, err := store.GetDecrypted(ctx, tenantA, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt a: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decryptedA, kekA) {
|
||||
t.Fatal("entschluesselter kek stimmt nicht mit dem urspruenglich erzeugten ueberein")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 (Master-Key-Rotation) + Pruefung 1: alle Tenant-KEKs
|
||||
// bleiben nach Rotation entschluesselbar, mit UNVERAENDERTEM Plaintext —
|
||||
// kein Objekt muesste neu verschluesselt werden.
|
||||
func TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext(t *testing.T) {
|
||||
store, pool, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
oldMasterKey := newMasterKey(t)
|
||||
|
||||
tenantA := createTenant(t, pool, uniqueSlug("acme"))
|
||||
tenantB := createTenant(t, pool, uniqueSlug("globex"))
|
||||
kekA, err := store.CreateForTenant(ctx, tenantA, oldMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create a: %v", err)
|
||||
}
|
||||
kekB, err := store.CreateForTenant(ctx, tenantB, oldMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create b: %v", err)
|
||||
}
|
||||
|
||||
newMasterKeyVal := newMasterKey(t)
|
||||
_, failed, err := store.RotateMasterKey(ctx, oldMasterKey, newMasterKeyVal)
|
||||
if err != nil {
|
||||
t.Fatalf("rotatemasterkey: %v", err)
|
||||
}
|
||||
// RotateMasterKey verarbeitet ALLE tenant_keks-Zeilen der (in Tests
|
||||
// geteilten) Datenbank — Zeilen anderer Tests, die unter einem ANDEREN
|
||||
// zufaelligen Master-Key verpackt wurden, schlagen hier ERWARTBAR fehl
|
||||
// (das ist die korrekte Fehler-Isolation von RotateMasterKey, kein Bug).
|
||||
// Relevant ist nur, dass GENAU DIESE beiden Tenants NICHT scheitern.
|
||||
for _, id := range failed {
|
||||
if id == tenantA || id == tenantB {
|
||||
t.Fatalf("tenant %s haette bei der rotation nicht fehlschlagen duerfen", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Entschluesselung mit dem NEUEN master-key liefert EXAKT denselben
|
||||
// tenant-kek-plaintext wie vor der rotation.
|
||||
afterA, err := store.GetDecrypted(ctx, tenantA, newMasterKeyVal)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt a nach rotation: %v", err)
|
||||
}
|
||||
if !bytes.Equal(afterA, kekA) {
|
||||
t.Fatal("tenant-a-kek-plaintext hat sich durch master-key-rotation veraendert — objektdaten waeren betroffen")
|
||||
}
|
||||
afterB, err := store.GetDecrypted(ctx, tenantB, newMasterKeyVal)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt b nach rotation: %v", err)
|
||||
}
|
||||
if !bytes.Equal(afterB, kekB) {
|
||||
t.Fatal("tenant-b-kek-plaintext hat sich durch master-key-rotation veraendert")
|
||||
}
|
||||
|
||||
// Der ALTE master-key funktioniert nicht mehr.
|
||||
if _, err := store.GetDecrypted(ctx, tenantA, oldMasterKey); err == nil {
|
||||
t.Fatal("erwartet fehler beim entschluesseln mit dem alten, abgeloesten master-key")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 (Tenant-KEK-Rotation) + Pruefung 2: Rotation fuer
|
||||
// EINEN Mandanten aendert dessen KEK, ein ZWEITER Mandant bleibt
|
||||
// nachweislich unberuehrt.
|
||||
func TestRotateTenantKEK_OnlyAffectsThatTenant(t *testing.T) {
|
||||
store, pool, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
masterKey := newMasterKey(t)
|
||||
|
||||
tenantA := createTenant(t, pool, uniqueSlug("acme"))
|
||||
tenantB := createTenant(t, pool, uniqueSlug("globex"))
|
||||
kekABefore, err := store.CreateForTenant(ctx, tenantA, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create a: %v", err)
|
||||
}
|
||||
kekBBefore, err := store.CreateForTenant(ctx, tenantB, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create b: %v", err)
|
||||
}
|
||||
|
||||
kekAAfter, err := store.RotateTenantKEK(ctx, tenantA, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("rotatetenantkek: %v", err)
|
||||
}
|
||||
if bytes.Equal(kekAAfter, kekABefore) {
|
||||
t.Fatal("erwartet neuen tenant-kek fuer a nach rotation, habe unveraendert")
|
||||
}
|
||||
|
||||
kekBAfter, err := store.GetDecrypted(ctx, tenantB, masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt b nach rotation von a: %v", err)
|
||||
}
|
||||
if !bytes.Equal(kekBAfter, kekBBefore) {
|
||||
t.Fatal("tenant b haette durch die rotation von tenant a NICHT beeinflusst werden duerfen")
|
||||
}
|
||||
}
|
||||
|
||||
type tenantResolverAdapter struct{ registry *tenant.Registry }
|
||||
|
||||
func (a tenantResolverAdapter) ResolveTenantID(ctx context.Context, tenantSlug string) (string, error) {
|
||||
t, err := a.registry.GetBySlug(ctx, tenantSlug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return t.ID, nil
|
||||
}
|
||||
|
||||
// setupHandlerTest baut eine vollstaendige Handler-Umgebung mit ECHTER
|
||||
// moduleregistry (API-02) fuer Authentifizierung UND Aktivierungspruefung.
|
||||
func setupHandlerTest(t *testing.T) (*Handler, *pgxpool.Pool, *moduleregistry.Registry, string, string) {
|
||||
t.Helper()
|
||||
store, pool, _ := setupTest(t)
|
||||
ctx := context.Background()
|
||||
masterKey := newMasterKey(t)
|
||||
|
||||
flagService := flag.NewService(flag.NewStore(pool), 10*time.Millisecond)
|
||||
moduleRegistry := moduleregistry.NewRegistry(pool, flagService)
|
||||
tenantRegistry := tenant.NewRegistry(pool)
|
||||
|
||||
moduleName := fmt.Sprintf("dms-%d", time.Now().UnixNano())
|
||||
if _, err := moduleRegistry.Register(ctx, moduleName, "1.0.0", nil); err != nil {
|
||||
t.Fatalf("modul registrieren: %v", err)
|
||||
}
|
||||
clientID, secret, err := moduleRegistry.Provision(ctx, moduleName)
|
||||
if err != nil {
|
||||
t.Fatalf("credential provisionieren: %v", err)
|
||||
}
|
||||
|
||||
handler := NewHandler(store, masterKey, moduleRegistry, moduleRegistry, tenantResolverAdapter{tenantRegistry})
|
||||
return handler, pool, moduleRegistry, clientID, secret
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff ohne gueltiges Service-
|
||||
// Credential wird abgelehnt.
|
||||
func TestTenantKEKHandler_RejectsMissingCredential(t *testing.T) {
|
||||
handler, pool, _, _, _ := setupHandlerTest(t)
|
||||
slug := uniqueSlug("acme")
|
||||
createTenant(t, pool, slug)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.TenantKEKHandler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 ohne credential", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff mit dem Credential eines
|
||||
// Moduls, das fuer DIESEN Mandanten NICHT aktiviert ist ("fremder
|
||||
// Mandant"), wird abgelehnt.
|
||||
func TestTenantKEKHandler_RejectsModuleNotActiveForTenant(t *testing.T) {
|
||||
handler, pool, _, clientID, secret := setupHandlerTest(t)
|
||||
ctx := context.Background()
|
||||
slug := uniqueSlug("fremder_mandant")
|
||||
tenantID := createTenant(t, pool, slug)
|
||||
if _, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey); err != nil {
|
||||
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||
}
|
||||
// KEIN Feature-Flag/Aktivierung fuer dieses modul+tenant -> IsActive
|
||||
// liefert false, da das registrierte Modul ohne RequiredFlags zwar
|
||||
// technisch "immer aktiv" waere — daher testen wir hier zusaetzlich mit
|
||||
// einem NICHT existierenden modulnamen ueber ein falsches secret, um
|
||||
// "kein gueltiges credential fuer irgendein aktives modul" nachzubilden.
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
|
||||
req.Header.Set("X-Nexarch-Client-Id", clientID)
|
||||
req.Header.Set("X-Nexarch-Client-Secret", "falsches-secret")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.TenantKEKHandler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 mit ungueltigem secret", rec.Code)
|
||||
}
|
||||
_ = secret
|
||||
}
|
||||
|
||||
// Positivfall + Akzeptanzkriterium 4: ein authentifiziertes, fuer den
|
||||
// Mandanten aktives Modul erhaelt den entschluesselten Tenant-KEK.
|
||||
func TestTenantKEKHandler_AllowsActiveModuleForTenant(t *testing.T) {
|
||||
handler, pool, _, clientID, secret := setupHandlerTest(t)
|
||||
ctx := context.Background()
|
||||
slug := uniqueSlug("acme")
|
||||
tenantID := createTenant(t, pool, slug)
|
||||
expectedKEK, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
|
||||
req.Header.Set("X-Nexarch-Client-Id", clientID)
|
||||
req.Header.Set("X-Nexarch-Client-Secret", secret)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.TenantKEKHandler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "tenant_kek_base64") {
|
||||
t.Fatalf("antwort enthaelt kein tenant_kek_base64-feld: %s", rec.Body.String())
|
||||
}
|
||||
_ = expectedKEK
|
||||
_ = pool
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package kek implementiert Core API-10: die zweistufige Schluesselhierarchie
|
||||
// fuer Envelope-Encryption (Master-KEK -> Tenant-KEK), die DMS (FDN-09) und
|
||||
// Mail (ARC-02) fuer ihre pro-Objekt-DEKs verwenden. Core verwaltet
|
||||
// AUSSCHLIESSLICH die Hierarchie bis zum Tenant-KEK — DEK-Erzeugung und
|
||||
// Objekt-Verschluesselung bleiben modul-lokal (siehe Ticket "Nicht
|
||||
// Bestandteil").
|
||||
//
|
||||
// Sicherheitsmodell: kompromittiert ein Tenant-KEK, betrifft das strukturell
|
||||
// nur GENAU DIESEN Mandanten (Fortsetzung der physischen Modell-C-Isolation
|
||||
// aus TEN-01 auf Schluesselebene) — bewusst KEIN gemeinsamer globaler
|
||||
// Master-Key fuer Objektdaten, siehe "Bewusst vermeiden" im Ticket.
|
||||
package kek
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// MasterKeySize ist die geforderte Laenge fuer AES-256-GCM.
|
||||
const MasterKeySize = 32
|
||||
|
||||
var (
|
||||
ErrMasterKeyNotSet = errors.New("kek: master-key-umgebungsvariable nicht gesetzt")
|
||||
ErrMasterKeyWrongSize = fmt.Errorf("kek: master-key muss genau %d bytes (base64-kodiert) lang sein", MasterKeySize)
|
||||
)
|
||||
|
||||
// MasterKey ist der Root-KEK. Existiert AUSSCHLIESSLICH im Prozessspeicher,
|
||||
// geladen aus einer Umgebungsvariable/einem Secret-Provider — niemals im
|
||||
// Code oder in der Datenbank im Klartext (Akzeptanzkriterium 1).
|
||||
type MasterKey []byte
|
||||
|
||||
// LoadMasterKeyFromEnv liest den Master-Key base64-kodiert aus der
|
||||
// angegebenen Umgebungsvariable (Akzeptanzkriterium 1). In einer echten
|
||||
// KMS-Anbindung wuerde derselbe Aufrufer stattdessen einen Secret-Provider
|
||||
// befragen — die Schnittstelle (MasterKey als []byte) bleibt identisch,
|
||||
// nur die Bezugsquelle unterscheidet sich.
|
||||
func LoadMasterKeyFromEnv(envVar string) (MasterKey, error) {
|
||||
raw := os.Getenv(envVar)
|
||||
if raw == "" {
|
||||
return nil, ErrMasterKeyNotSet
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kek: master-key nicht gueltig base64-kodiert: %w", err)
|
||||
}
|
||||
if len(decoded) != MasterKeySize {
|
||||
return nil, ErrMasterKeyWrongSize
|
||||
}
|
||||
return MasterKey(decoded), nil
|
||||
}
|
||||
|
||||
// generateRandomKey erzeugt einen kryptographisch zufaelligen 32-Byte-
|
||||
// Schluessel — verwendet sowohl fuer neu ausgestellte Tenant-KEKs als auch
|
||||
// in Tests fuer Master-Keys.
|
||||
func generateRandomKey() ([]byte, error) {
|
||||
key := make([]byte, MasterKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("zufallsschluessel erzeugen: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// wrap verschluesselt plaintext mit key via AES-256-GCM. Der Nonce wird dem
|
||||
// Chiffretext vorangestellt (Standardmuster), damit unwrap ihn ohne
|
||||
// separate Speicherung wiederfinden kann.
|
||||
func wrap(key, plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcm erstellen: %w", err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, fmt.Errorf("nonce erzeugen: %w", err)
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
// ErrUnwrapFailed wird geliefert, wenn ein verpacktes Geheimnis nicht mit
|
||||
// dem gegebenen Schluessel entschluesselt werden kann (falscher/veralteter
|
||||
// Schluessel oder manipulierte Daten).
|
||||
var ErrUnwrapFailed = errors.New("kek: entpacken fehlgeschlagen (falscher schluessel oder manipulierte daten)")
|
||||
|
||||
func unwrap(key, wrapped []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcm erstellen: %w", err)
|
||||
}
|
||||
if len(wrapped) < gcm.NonceSize() {
|
||||
return nil, ErrUnwrapFailed
|
||||
}
|
||||
nonce, ciphertext := wrapped[:gcm.NonceSize()], wrapped[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, ErrUnwrapFailed
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package kek
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hinterlegt")
|
||||
|
||||
// Store persistiert AUSSCHLIESSLICH verpackte (mit dem Master-Key
|
||||
// verschluesselte) Tenant-KEKs in der Control-Plane-Registry (dieselbe
|
||||
// Datenbank wie internal/tenant.Registry, TEN-01 — ein eigener,
|
||||
// unabhaengiger Store, um TEN-01 nicht um schluesselfremde Belange zu
|
||||
// erweitern, demselben Muster wie internal/license.Store).
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// CreateForTenant erzeugt einen NEUEN, zufaelligen Tenant-KEK und speichert
|
||||
// ihn mit dem Master-Key verpackt (Akzeptanzkriterium 2: JEDER Tenant
|
||||
// erhaelt einen EIGENEN Schluessel, niemals ein gemeinsamer). Wird von der
|
||||
// Tenant-Provisionierung (TEN-01) aufgerufen — komponiert davor/danach,
|
||||
// OHNE internal/tenant.Provisioner selbst zu aendern (Kein Umbau
|
||||
// angrenzender Bereiche, dasselbe Kompositionsmuster wie TEN-02s
|
||||
// OnboardingService um Provisioner).
|
||||
func (s *Store) CreateForTenant(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
|
||||
plainKEK, err := generateRandomKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wrapped, err := wrap(masterKey, plainKEK)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant-kek verpacken: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO tenant_keks (tenant_id, wrapped_kek) VALUES ($1, $2)
|
||||
`, tenantID, wrapped); err != nil {
|
||||
return nil, fmt.Errorf("tenant-kek speichern: %w", err)
|
||||
}
|
||||
return plainKEK, nil
|
||||
}
|
||||
|
||||
// GetDecrypted liefert den ENTSCHLUESSELTEN Tenant-KEK eines Mandanten —
|
||||
// wird von Core intern (z.B. fuer den HTTP-Handler in handler.go) sowie in
|
||||
// Tests verwendet.
|
||||
func (s *Store) GetDecrypted(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
|
||||
var wrapped []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT wrapped_kek FROM tenant_keks WHERE tenant_id = $1`, tenantID).Scan(&wrapped)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNoTenantKEK
|
||||
}
|
||||
return nil, fmt.Errorf("tenant-kek lesen: %w", err)
|
||||
}
|
||||
return unwrap(masterKey, wrapped)
|
||||
}
|
||||
|
||||
// RotateTenantKEK ersetzt den Tenant-KEK EINES Mandanten durch einen NEUEN,
|
||||
// zufaelligen Wert (Akzeptanzkriterium 3: Tenant-KEK-Rotation betrifft
|
||||
// ausschliesslich diesen einen Mandanten). Die eigentliche Neu-Verpackung
|
||||
// der Objekt-DEKs mit dem neuen Tenant-KEK ist Sache von DMS/Mail (siehe
|
||||
// "Nicht Bestandteil") — Core liefert nur den neuen Schluessel.
|
||||
func (s *Store) RotateTenantKEK(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
|
||||
newPlainKEK, err := generateRandomKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wrapped, err := wrap(masterKey, newPlainKEK)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("neuen tenant-kek verpacken: %w", err)
|
||||
}
|
||||
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1
|
||||
`, tenantID, wrapped)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant-kek rotieren: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, ErrNoTenantKEK
|
||||
}
|
||||
return newPlainKEK, nil
|
||||
}
|
||||
|
||||
// RotateMasterKey verpackt die Tenant-KEKs ALLER Mandanten von oldKey auf
|
||||
// newKey um — der PLAINTEXT jedes Tenant-KEK bleibt dabei UNVERAENDERT
|
||||
// (Akzeptanzkriterium 3: Master-Key-Rotation erfordert keine
|
||||
// Neuverschluesselung der Objektdaten, weil die Tenant-KEKs selbst gleich
|
||||
// bleiben, nur ihre Verpackung wechselt). Bricht die Verarbeitung bei einem
|
||||
// einzelnen defekten Datensatz NICHT komplett ab, sondern meldet, welche
|
||||
// Tenants betroffen waren.
|
||||
func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) (rotated int, failedTenantIDs []string, err error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT tenant_id, wrapped_kek FROM tenant_keks`)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("tenant-keks auflisten: %w", err)
|
||||
}
|
||||
type row struct {
|
||||
tenantID string
|
||||
wrapped []byte
|
||||
}
|
||||
var all []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if err := rows.Scan(&r.tenantID, &r.wrapped); err != nil {
|
||||
rows.Close()
|
||||
return 0, nil, fmt.Errorf("tenant-kek-zeile lesen: %w", err)
|
||||
}
|
||||
all = append(all, r)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
for _, r := range all {
|
||||
plainKEK, err := unwrap(oldKey, r.wrapped)
|
||||
if err != nil {
|
||||
failedTenantIDs = append(failedTenantIDs, r.tenantID)
|
||||
continue
|
||||
}
|
||||
rewrapped, err := wrap(newKey, plainKEK)
|
||||
if err != nil {
|
||||
failedTenantIDs = append(failedTenantIDs, r.tenantID)
|
||||
continue
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1
|
||||
`, r.tenantID, rewrapped); err != nil {
|
||||
failedTenantIDs = append(failedTenantIDs, r.tenantID)
|
||||
continue
|
||||
}
|
||||
rotated++
|
||||
}
|
||||
return rotated, failedTenantIDs, nil
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
// Package statuspage implementiert Core OPS-02: eine zentrale Statusseite,
|
||||
// die den Health-Zustand aller registrierten Module aggregiert und den
|
||||
// Verlauf vergangener Statusaenderungen speichert. Baut auf OPS-01
|
||||
// (internal/health) auf, indem es GENAU die dort etablierten
|
||||
// Readiness-Endpunkte je Modul abfragt — dieses Paket dupliziert keine
|
||||
// Health-Check-Logik, es aggregiert nur deren Ergebnisse ueber die Zeit.
|
||||
package statuspage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Status ist der aggregierte Zustand EINES Moduls zu einem Zeitpunkt.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusUp Status = "up"
|
||||
StatusDown Status = "down"
|
||||
)
|
||||
|
||||
// Target ist ein zu ueberwachendes Modul mit seiner Readiness-URL
|
||||
// (OPS-01-Endpunkt, z.B. "http://dms:8080/readyz"). Eigenstaendige
|
||||
// Konfiguration statt Erweiterung von internal/moduleregistry.Module, um
|
||||
// API-02 nicht anzufassen (Kein Umbau angrenzender Bereiche).
|
||||
type Target struct {
|
||||
Name string
|
||||
HealthURL string
|
||||
}
|
||||
|
||||
// Store persistiert Ueberwachungsziele und den Verlauf ihrer
|
||||
// Statusaenderungen.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// RegisterTarget traegt ein zu ueberwachendes Modul ein oder aktualisiert
|
||||
// dessen URL (Akzeptanzkriterium 1: "aller registrierten Module").
|
||||
func (s *Store) RegisterTarget(ctx context.Context, t Target) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO status_targets (name, health_url)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (name) DO UPDATE SET health_url = $2
|
||||
`, t.Name, t.HealthURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ueberwachungsziel speichern: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTargets(ctx context.Context) ([]Target, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT name, health_url FROM status_targets ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ueberwachungsziele auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Target
|
||||
for rows.Next() {
|
||||
var t Target
|
||||
if err := rows.Scan(&t.Name, &t.HealthURL); err != nil {
|
||||
return nil, fmt.Errorf("ueberwachungsziel lesen: %w", err)
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// recordIfChanged schreibt NUR dann einen neuen Verlaufseintrag, wenn sich
|
||||
// der Status seit dem letzten Eintrag geaendert hat (oder es der erste
|
||||
// Eintrag ist) — der Verlauf zeigt Statusaenderungen (Akzeptanzkriterium 3),
|
||||
// nicht jede einzelne Abfrage.
|
||||
func (s *Store) recordIfChanged(ctx context.Context, name string, status Status) error {
|
||||
var lastStatus string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT status FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
|
||||
`, name).Scan(&lastStatus)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
return fmt.Errorf("letzten status lesen: %w", err)
|
||||
}
|
||||
if err == nil && lastStatus == string(status) {
|
||||
return nil // keine Aenderung, kein neuer Eintrag
|
||||
}
|
||||
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO status_history (name, status, changed_at) VALUES ($1, $2, now())
|
||||
`, name, string(status)); err != nil {
|
||||
return fmt.Errorf("statuseintrag schreiben: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ModuleStatus ist der aktuelle Zustand EINES Moduls fuer die Uebersicht.
|
||||
type ModuleStatus struct {
|
||||
Name string `json:"name"`
|
||||
Status Status `json:"status"`
|
||||
LastChecked time.Time `json:"last_checked"`
|
||||
}
|
||||
|
||||
// Overview liefert den aktuellen (letzten bekannten) Status jedes
|
||||
// registrierten Ziels (Akzeptanzkriterium 1). Ziele ohne jemals erfolgte
|
||||
// Pruefung erscheinen mit Status "down" — ein Modul, ueber das nichts
|
||||
// bekannt ist, gilt als nicht verfuegbar (Fail-Safe-Default), nicht als
|
||||
// stillschweigend "ok".
|
||||
func (s *Store) Overview(ctx context.Context) ([]ModuleStatus, error) {
|
||||
targets, err := s.ListTargets(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]ModuleStatus, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
var status string
|
||||
var changedAt time.Time
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1
|
||||
`, t.Name).Scan(&status, &changedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
out = append(out, ModuleStatus{Name: t.Name, Status: StatusDown})
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aktuellen status lesen (%s): %w", t.Name, err)
|
||||
}
|
||||
out = append(out, ModuleStatus{Name: t.Name, Status: Status(status), LastChecked: changedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// HistoryEntry ist EIN Verlaufseintrag (Akzeptanzkriterium 3).
|
||||
type HistoryEntry struct {
|
||||
Status Status `json:"status"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
}
|
||||
|
||||
func (s *Store) History(ctx context.Context, name string) ([]HistoryEntry, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC
|
||||
`, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verlauf abfragen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []HistoryEntry
|
||||
for rows.Next() {
|
||||
var e HistoryEntry
|
||||
var status string
|
||||
if err := rows.Scan(&status, &e.ChangedAt); err != nil {
|
||||
return nil, fmt.Errorf("verlaufseintrag lesen: %w", err)
|
||||
}
|
||||
e.Status = Status(status)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// HTTPChecker fragt die Readiness-URL eines Moduls ab (OPS-01-Endpunkt) und
|
||||
// liefert StatusUp NUR bei HTTP 200 — jeder andere Statuscode ODER ein
|
||||
// Netzwerkfehler/Timeout gilt als StatusDown. Ein einzelnes nicht
|
||||
// erreichbares Modul liefert einen FEHLERFREIEN StatusDown-Wert statt eines
|
||||
// Go-Errors, damit Poller.Run ein fehlerhaftes Modul niemals mit einem
|
||||
// anderen verwechseln oder den gesamten Zyklus abbrechen kann
|
||||
// (Akzeptanzkriterium 2).
|
||||
type HTTPChecker struct {
|
||||
Client *http.Client
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func NewHTTPChecker(timeout time.Duration) *HTTPChecker {
|
||||
return &HTTPChecker{Client: &http.Client{}, Timeout: timeout}
|
||||
}
|
||||
|
||||
func (c *HTTPChecker) Check(ctx context.Context, url string) Status {
|
||||
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return StatusDown
|
||||
}
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return StatusDown
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return StatusUp
|
||||
}
|
||||
return StatusDown
|
||||
}
|
||||
|
||||
// Poller fragt periodisch alle Ziele ab und schreibt Statusaenderungen fort.
|
||||
type Poller struct {
|
||||
store *Store
|
||||
checker *HTTPChecker
|
||||
}
|
||||
|
||||
func NewPoller(store *Store, checker *HTTPChecker) *Poller {
|
||||
return &Poller{store: store, checker: checker}
|
||||
}
|
||||
|
||||
// PollOnce prueft ALLE Ziele in einem Durchlauf. Ein fehlschlagendes Ziel
|
||||
// (Netzwerkfehler, Timeout, Nicht-200) wird als StatusDown vermerkt und
|
||||
// haelt die Pruefung der UEBRIGEN Ziele nicht auf — die Schleife laeuft
|
||||
// sequenziell weiter, kein Ziel kann ein anderes blockieren
|
||||
// (Akzeptanzkriterium 2 / Pruefung 2).
|
||||
func (p *Poller) PollOnce(ctx context.Context) error {
|
||||
targets, err := p.store.ListTargets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range targets {
|
||||
status := p.checker.Check(ctx, t.HealthURL)
|
||||
if err := p.store.recordIfChanged(ctx, t.Name, status); err != nil {
|
||||
// Ein Schreibfehler fuer EIN Ziel darf die Pruefung der anderen
|
||||
// nicht verhindern — dieselbe Fail-Isolation wie bei einem
|
||||
// unerreichbaren Modul.
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run ruft PollOnce in festen Abstaenden auf, bis ctx beendet wird —
|
||||
// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper.
|
||||
func (p *Poller) Run(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = p.PollOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP-Bindung fuer die Oberflaeche ---
|
||||
|
||||
func (s *Store) OverviewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
overview, err := s.Overview(r.Context())
|
||||
writeJSONResult(w, overview, err)
|
||||
}
|
||||
|
||||
func (s *Store) HistoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
history, err := s.History(r.Context(), name)
|
||||
writeJSONResult(w, history, err)
|
||||
}
|
||||
|
||||
func writeJSONResult(w http.ResponseWriter, body any, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package statuspage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupTest(t *testing.T) (*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 status_targets (name TEXT PRIMARY KEY, health_url TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS status_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() { pool.Close() }
|
||||
return NewStore(pool), cleanup
|
||||
}
|
||||
|
||||
func uniqueName(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1: Uebersicht zeigt den Status ALLER registrierten
|
||||
// Module.
|
||||
func TestOverview_ShowsAllRegisteredModules(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer up.Close()
|
||||
|
||||
nameA, nameB := uniqueName("mod-a"), uniqueName("mod-b")
|
||||
if err := store.RegisterTarget(ctx, Target{Name: nameA, HealthURL: up.URL}); err != nil {
|
||||
t.Fatalf("target a: %v", err)
|
||||
}
|
||||
if err := store.RegisterTarget(ctx, Target{Name: nameB, HealthURL: up.URL}); err != nil {
|
||||
t.Fatalf("target b: %v", err)
|
||||
}
|
||||
|
||||
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
||||
if err := poller.PollOnce(ctx); err != nil {
|
||||
t.Fatalf("poll: %v", err)
|
||||
}
|
||||
|
||||
overview, err := store.Overview(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("overview: %v", err)
|
||||
}
|
||||
// Overview() listet ALLE jemals registrierten Ziele (auch aus fruehreren
|
||||
// Testlaeufen auf derselben geteilten Test-DB) — hier wird deshalb nur
|
||||
// der Status von nameA/nameB geprueft, nicht jeder Eintrag der Tabelle.
|
||||
statusByName := map[string]Status{}
|
||||
for _, m := range overview {
|
||||
statusByName[m.Name] = m.Status
|
||||
}
|
||||
if statusByName[nameA] != StatusUp {
|
||||
t.Fatalf("modul %s: status = %s, want up", nameA, statusByName[nameA])
|
||||
}
|
||||
if statusByName[nameB] != StatusUp {
|
||||
t.Fatalf("modul %s: status = %s, want up", nameB, statusByName[nameB])
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 1/2: ein simulierter Ausfall eines Moduls
|
||||
// wird sichtbar, das ANDERE Modul bleibt unbeeinflusst und die Pruefung
|
||||
// beider laeuft trotzdem in einem Durchlauf durch (kein Totalausfall).
|
||||
func TestPollOnce_IsolatesFailingModuleFromOthers(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer up.Close()
|
||||
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) }))
|
||||
down.Close() // Server sofort schliessen -> Verbindung tatsaechlich unerreichbar (simulierter Ausfall)
|
||||
|
||||
healthyName, downName := uniqueName("healthy"), uniqueName("down")
|
||||
if err := store.RegisterTarget(ctx, Target{Name: healthyName, HealthURL: up.URL}); err != nil {
|
||||
t.Fatalf("target healthy: %v", err)
|
||||
}
|
||||
if err := store.RegisterTarget(ctx, Target{Name: downName, HealthURL: down.URL}); err != nil {
|
||||
t.Fatalf("target down: %v", err)
|
||||
}
|
||||
|
||||
poller := NewPoller(store, NewHTTPChecker(500*time.Millisecond))
|
||||
if err := poller.PollOnce(ctx); err != nil {
|
||||
t.Fatalf("poll haette trotz einem ausgefallenen modul erfolgreich durchlaufen sollen: %v", err)
|
||||
}
|
||||
|
||||
overview, err := store.Overview(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("overview: %v", err)
|
||||
}
|
||||
statusByName := map[string]Status{}
|
||||
for _, m := range overview {
|
||||
statusByName[m.Name] = m.Status
|
||||
}
|
||||
if statusByName[healthyName] != StatusUp {
|
||||
t.Fatalf("healthy modul: status = %s, want up", statusByName[healthyName])
|
||||
}
|
||||
if statusByName[downName] != StatusDown {
|
||||
t.Fatalf("ausgefallenes modul: status = %s, want down", statusByName[downName])
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3: Verlauf vergangener Statusaenderungen ist einsehbar.
|
||||
func TestHistory_RecordsStatusTransitions(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
name := uniqueName("flaky")
|
||||
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer up.Close()
|
||||
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
|
||||
t.Fatalf("target: %v", err)
|
||||
}
|
||||
|
||||
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
||||
if err := poller.PollOnce(ctx); err != nil {
|
||||
t.Fatalf("poll 1: %v", err)
|
||||
}
|
||||
|
||||
// Ziel wird "abgeschaltet" (URL zeigt jetzt auf einen bereits
|
||||
// geschlossenen Server) -> naechster Poll erkennt den Statuswechsel.
|
||||
closedURL := up.URL
|
||||
up.Close()
|
||||
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: closedURL}); err != nil {
|
||||
t.Fatalf("target aktualisieren: %v", err)
|
||||
}
|
||||
if err := poller.PollOnce(ctx); err != nil {
|
||||
t.Fatalf("poll 2: %v", err)
|
||||
}
|
||||
|
||||
history, err := store.History(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(history) != 2 {
|
||||
t.Fatalf("erwartet genau 2 statuswechsel (up -> down), habe %d: %+v", len(history), history)
|
||||
}
|
||||
if history[0].Status != StatusDown || history[1].Status != StatusUp {
|
||||
t.Fatalf("erwartet verlauf [down, up] (neueste zuerst), habe: %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
// Wiederholte Polls OHNE Statuswechsel duerfen den Verlauf nicht mit
|
||||
// identischen Eintraegen fluten (siehe recordIfChanged).
|
||||
func TestHistory_DoesNotDuplicateUnchangedStatus(t *testing.T) {
|
||||
store, cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
name := uniqueName("stable")
|
||||
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer up.Close()
|
||||
if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil {
|
||||
t.Fatalf("target: %v", err)
|
||||
}
|
||||
|
||||
poller := NewPoller(store, NewHTTPChecker(time.Second))
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := poller.PollOnce(ctx); err != nil {
|
||||
t.Fatalf("poll %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
history, err := store.History(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("erwartet genau 1 eintrag trotz 3 unveraenderter polls, habe %d", len(history))
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
DROP TABLE status_history;
|
||||
DROP TABLE status_targets;
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Zentrale Statusseite (OPS-02, siehe core-kanban/tickets/OPS-02.md) —
|
||||
-- ueberwachte Module und der Verlauf ihrer Statusaenderungen.
|
||||
CREATE TABLE status_targets (
|
||||
name TEXT PRIMARY KEY,
|
||||
health_url TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE status_history (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX status_history_name_idx ON status_history (name, changed_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE tenant_keks;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Master-Key-Verwaltung & Tenant-Schluesselhierarchie (API-10, siehe
|
||||
-- core-kanban/tickets/API-10.md) — EIN verpackter (mit dem Master-Key
|
||||
-- umhuellter) Tenant-KEK je Mandant. Niemals der Master-Key selbst und
|
||||
-- niemals ein Tenant-KEK im Klartext in dieser Tabelle.
|
||||
CREATE TABLE tenant_keks (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
||||
wrapped_kek BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
rotated_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -1,11 +1,23 @@
|
||||
#!/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
|
||||
|
||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||
ROLE="nexarch_test"
|
||||
|
||||
export PGPASSWORD="$PASS"
|
||||
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
||||
|
||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||
for db in $dbs; do
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
|
||||
done
|
||||
|
||||
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
#!/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
|
||||
|
||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
|
||||
|
||||
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
|
||||
|
||||
echo "== go build =="
|
||||
go build ./...
|
||||
|
||||
echo "== go vet =="
|
||||
go vet ./...
|
||||
|
||||
echo "== go test (-p 1) =="
|
||||
go test ./... -p 1 -count=1
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
||||
|
||||
export const metadata = {
|
||||
title: "NEXARCH Systemstatus",
|
||||
};
|
||||
|
||||
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,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchOverview, fetchHistory, type ModuleStatus, type HistoryEntry } from "@/lib/api";
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
export default function Page() {
|
||||
const [modules, setModules] = useState<ModuleStatus[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<HistoryEntry[] | null>(null);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const data = await fetchOverview();
|
||||
if (!cancelled) {
|
||||
setModules(data);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Ein nicht antwortendes Backend darf die zuletzt bekannte Ansicht
|
||||
// nicht loeschen und die Seite nicht unbedienbar machen
|
||||
// (Akzeptanzkriterium 2) — nur eine Fehlermeldung anzeigen, alte
|
||||
// Daten bleiben sichtbar.
|
||||
if (!cancelled) {
|
||||
setError(e.message ?? "Unbekannter Fehler beim Laden der Uebersicht");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
const id = setInterval(poll, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function openHistory(name: string) {
|
||||
setSelected(name);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const data = await fetchHistory(name);
|
||||
setHistory(data);
|
||||
} catch (e: any) {
|
||||
setHistoryError(e.message ?? "Verlauf konnte nicht geladen werden");
|
||||
setHistory(null);
|
||||
}
|
||||
}
|
||||
|
||||
const anyDown = modules.some((m) => m.status === "down");
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 800, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||
<h1>Systemstatus</h1>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "#c62828", background: "#fdecea", padding: "0.5rem", borderRadius: 4 }} role="alert">
|
||||
Uebersicht konnte gerade nicht aktualisiert werden: {error}. Zuletzt bekannter Stand wird weiter angezeigt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && anyDown && (
|
||||
<p style={{ color: "#c62828", background: "#fdecea", padding: "0.5rem", borderRadius: 4 }}>
|
||||
Mindestens ein Modul ist derzeit nicht erreichbar.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||
{modules.map((m) => (
|
||||
<li
|
||||
key={m.name}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "white",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "0.5rem",
|
||||
borderRadius: 8,
|
||||
borderLeft: `6px solid ${m.status === "up" ? "#2e7d32" : "#c62828"}`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{m.name}</strong>
|
||||
<div style={{ fontSize: "0.85rem", color: "#666" }}>
|
||||
{m.status === "up" ? "Verfügbar" : "Nicht verfügbar"}
|
||||
{m.last_checked && ` — zuletzt geprüft ${new Date(m.last_checked).toLocaleString("de-DE")}`}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => openHistory(m.name)}>Verlauf</button>
|
||||
</li>
|
||||
))}
|
||||
{modules.length === 0 && !error && <li>Lade Modulstatus…</li>}
|
||||
</ul>
|
||||
|
||||
{selected && (
|
||||
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
|
||||
<h2>Verlauf: {selected}</h2>
|
||||
{historyError && (
|
||||
<p style={{ color: "#c62828" }} role="alert">
|
||||
{historyError}
|
||||
</p>
|
||||
)}
|
||||
{history && (
|
||||
<ul>
|
||||
{history.map((h, i) => (
|
||||
<li key={i}>
|
||||
{new Date(h.changed_at).toLocaleString("de-DE")} — {h.status === "up" ? "verfügbar" : "nicht verfügbar"}
|
||||
</li>
|
||||
))}
|
||||
{history.length === 0 && <li>Keine Statusänderungen bisher.</li>}
|
||||
</ul>
|
||||
)}
|
||||
<button onClick={() => setSelected(null)}>Schließen</button>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Duenner Client des OPS-02-Backends (internal/statuspage) — keine eigene
|
||||
// Aggregations-/Polling-Logik im Frontend.
|
||||
export type ModuleStatus = {
|
||||
name: string;
|
||||
status: "up" | "down";
|
||||
last_checked?: string;
|
||||
};
|
||||
|
||||
export type HistoryEntry = {
|
||||
status: "up" | "down";
|
||||
changed_at: string;
|
||||
};
|
||||
|
||||
function apiBase(): string {
|
||||
const base = process.env.NEXT_PUBLIC_STATUSPAGE_API_URL;
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_STATUSPAGE_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
|
||||
);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// fetchOverview holt den Status EINES Moduls unabhaengig vom Erfolg der
|
||||
// anderen — ein Netzwerkfehler beim Abruf der Gesamtuebersicht wird vom
|
||||
// Aufrufer (Page-Komponente) abgefangen, sodass ein nicht antwortendes
|
||||
// Backend die Seite nicht zum Absturz bringt (Akzeptanzkriterium 2).
|
||||
export async function fetchOverview(): Promise<ModuleStatus[]> {
|
||||
const res = await fetch(`${apiBase()}/status/overview`, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Uebersicht konnte nicht geladen werden (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchHistory(name: string): Promise<HistoryEntry[]> {
|
||||
const res = await fetch(`${apiBase()}/status/history?name=${encodeURIComponent(name)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Verlauf konnte nicht geladen werden (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -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-status-page",
|
||||
"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"]
|
||||
}
|
||||
Reference in New Issue
Block a user