Compare commits

..
Author SHA1 Message Date
sysops 49a48743f5 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 22:02:02 +02:00
sysops 11940e28e6 OPS-02: fix — transpilePackages fuer @nexarch/shl (Next.js transpiliert node_modules sonst nicht, Build brach ab) 2026-08-28 21:56:39 +02:00
sysops f7bc927d50 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite 2026-08-28 21:56:31 +02:00
sysops 0f1add0b78 OPS-02: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) 2026-08-28 21:47:48 +02:00
sysops 265c09db24 Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/ops-02-zentrale-statusseite
# Conflicts:
#	DEVLOG.md
2026-08-28 21:47:32 +02:00
sysops cabcabda8d OPS-02: test-fix — overview-pruefung nur fuer eigene testziele, nicht alle jemals registrierten 2026-08-28 08:29:27 +02:00
sysops 81ebdd892b OPS-02: dev-server + next.js zentrale statusseite 2026-08-28 08:21:27 +02:00
sysops dff760e1ba OPS-02: zentrale-statusseite (backend: poller + aggregierte uebersicht + verlauf) 2026-08-28 08:19:40 +02:00
sysopsandClaude Sonnet 5 6a03dcafd6 OPS-01: health-check-endpunkte-je-modul
internal/health: wiederverwendbare Registry fuer benannte Checks (DB, Queue)
— nicht Core-spezifisch, sondern von jedem registrierten Modul (API-02)
gleichermassen einsetzbar. LivenessHandler prueft bewusst KEINE externen
Abhaengigkeiten (Akzeptanzkriterium 2: Liveness/Readiness getrennt) — ein
DB-Ausfall soll den Prozess nicht faelschlich als "tot" markieren und einen
grundlosen Neustart ausloesen. ReadinessHandler fuehrt alle registrierten
Checks NEBENLAEUFIG mit je eigenem Timeout aus (DefaultCheckTimeout=2s) und
liefert 503, sobald irgendeine Abhaengigkeit fehlschlaegt (Akzeptanz-
kriterium 1 + 3) — echte Pruefung von DB (Ping) und Job-Queue statt nur
Prozessstatus.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Simulierter Datenbankausfall fuehrt zu "nicht bereit" —
   TestReadinessHandler_ReportsNotReadyOnDatabaseFailure: geschlossener Pool,
   503 mit "database" im Checks-Ergebnis. PASS.
2. Health-Endpunkt antwortet auch bei haengendem Check innerhalb definierter
   Zeit — TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck:
   ein 10s blockierender Check wird durch 50ms-Timeout begrenzt, Handler
   antwortet deutlich unter 1s. PASS.
3. Readiness- und Liveness-Antwort unterscheiden sich nachweislich in
   mindestens einem Fehlerfall — TestLivenessAndReadiness_DifferOnDatabaseFailure:
   bei DB-Ausfall liefert Liveness weiterhin 200, Readiness 503. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:30:30 +02:00
sysopsandClaude Sonnet 5 b23cd1961f API-02: modul-registry-aktivierungspruefung
internal/moduleregistry: Registry.Register traegt Fachmodule mit Name,
Version und benoetigten Feature-Flags ein (Akzeptanzkriterium 1), fehlende
Pflichtangaben werden abgewiesen. IsActive kombiniert Registrierung + LIC-02
Feature-Flag-Auswertung (ALLE benoetigten Flags muessen fuer den Tenant
aktiv sein) — ein nicht registriertes Modul ist nie aktiv. List liefert alle
Module fuer Statusseite/Lizenzoberflaeche (Akzeptanzkriterium 3).

RequireActiveModule ist die zentrale Durchsetzungs-Middleware (Casbin-
Prinzip): weist Anfragen an ein deaktiviertes Modul ab, BEVOR der
Modul-Handler ueberhaupt aufgerufen wird (Akzeptanzkriterium 2) —
Pruefung per Test belegt, dass der Handler bei Deaktivierung nachweislich
nicht erreicht wird.

Service-Credentials (Akzeptanzkriterium 4): Registry.Provision stellt pro
Modul-Instanz Client-ID + Secret aus, gespeichert wird nur der SHA-256-Hash
des Secrets. Registry.Authenticate vergleicht timing-safe (dieselbe
subtle.ConstantTimeCompare-Referenzimplementierung wie AUD-02).
RequireServiceCredential-Middleware liest X-Client-Id/X-Client-Secret und
weist Aufrufe ohne gueltiges Credential mit 401 ab, bevor der Core-seitige
Endpunkt (z.B. Audit-Nachlieferung, Nutzungsmeldung) erreicht wird.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Anfrage an deaktiviertes Modul nachweislich vor Modul-Logik abgewiesen —
   TestRequireActiveModule_BlocksBeforeHandler: handlerReached bleibt false
   bei 403, wird true erst nach Aktivierung bei 200. PASS.
2. Registrierung mit fehlenden Pflichtangaben abgewiesen —
   TestRegister_RejectsMissingFields (leerer Name, leere Version). PASS.
3. Registry-Abfrage liefert konsistente Daten nach Aktivierung/Deaktivierung —
   TestIsActive_ReflectsFlagStateConsistently: aus/an/aus-Zyklus, IsActive
   folgt dem Flag-Zustand korrekt. PASS.
4. Aufruf mit ungueltigem/fehlendem Service-Credential abgewiesen, mit
   gueltigem angenommen — TestRequireServiceCredential_RejectsInvalidAcceptsValid
   und TestProvisionAndAuthenticate (falsches Secret, unbekannte Client-ID,
   korrektes Credential). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:17:50 +02:00
sysopsandClaude Sonnet 5 4a30345e07 LIC-02: feature-flag-service-je-tenant
internal/flag: Store (Verwaltung) + Service (Auswertung mit TTL-Cache,
Default 5s) — Unleash-Prinzip Flag-Verwaltung vs. Flag-Auswertung getrennt,
als Kernfunktion des Core-Dienstes selbst statt separater Infrastruktur.

evaluate() wendet drei Strategien in fester Reihenfolge an: global an/aus,
Tenant-Zielgruppe, deterministischer Prozentsatz-Rollout (FNV-Hash aus
Tenant+Key, stabil pro Tenant). IsEnabled liefert IMMER nur bool (kein
Fehlerwert) — ein nicht erreichbarer Flag-Dienst kann damit keinen
Aufrufer zum Absturz bringen: bei DB-Fehler wird der zuletzt bekannte
Cache-Stand verwendet, ohne jeglichen Stand faellt der Dienst sicher auf
false zurueck. Service.Invalidate erzwingt sofortiges Neuladen fuer den
Schreiber selbst, andere Instanzen sehen Aenderungen spaetestens nach der
TTL (Akzeptanzkriterium 3, kein Neustart noetig).

Bugfix waehrend Tests: Store.Set uebergab ein nil-TargetTenantSlugs-Slice
als SQL NULL statt leerem Array (NOT-NULL-Verletzung) — auf leeres Slice
normalisiert.

Akzeptanzkriterium 4 (Deaktivierung loescht keine Daten): dieses Paket
besitzt ausschliesslich die eigene feature_flags-Zeile, hat keinerlei
Code-Pfad, der Modul-Geschaeftsdaten anfassen koennte — Loeschung bleibt
strukturell der Archive-Retention-Engine vorbehalten.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Cache-Invalidierungszeit automatisiert gemessen —
   TestService_CacheInvalidationTiming: Aenderung wirksam nach 153ms bei
   TTL=150ms (innerhalb Ziel+Toleranz), vorher nachweislich noch alter Stand. PASS.
2. Zielgruppen-Strategie liefert erwartete Auswertung —
   TestService_TargetTenantStrategy / TestEvaluate_TargetTenantStrategy. PASS.
3. Ausfall des Flag-Dienstes fuehrt zu dokumentiertem Fallback, kein Absturz —
   TestService_FallsBackOnStoreFailure (mit recover()-Absicherung): Fallback
   auf Cache-Stand bzw. sicheres false bei komplett unerreichbarer DB, geloggt. PASS.
4. Modul-Deaktivierung/Reaktivierung ohne Datenverlust — architektonisch durch
   fehlenden Code-Pfad sichergestellt (siehe oben), zusaetzlich durch
   TestService_InvalidateForcesImmediateRefresh (Toggle aus/an bleibt
   konsistent nachvollziehbar) mitabgedeckt. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:19:59 +02:00
41 changed files with 2042 additions and 1037 deletions
-69
View File
@@ -1,69 +0,0 @@
// auditlog-devserver stellt AUD-03s ExportHandler (internal/audit) fuer die
// Next.js-Audit-Log-Ansicht (AUD-04) bereit. Getrennt von cmd/core aus
// demselben Grund wie die anderen *-devserver (siehe LIC-04/TEN-05): echte
// Auth/RBAC ist noch nicht in die zentrale Server-Topologie verdrahtet.
//
// Authorize wird hier mit einem geteilten Admin-Token ueber
// crypto/subtle.ConstantTimeCompare umgesetzt — demselben Timing-safe-Muster
// wie internal/audit.timingsafe (AUD-02), NICHT ueber eine neue
// Rollen-/Rechteschicht, da diese Kachel ausdruecklich nur von AUD-03
// abhaengt und keine Rechteverwaltung duplizieren soll.
package main
import (
"context"
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
"gitea.perlbach24.de/scripte/nexarch/internal/auditadmin"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
adminToken := os.Getenv("NEXARCH_AUDIT_ADMIN_TOKEN")
if adminToken == "" {
log.Fatal("NEXARCH_AUDIT_ADMIN_TOKEN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_AUDITLOG_LISTEN_ADDR")
if addr == "" {
addr = ":8083"
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
auditLog := audit.NewLog(pool)
tokenAuthorize := auditadmin.NewTokenAuthorizer(adminToken)
authorize := func(_ context.Context, caller string) bool { return tokenAuthorize(caller) }
handler := audit.NewExportHandler(auditLog, authorize)
mux := http.NewServeMux()
mux.HandleFunc("/audit/export", withCORS(handler.Export))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("auditlog-devserver listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
+66
View File
@@ -0,0 +1,66 @@
// 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)
}
}
-96
View File
@@ -1,96 +0,0 @@
// Package audit implementiert Core AUD-01: das zentrale, vom allgemeinen
// Anwendungs-Log getrennte Audit-Datenmodell fuer sicherheits- und
// compliancerelevante Ereignisse (wer, was, wann, an welchem Tenant).
// Unveraenderlichkeit (Append-only) ist AUD-02, Export/Filter-API ist AUD-03
// — dieses Paket liefert nur das Datenmodell und den EINEN zentralen
// Schreibpfad (Akzeptanzkriterium 3).
package audit
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// SystemTenant ist der reservierte Tenant-Bezug fuer mandantenuebergreifende
// Ereignisse (z.B. Superadmin-Aktionen) — es gibt bewusst KEINEN Weg, ein
// Ereignis ganz ohne Tenant-Bezug zu schreiben (Akzeptanzkriterium 2).
const SystemTenant = "system"
var ErrMissingTenant = errors.New("audit: tenant_slug darf nicht leer sein")
var ErrMissingActor = errors.New("audit: actor darf nicht leer sein")
var ErrMissingAction = errors.New("audit: action darf nicht leer sein")
// Event ist ein strukturiertes Audit-Ereignis (Akzeptanzkriterium 1: Akteur,
// Aktion, Zielobjekt, Zeitpunkt, Tenant).
type Event struct {
TenantSlug string
Actor string
Action string
Target string
Metadata map[string]any
OccurredAt time.Time
}
// Log ist der EINE zentrale Schreibpfad fuer Audit-Ereignisse — es gibt
// bewusst keine zweite Schreibmoeglichkeit, damit kein Handler versehentlich
// direkt in audit_events schreibt und dabei die Validierung umgeht
// (Akzeptanzkriterium 3).
type Log struct {
pool *pgxpool.Pool
}
func NewLog(pool *pgxpool.Pool) *Log {
return &Log{pool: pool}
}
// Record persistiert genau einen Audit-Eintrag. Fehlender Tenant-Bezug wird
// bereits hier abgewiesen (klarer Fehler statt Constraint-Verletzung im
// Normalfall) — die Datenbank-CHECK-Constraint aus der Migration ist die
// zweite, unumgehbare Verteidigungslinie (Akzeptanzkriterium 2 / Pruefung 2).
func (l *Log) Record(ctx context.Context, e Event) error {
if e.TenantSlug == "" {
return ErrMissingTenant
}
if e.Actor == "" {
return ErrMissingActor
}
if e.Action == "" {
return ErrMissingAction
}
if e.Metadata == nil {
e.Metadata = map[string]any{}
}
metadataJSON, err := json.Marshal(e.Metadata)
if err != nil {
return fmt.Errorf("metadaten serialisieren: %w", err)
}
if e.OccurredAt.IsZero() {
e.OccurredAt = time.Now()
}
_, err = l.pool.Exec(ctx, `
INSERT INTO audit_events (occurred_at, tenant_slug, actor, action, target, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
`, e.OccurredAt, e.TenantSlug, e.Actor, e.Action, e.Target, metadataJSON)
if err != nil {
return fmt.Errorf("audit-ereignis schreiben: %w", err)
}
return nil
}
// CountByTenant ist eine schlanke Lesehilfe fuer Tests/Diagnose — die
// eigentliche Filter-/Export-API ist AUD-03, hier bewusst nicht vorgezogen.
func (l *Log) CountByTenant(ctx context.Context, tenantSlug string) (int, error) {
var n int
if err := l.pool.QueryRow(ctx, `
SELECT count(*) FROM audit_events WHERE tenant_slug = $1
`, tenantSlug).Scan(&n); err != nil {
return 0, fmt.Errorf("audit-ereignisse zaehlen: %w", err)
}
return n, nil
}
-132
View File
@@ -1,132 +0,0 @@
package audit
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupAuditTest(t *testing.T) (*Log, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_events WHERE tenant_slug LIKE 'test\_%' ESCAPE '\' OR tenant_slug = $1`, SystemTenant)
pool.Close()
}
return NewLog(pool), pool, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: ein sicherheitsrelevanter Vorgang
// (hier: fehlgeschlagener Login) erzeugt zuverlaessig genau einen Eintrag.
func TestRecord_PersistsExactlyOneEventPerSecurityIncident(t *testing.T) {
log, pool, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
err := log.Record(ctx, Event{
TenantSlug: "test_acme",
Actor: "alice@example.com",
Action: "iam.login_failed",
Target: "user:alice@example.com",
Metadata: map[string]any{"reason": "falsches passwort"},
})
if err != nil {
t.Fatalf("record: %v", err)
}
count, err := log.CountByTenant(ctx, "test_acme")
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("erwartet genau 1 audit-eintrag, habe %d", count)
}
var actor, action, target string
if err := pool.QueryRow(ctx, `
SELECT actor, action, target FROM audit_events WHERE tenant_slug = 'test_acme'
`).Scan(&actor, &action, &target); err != nil {
t.Fatalf("eintrag lesen: %v", err)
}
if actor != "alice@example.com" || action != "iam.login_failed" || target != "user:alice@example.com" {
t.Fatalf("eintrag unerwartet: actor=%q action=%q target=%q", actor, action, target)
}
}
// Akzeptanzkriterium 2 + Pruefung 2 (App-Ebene): fehlender Tenant-Bezug wird
// bereits vom zentralen Schreibpfad abgewiesen.
func TestRecord_RejectsMissingTenant(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
err := log.Record(ctx, Event{TenantSlug: "", Actor: "alice", Action: "irgendwas"})
if !errors.Is(err, ErrMissingTenant) {
t.Fatalf("erwartet ErrMissingTenant, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2 (DB-Ebene): selbst ein direkter INSERT,
// der Log.Record umgeht, wird durch die CHECK-Constraint verhindert — der
// Schutz haengt nicht allein von der Go-Validierung ab.
func TestConstraint_RejectsMissingTenantAtDatabaseLevel(t *testing.T) {
_, pool, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
_, err := pool.Exec(ctx, `
INSERT INTO audit_events (tenant_slug, actor, action, target)
VALUES ('', 'alice', 'irgendwas', 'ziel')
`)
if err == nil {
t.Fatal("erwartet fehler durch CHECK-constraint bei leerem tenant_slug, habe nil")
}
}
func TestRecord_RejectsMissingActorAndAction(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "", Action: "x"}); !errors.Is(err, ErrMissingActor) {
t.Fatalf("erwartet ErrMissingActor, habe %v", err)
}
if err := log.Record(ctx, Event{TenantSlug: "test_acme", Actor: "alice", Action: ""}); !errors.Is(err, ErrMissingAction) {
t.Fatalf("erwartet ErrMissingAction, habe %v", err)
}
}
func TestRecord_SystemTenantForCrossTenantEvents(t *testing.T) {
log, _, cleanup := setupAuditTest(t)
defer cleanup()
ctx := context.Background()
if err := log.Record(ctx, Event{TenantSlug: SystemTenant, Actor: "superadmin", Action: "tenant.provisioned", Target: "tenant:acme"}); err != nil {
t.Fatalf("record mit SystemTenant: %v", err)
}
}
-122
View File
@@ -1,122 +0,0 @@
package audit
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"time"
)
// Filter grenzt einen Export ein (Akzeptanzkriterium 1). Leere/Nil-Felder
// bedeuten "kein Filter auf diesem Feld".
type Filter struct {
TenantSlug string
Actor string
Action string
From *time.Time
To *time.Time
}
func buildFilterQuery(f Filter) (string, []any) {
query := `SELECT occurred_at, tenant_slug, actor, action, target, metadata FROM audit_events WHERE 1=1`
var args []any
if f.TenantSlug != "" {
args = append(args, f.TenantSlug)
query += fmt.Sprintf(" AND tenant_slug = $%d", len(args))
}
if f.Actor != "" {
args = append(args, f.Actor)
query += fmt.Sprintf(" AND actor = $%d", len(args))
}
if f.Action != "" {
args = append(args, f.Action)
query += fmt.Sprintf(" AND action = $%d", len(args))
}
if f.From != nil {
args = append(args, *f.From)
query += fmt.Sprintf(" AND occurred_at >= $%d", len(args))
}
if f.To != nil {
args = append(args, *f.To)
query += fmt.Sprintf(" AND occurred_at <= $%d", len(args))
}
query += " ORDER BY occurred_at"
return query, args
}
// StreamCSV schreibt gefilterte Audit-Eintraege direkt als CSV in w, Zeile
// fuer Zeile ueber rows.Next() — es wird zu keinem Zeitpunkt das gesamte
// Ergebnis im Speicher aufgebaut (Akzeptanzkriterium 3 / Pruefung 1).
func (l *Log) StreamCSV(ctx context.Context, filter Filter, w io.Writer) error {
query, args := buildFilterQuery(filter)
rows, err := l.pool.Query(ctx, query, args...)
if err != nil {
return fmt.Errorf("export abfragen: %w", err)
}
defer rows.Close()
cw := csv.NewWriter(w)
if err := cw.Write([]string{"occurred_at", "tenant_slug", "actor", "action", "target", "metadata"}); err != nil {
return fmt.Errorf("csv-header schreiben: %w", err)
}
for rows.Next() {
var occurredAt time.Time
var tenantSlug, actor, action, target string
var metadataJSON []byte
if err := rows.Scan(&occurredAt, &tenantSlug, &actor, &action, &target, &metadataJSON); err != nil {
return fmt.Errorf("zeile lesen: %w", err)
}
if err := cw.Write([]string{
occurredAt.Format(time.RFC3339), tenantSlug, actor, action, target, string(metadataJSON),
}); err != nil {
return fmt.Errorf("csv-zeile schreiben: %w", err)
}
}
cw.Flush()
if err := rows.Err(); err != nil {
return fmt.Errorf("export lesen: %w", err)
}
return cw.Error()
}
// exportRecord ist die JSON-Repraesentation einer exportierten Zeile.
type exportRecord struct {
OccurredAt time.Time `json:"occurred_at"`
TenantSlug string `json:"tenant_slug"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
Metadata json.RawMessage `json:"metadata"`
}
// StreamJSON schreibt gefilterte Audit-Eintraege als JSON Lines (ein
// JSON-Objekt pro Zeile) — bewusst kein einzelnes grosses JSON-Array, da
// dessen korrektes Streaming (Kommas/Klammern ohne Zwischenpufferung)
// unnoetige Komplexitaet fuer denselben Zweck waere. Wie StreamCSV
// zeilenweise ueber rows.Next(), kein Aufbau im Speicher.
func (l *Log) StreamJSON(ctx context.Context, filter Filter, w io.Writer) error {
query, args := buildFilterQuery(filter)
rows, err := l.pool.Query(ctx, query, args...)
if err != nil {
return fmt.Errorf("export abfragen: %w", err)
}
defer rows.Close()
enc := json.NewEncoder(w)
for rows.Next() {
var rec exportRecord
var metadataJSON []byte
if err := rows.Scan(&rec.OccurredAt, &rec.TenantSlug, &rec.Actor, &rec.Action, &rec.Target, &metadataJSON); err != nil {
return fmt.Errorf("zeile lesen: %w", err)
}
rec.Metadata = metadataJSON
if err := enc.Encode(rec); err != nil {
return fmt.Errorf("json-zeile schreiben: %w", err)
}
}
return rows.Err()
}
-73
View File
@@ -1,73 +0,0 @@
package audit
import (
"context"
"net/http"
"time"
)
// Authorize entscheidet, ob caller den Export ausfuehren darf. Die
// eigentliche Rollen-/Rechtepruefung (RBAC-02 Policy-Enforcement) ist nicht
// Teil dieser Kachel — ExportHandler kennt nur diese schmale Schnittstelle,
// analog zum RetentionRegistrar-Muster aus AUD-05.
type Authorize func(ctx context.Context, caller string) bool
// ExportHandler stellt den Export als HTTP-Endpunkt bereit
// (Akzeptanzkriterium 2: fuer berechtigte Rollen verfuegbar).
type ExportHandler struct {
log *Log
authorize Authorize
}
func NewExportHandler(log *Log, authorize Authorize) *ExportHandler {
return &ExportHandler{log: log, authorize: authorize}
}
// Export liest Filter-Query-Parameter (tenant, actor, action, from, to,
// format) und schreibt DIREKT auf den ResponseWriter (io.Writer) — dieselbe
// Streaming-Funktion wie in export.go, kein zusaetzlicher Pufferungsschritt.
func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) {
// "caller" identifiziert die anfragende Person fuer die Berechtigungs-
// pruefung — bewusst getrennt vom Filterfeld "actor" (das den
// AUDIT-Akteur meint, ueber den gefiltert wird).
caller := r.URL.Query().Get("caller")
if caller == "" || !h.authorize(r.Context(), caller) {
http.Error(w, "keine berechtigung fuer audit-log-export", http.StatusForbidden)
return
}
filter := Filter{
TenantSlug: r.URL.Query().Get("tenant"),
Actor: r.URL.Query().Get("actor"),
Action: r.URL.Query().Get("action"),
}
if from := r.URL.Query().Get("from"); from != "" {
t, err := time.Parse(time.RFC3339, from)
if err != nil {
http.Error(w, "ungueltiges from-datum, erwartet RFC3339", http.StatusBadRequest)
return
}
filter.From = &t
}
if to := r.URL.Query().Get("to"); to != "" {
t, err := time.Parse(time.RFC3339, to)
if err != nil {
http.Error(w, "ungueltiges to-datum, erwartet RFC3339", http.StatusBadRequest)
return
}
filter.To = &t
}
switch r.URL.Query().Get("format") {
case "json":
w.Header().Set("Content-Type", "application/x-ndjson")
if err := h.log.StreamJSON(r.Context(), filter, w); err != nil {
http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError)
}
default:
w.Header().Set("Content-Type", "text/csv")
if err := h.log.StreamCSV(r.Context(), filter, w); err != nil {
http.Error(w, "export fehlgeschlagen", http.StatusInternalServerError)
}
}
}
-222
View File
@@ -1,222 +0,0 @@
package audit
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"net/http"
"net/http/httptest"
"os"
"runtime"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupExportTest(t *testing.T) (*Log, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewLog(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Filterkombinationen liefern korrekte
// Teilmengen.
func TestExport_FilterCombinations(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
run := fmt.Sprintf("_%d", time.Now().UnixNano())
tenantA, tenantB := "test_fa"+run, "test_fb"+run
alice, bob := "alice"+run, "bob"+run
events := []Event{
{TenantSlug: tenantA, Actor: alice, Action: "login", Target: "x"},
{TenantSlug: tenantA, Actor: bob, Action: "login", Target: "x"},
{TenantSlug: tenantA, Actor: alice, Action: "logout", Target: "x"},
{TenantSlug: tenantB, Actor: alice, Action: "login", Target: "x"},
}
for _, e := range events {
if err := log.Record(ctx, e); err != nil {
t.Fatalf("record: %v", err)
}
}
cases := []struct {
name string
filter Filter
wantLen int
}{
{"nach tenant", Filter{TenantSlug: tenantA}, 3},
{"nach tenant+actor", Filter{TenantSlug: tenantA, Actor: alice}, 2},
{"nach tenant+actor+action", Filter{TenantSlug: tenantA, Actor: alice, Action: "login"}, 1},
{"nach actor ueber beide tenants", Filter{Actor: alice, Action: "login"}, 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var buf bytes.Buffer
if err := log.StreamCSV(ctx, c.filter, &buf); err != nil {
t.Fatalf("stream: %v", err)
}
rows, err := csv.NewReader(&buf).ReadAll()
if err != nil {
t.Fatalf("csv parsen: %v", err)
}
got := len(rows) - 1 // Header abziehen
if got != c.wantLen {
t.Fatalf("erwartet %d zeilen, habe %d", c.wantLen, got)
}
})
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zeitraum-Filter.
func TestExport_TimeRangeFilter(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
tenant := fmt.Sprintf("test_tr_%d", time.Now().UnixNano())
past := time.Now().Add(-48 * time.Hour)
future := time.Now().Add(48 * time.Hour)
if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "a", Action: "x", Target: "t", OccurredAt: time.Now()}); err != nil {
t.Fatalf("record: %v", err)
}
var buf bytes.Buffer
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &past, To: &future}, &buf); err != nil {
t.Fatalf("stream (innerhalb range): %v", err)
}
if got := countLines(buf.String()) - 1; got != 1 {
t.Fatalf("erwartet 1 eintrag innerhalb des zeitraums, habe %d", got)
}
farPast := time.Now().Add(-96 * time.Hour)
buf.Reset()
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant, From: &farPast, To: &past}, &buf); err != nil {
t.Fatalf("stream (ausserhalb range): %v", err)
}
if got := countLines(buf.String()) - 1; got != 0 {
t.Fatalf("erwartet 0 eintraege ausserhalb des zeitraums, habe %d", got)
}
}
func countLines(s string) int {
s = strings.TrimRight(s, "\n")
if s == "" {
return 0
}
return len(strings.Split(s, "\n"))
}
// Akzeptanzkriterium 3 + Pruefung 1: Export mit hoher Eintragszahl ohne
// uebermaessigen Speicherverbrauch — Stichprobe per runtime.MemStats.
func TestExport_StreamsLargeResultWithoutExcessiveMemory(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
ctx := context.Background()
tenant := fmt.Sprintf("test_large_%d", time.Now().UnixNano())
const n = 20000
for i := 0; i < n; i++ {
if err := log.Record(ctx, Event{TenantSlug: tenant, Actor: "bulk", Action: "test.bulk", Target: fmt.Sprintf("obj-%d", i)}); err != nil {
t.Fatalf("record %d: %v", i, err)
}
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
lineCount := 0
cw := &countingWriter{onWrite: func(p []byte) { lineCount += strings.Count(string(p), "\n") }}
if err := log.StreamCSV(ctx, Filter{TenantSlug: tenant}, cw); err != nil {
t.Fatalf("stream: %v", err)
}
runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
if lineCount != n+1 { // +1 Header
t.Fatalf("erwartet %d zeilen (inkl. header), habe %d", n+1, lineCount)
}
// Grobe Stichprobe: ein NICHT streamender Export haette hier locker
// mehrere MB an einmal gehaltenen Zeilen/Strings erzeugt. Grosszuegige
// Schwelle, da Go-Heap-Messungen naturgemaess rauschen.
const maxAcceptableGrowth = 3 * 1024 * 1024 // 3 MB
growth := int64(after.HeapAlloc) - int64(before.HeapAlloc)
t.Logf("heap-wachstum waehrend export von %d zeilen: %d bytes (schwelle: %d)", n, growth, maxAcceptableGrowth)
if growth > maxAcceptableGrowth {
t.Fatalf("heap ist um %d bytes gewachsen, erwartet unter %d (hinweis auf vollstaendige pufferung statt streaming)", growth, maxAcceptableGrowth)
}
}
type countingWriter struct {
onWrite func(p []byte)
}
func (w *countingWriter) Write(p []byte) (int, error) {
w.onWrite(p)
return len(p), nil
}
// Akzeptanzkriterium 2 + Pruefung 3: Zugriff ohne passende Berechtigung wird abgewiesen.
func TestExportHandler_RejectsWithoutAuthorization(t *testing.T) {
log, cleanup := setupExportTest(t)
defer cleanup()
handler := NewExportHandler(log, func(ctx context.Context, caller string) bool {
return caller == "berechtigte-person@example.com"
})
req := httptest.NewRequest(http.MethodGet, "/audit/export?caller=unberechtigt@example.com", nil)
rec := httptest.NewRecorder()
handler.Export(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("unberechtigt: status = %d, want 403", rec.Code)
}
reqNoCaller := httptest.NewRequest(http.MethodGet, "/audit/export", nil)
recNoCaller := httptest.NewRecorder()
handler.Export(recNoCaller, reqNoCaller)
if recNoCaller.Code != http.StatusForbidden {
t.Fatalf("ohne caller: status = %d, want 403", recNoCaller.Code)
}
reqOK := httptest.NewRequest(http.MethodGet, "/audit/export?caller=berechtigte-person@example.com", nil)
recOK := httptest.NewRecorder()
handler.Export(recOK, reqOK)
if recOK.Code != http.StatusOK {
t.Fatalf("berechtigt: status = %d, want 200", recOK.Code)
}
}
-23
View File
@@ -1,23 +0,0 @@
// Package auditadmin implementiert Core AUD-04: die Berechtigungspruefung
// fuer die Audit-Log-Ansicht. Enthaelt bewusst KEINE eigene Filter-/
// Export-Logik — die Ansicht ist "reiner Konsument der Export-API" (AUD-03,
// internal/audit.ExportHandler); dieses Paket liefert nur die
// audit.Authorize-Implementierung, die der Entwicklungs-/Testserver
// (cmd/auditlog-devserver) einhaengt.
package auditadmin
import "crypto/subtle"
// NewTokenAuthorizer liefert eine audit.Authorize-Funktion, die den
// aufrufenden "caller"-Wert timing-safe gegen ein geteiltes Admin-Token
// vergleicht — dasselbe Muster wie internal/audit.timingsafe (AUD-02),
// NICHT ueber eine neue Rollen-/Rechteschicht, da AUD-04 ausdruecklich nur
// von AUD-03 abhaengt.
func NewTokenAuthorizer(adminToken string) func(caller string) bool {
return func(caller string) bool {
if caller == "" || adminToken == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(caller), []byte(adminToken)) == 1
}
}
-33
View File
@@ -1,33 +0,0 @@
package auditadmin
import "testing"
// Grundlage fuer Akzeptanzkriterium 1/2: die Oberflaeche darf Daten und den
// Export nur bei korrektem Admin-Token abrufen.
func TestNewTokenAuthorizer_AcceptsCorrectToken(t *testing.T) {
authorize := NewTokenAuthorizer("geheimes-token")
if !authorize("geheimes-token") {
t.Fatal("erwartet true fuer korrektes token")
}
}
func TestNewTokenAuthorizer_RejectsWrongToken(t *testing.T) {
authorize := NewTokenAuthorizer("geheimes-token")
if authorize("falsches-token") {
t.Fatal("erwartet false fuer falsches token")
}
}
func TestNewTokenAuthorizer_RejectsEmptyCaller(t *testing.T) {
authorize := NewTokenAuthorizer("geheimes-token")
if authorize("") {
t.Fatal("erwartet false fuer leeren aufrufer")
}
}
func TestNewTokenAuthorizer_RejectsWhenNoTokenConfigured(t *testing.T) {
authorize := NewTokenAuthorizer("")
if authorize("irgendwas") {
t.Fatal("erwartet false, wenn kein admin-token konfiguriert ist (fail-safe-default)")
}
}
+87
View File
@@ -0,0 +1,87 @@
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
package flag
import (
"context"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("flag: nicht gefunden")
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
type Flag struct {
Key string
Enabled bool
RolloutPercentage int
TargetTenantSlugs []string
}
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Set(ctx context.Context, f Flag) error {
if f.TargetTenantSlugs == nil {
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key) DO UPDATE SET
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
if err != nil {
return fmt.Errorf("flag speichern: %w", err)
}
return nil
}
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
var f Flag
row := s.pool.QueryRow(ctx, `
SELECT key, enabled, rollout_percentage, target_tenant_slugs
FROM feature_flags WHERE key = $1
`, key)
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Flag{}, ErrNotFound
}
return Flag{}, fmt.Errorf("flag lesen: %w", err)
}
return f, nil
}
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
// kein Feature wird versehentlich aktiv.
func evaluate(f Flag, tenantSlug string) bool {
if f.Enabled {
return true
}
for _, target := range f.TargetTenantSlugs {
if target == tenantSlug {
return true
}
}
if f.RolloutPercentage > 0 {
h := fnv.New32a()
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
return int(h.Sum32()%100) < f.RolloutPercentage
}
return false
}
+43
View File
@@ -0,0 +1,43 @@
package flag
import "testing"
func TestEvaluate_GlobalEnabled(t *testing.T) {
f := Flag{Key: "k", Enabled: true}
if !evaluate(f, "irgendein-tenant") {
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
if !evaluate(f, "acme") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if evaluate(f, "globex") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
full := Flag{Key: "k", RolloutPercentage: 100}
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
t.Fatal("100% rollout sollte immer true liefern")
}
none := Flag{Key: "k", RolloutPercentage: 0}
if evaluate(none, "beliebiger-tenant") {
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
}
}
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
f := Flag{Key: "k", RolloutPercentage: 50}
first := evaluate(f, "stabiler-tenant")
for i := 0; i < 5; i++ {
if evaluate(f, "stabiler-tenant") != first {
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package flag
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
// muss (Akzeptanzkriterium 3).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
flag Flag
expiresAt time.Time
}
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
f, ok := s.resolve(ctx, key)
if !ok {
return false
}
return evaluate(f, tenantSlug)
}
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
s.mu.RLock()
entry, exists := s.cache[key]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.flag, true
}
f, err := s.store.Get(ctx, key)
if err != nil {
if exists {
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
"flag_key", key, "error", err)
return entry.flag, true
}
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
"flag_key", key, "error", err)
return Flag{}, false
}
s.mu.Lock()
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return f, true
}
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
func (s *Service) Invalidate(key string) {
s.mu.Lock()
delete(s.cache, key)
s.mu.Unlock()
}
+179
View File
@@ -0,0 +1,179 @@
package flag
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupFlagStoreTest(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 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()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
// die erwartete Auswertung.
func TestService_TargetTenantStrategy(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("erwartet false vor der aenderung")
}
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
// Store, ohne svc.Invalidate aufzurufen.
changedAt := time.Now()
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
// Sofort danach sollte der Cache noch den alten Stand liefern.
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
elapsed := time.Since(changedAt)
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_flag")
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
func TestService_FallsBackOnStoreFailure(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet true bei funktionierender db")
}
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
if err != nil {
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
}
brokenStore := NewStore(brokenPool)
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
svcWithCache.mu.Lock()
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
flag: Flag{Key: "test_fallback_flag", Enabled: true},
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
}
svcWithCache.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
}
}()
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
}
}()
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
// default false, kein absturz.
freshSvc := NewService(brokenStore, time.Hour)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict: %v", r)
}
}()
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
}
}()
}
+25
View File
@@ -0,0 +1,25 @@
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
}
}
+49
View File
@@ -0,0 +1,49 @@
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)
}
+86
View File
@@ -0,0 +1,86 @@
// 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
}
+161
View File
@@ -0,0 +1,161 @@
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"])
}
}
+99
View File
@@ -0,0 +1,99 @@
package moduleregistry
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var (
ErrModuleNotRegistered = errors.New("moduleregistry: modul muss vor provisionierung registriert sein")
ErrInvalidCredential = errors.New("moduleregistry: ungueltiges oder fehlendes service-credential")
)
// Provision stellt ein Service-Credential (Client-ID + Secret) fuer eine
// Modul-Instanz aus (Akzeptanzkriterium 4). Das Secret wird NUR beim
// Ausstellen im Klartext zurueckgegeben, gespeichert wird ausschliesslich
// dessen SHA-256-Hash.
func (r *Registry) Provision(ctx context.Context, moduleName string) (clientID, secret string, err error) {
if _, err := r.Get(ctx, moduleName); err != nil {
if errors.Is(err, ErrModuleNotFound) {
return "", "", ErrModuleNotRegistered
}
return "", "", err
}
clientID, err = randomToken(16)
if err != nil {
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
}
secret, err = randomToken(32)
if err != nil {
return "", "", fmt.Errorf("secret erzeugen: %w", err)
}
hash := hashSecret(secret)
_, err = r.pool.Exec(ctx, `
INSERT INTO module_credentials (module_name, client_id, secret_hash, issued_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (module_name) DO UPDATE SET client_id = $2, secret_hash = $3, issued_at = now()
`, moduleName, clientID, hash)
if err != nil {
return "", "", fmt.Errorf("credential speichern: %w", err)
}
return clientID, secret, nil
}
// Authenticate prueft ein Service-Credential timing-safe (Referenzmuster
// siehe AUD-02) — Aufrufe ohne gueltiges Credential werden abgelehnt
// (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error) {
if clientID == "" || secret == "" {
return "", false, nil
}
var storedHash []byte
err = r.pool.QueryRow(ctx, `
SELECT module_name, secret_hash FROM module_credentials WHERE client_id = $1
`, clientID).Scan(&moduleName, &storedHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("credential lesen: %w", err)
}
if !timingSafeEqual(hashSecret(secret), storedHash) {
return "", false, nil
}
return moduleName, true, nil
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// timingSafeEqual folgt derselben Referenzimplementierung wie AUD-02
// (subtle.ConstantTimeCompare) — projektweite Konvention fuer jeden
// sicherheitsrelevanten Vergleich.
func timingSafeEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare(a, b) == 1
}
+46
View File
@@ -0,0 +1,46 @@
package moduleregistry
import "net/http"
// RequireActiveModule weist Anfragen an ein nicht aktiviertes Modul ZENTRAL
// ab, bevor der eigentliche Modul-Handler erreicht wird (Akzeptanzkriterium 2 /
// Pruefung 1) — Casbin-Prinzip: Durchsetzung als Middleware statt verstreuter
// Pruefungen in jedem Handler. tenantSlug/moduleName werden hier ueber
// Query-Parameter gelesen (echte Extraktion aus JWT/Tenant-Kontext ist
// API-05/TEN-06, nicht Teil dieser Kachel).
func (r *Registry) RequireActiveModule(moduleName string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tenantSlug := req.URL.Query().Get("tenant")
active, err := r.IsActive(req.Context(), tenantSlug, moduleName)
if err != nil {
http.Error(w, "aktivierungspruefung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !active {
http.Error(w, "modul nicht aktiviert", http.StatusForbidden)
return
}
next(w, req)
}
}
// RequireServiceCredential authentifiziert eine Modul-Instanz ueber ihr
// Service-Credential (X-Client-Id/X-Client-Secret-Header) BEVOR der
// eigentliche Handler erreicht wird (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) RequireServiceCredential(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
clientID := req.Header.Get("X-Client-Id")
secret := req.Header.Get("X-Client-Secret")
_, ok, err := r.Authenticate(req.Context(), clientID, secret)
if err != nil {
http.Error(w, "authentifizierung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, ErrInvalidCredential.Error(), http.StatusUnauthorized)
return
}
next(w, req)
}
}
+120
View File
@@ -0,0 +1,120 @@
// Package moduleregistry implementiert Core API-02: die Registry, in der
// sich Fachmodule (DMS, Mail, weitere) mit Metadaten eintragen, gekoppelt an
// die Aktivierungspruefung aus LIC-02 (Feature-Flags). Zusaetzlich
// authentifiziert die Registry Modul-Instanzen selbst ueber ein bei
// Provisionierung ausgestelltes Service-Credential.
package moduleregistry
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
var (
ErrMissingName = errors.New("moduleregistry: name darf nicht leer sein")
ErrMissingVersion = errors.New("moduleregistry: version darf nicht leer sein")
ErrModuleNotFound = errors.New("moduleregistry: modul nicht registriert")
)
type Module struct {
Name string
Version string
RequiredFlags []string
}
type Registry struct {
pool *pgxpool.Pool
flags *flag.Service
}
func NewRegistry(pool *pgxpool.Pool, flags *flag.Service) *Registry {
return &Registry{pool: pool, flags: flags}
}
// Register traegt ein Modul mit Name, Version und benoetigten Feature-Flags
// ein (Akzeptanzkriterium 1). Fehlende Pflichtangaben werden abgewiesen
// (Akzeptanzkriterium 1 / Pruefung 2). Erneutes Register desselben Namens
// aktualisiert Version/Flags (Redeploy-Fall).
func (r *Registry) Register(ctx context.Context, name, version string, requiredFlags []string) (Module, error) {
if name == "" {
return Module{}, ErrMissingName
}
if version == "" {
return Module{}, ErrMissingVersion
}
if requiredFlags == nil {
requiredFlags = []string{}
}
_, err := r.pool.Exec(ctx, `
INSERT INTO modules (name, version, required_flags, registered_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (name) DO UPDATE SET version = $2, required_flags = $3, registered_at = now()
`, name, version, requiredFlags)
if err != nil {
return Module{}, fmt.Errorf("modul registrieren: %w", err)
}
return Module{Name: name, Version: version, RequiredFlags: requiredFlags}, nil
}
func (r *Registry) Get(ctx context.Context, name string) (Module, error) {
var m Module
m.Name = name
err := r.pool.QueryRow(ctx, `
SELECT version, required_flags FROM modules WHERE name = $1
`, name).Scan(&m.Version, &m.RequiredFlags)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Module{}, ErrModuleNotFound
}
return Module{}, fmt.Errorf("modul lesen: %w", err)
}
return m, nil
}
// List liefert alle registrierten Module (Akzeptanzkriterium 3: ueber API
// abfragbar, z.B. fuer Statusseite/Lizenzoberflaeche).
func (r *Registry) List(ctx context.Context) ([]Module, error) {
rows, err := r.pool.Query(ctx, `SELECT name, version, required_flags FROM modules ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("module auflisten: %w", err)
}
defer rows.Close()
var out []Module
for rows.Next() {
var m Module
if err := rows.Scan(&m.Name, &m.Version, &m.RequiredFlags); err != nil {
return nil, fmt.Errorf("modul lesen: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// IsActive prueft, ob ein registriertes Modul fuer einen Tenant aktiviert
// ist: registriert UND alle benoetigten Feature-Flags sind fuer diesen
// Tenant aktiv (Akzeptanzkriterium 2). Ein nicht registriertes Modul gilt
// immer als nicht aktiv.
func (r *Registry) IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error) {
m, err := r.Get(ctx, moduleName)
if err != nil {
if errors.Is(err, ErrModuleNotFound) {
return false, nil
}
return false, err
}
for _, flagKey := range m.RequiredFlags {
if !r.flags.IsEnabled(ctx, tenantSlug, flagKey) {
return false, nil
}
}
return true, nil
}
+304
View File
@@ -0,0 +1,304 @@
package moduleregistry
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
func setupTest(t *testing.T) (*Registry, *flag.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 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)
}
flagStore := flag.NewStore(pool)
// Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an
// Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate
// zuverlaessig sehen.
flagService := flag.NewService(flagStore, 10*time.Millisecond)
registry := NewRegistry(pool, flagService)
cleanup := func() { pool.Close() }
return registry, flagStore, cleanup
}
func uniqueModuleName(t *testing.T) string {
return fmt.Sprintf("dms_%d", time.Now().UnixNano())
}
// Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen.
func TestRegister_RejectsMissingFields(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) {
t.Fatalf("erwartet ErrMissingName, habe %v", err)
}
if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) {
t.Fatalf("erwartet ErrMissingVersion, habe %v", err)
}
}
func TestRegister_AndGet(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"})
if err != nil {
t.Fatalf("register: %v", err)
}
if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 {
t.Fatalf("unerwartet: %+v", m)
}
got, err := registry.Get(ctx, name)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Version != "1.2.0" {
t.Fatalf("get version = %q", got.Version)
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach
// Aktivierung/Deaktivierung eines Moduls.
func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
active, err := registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (vor flag): %v", err)
}
if active {
t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag an): %v", err)
}
if !active {
t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil {
t.Fatalf("flag zuruecksetzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag aus): %v", err)
}
if active {
t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde")
}
}
func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
active, err := registry.IsActive(ctx, "acme", "nie-registriert")
if err != nil {
t.Fatalf("is active: %v", err)
}
if active {
t.Fatal("unregistriertes modul darf nie aktiv sein")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird
// zentral abgewiesen, BEVOR die Modul-Logik erreicht wird.
func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
handlerReached := false
handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) {
handlerReached = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if handlerReached {
t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code)
}
if !handlerReached {
t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen")
}
}
// Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential.
func TestProvisionAndAuthenticate(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
if clientID == "" || secret == "" {
t.Fatal("erwartet nicht-leere client-id/secret")
}
moduleName, ok, err := registry.Authenticate(ctx, clientID, secret)
if err != nil {
t.Fatalf("authenticate (korrekt): %v", err)
}
if !ok || moduleName != name {
t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName)
}
_, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret")
if err != nil {
t.Fatalf("authenticate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei falschem secret")
}
_, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret)
if err != nil {
t.Fatalf("authenticate (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei unbekannter client-id")
}
}
func TestProvision_RequiresRegisteredModule(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) {
t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err)
}
}
func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Fehlendes Credential.
req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne credential: status = %d, want 401", rec.Code)
}
// Falsches Secret.
req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req2.Header.Set("X-Client-Id", clientID)
req2.Header.Set("X-Client-Secret", "falsch")
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("falsches secret: status = %d, want 401", rec2.Code)
}
// Gueltiges Credential.
req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req3.Header.Set("X-Client-Id", clientID)
req3.Header.Set("X-Client-Secret", secret)
rec3 := httptest.NewRecorder()
handler(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code)
}
}
+273
View File
@@ -0,0 +1,273 @@
// 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)
}
+198
View File
@@ -0,0 +1,198 @@
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
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS audit_events;
-17
View File
@@ -1,17 +0,0 @@
-- Zentrales Audit-Log-Modell (AUD-01, siehe core-kanban/tickets/AUD-01.md).
-- Getrennt vom allgemeinen Anwendungs-Log (Akzeptanzkriterium 2): eigene
-- Tabelle, eigenes Paket (internal/audit), kein Log-Framework.
-- tenant_slug ist NOT NULL + darf nicht leer sein (Akzeptanzkriterium 2 /
-- Pruefung 2) — mandantenuebergreifende Ereignisse nutzen den reservierten
-- Wert 'system', niemals NULL oder leeren String.
CREATE TABLE audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
tenant_slug TEXT NOT NULL CHECK (tenant_slug <> ''),
actor TEXT NOT NULL CHECK (actor <> ''),
action TEXT NOT NULL CHECK (action <> ''),
target TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX audit_events_tenant_slug_idx ON audit_events (tenant_slug, occurred_at);
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS feature_flags;
+10
View File
@@ -0,0 +1,10 @@
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS module_credentials;
DROP TABLE IF EXISTS modules;
+18
View File
@@ -0,0 +1,18 @@
-- Modul-Registry & Aktivierungspruefung (API-02, siehe core-kanban/tickets/API-02.md).
CREATE TABLE modules (
name TEXT PRIMARY KEY,
version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}',
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Service-Credential je Modul-Instanz, bei Provisionierung ausgestellt
-- (Akzeptanzkriterium 4). secret_hash enthaelt NIEMALS das Secret im
-- Klartext, nur dessen SHA-256-Hash (Timing-safe-Vergleich beim Login,
-- Referenzmuster siehe AUD-02).
CREATE TABLE 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()
);
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE status_history;
DROP TABLE status_targets;
+15
View File
@@ -0,0 +1,15 @@
-- 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);
-13
View File
@@ -1,24 +1,11 @@
#!/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;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
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."
-12
View File
@@ -1,24 +1,12 @@
#!/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
-159
View File
@@ -1,159 +0,0 @@
"use client";
import { useState } from "react";
import { fetchRecords, exportURL, type AuditRecord, type AuditFilter } from "@/lib/api";
const EMPTY_FILTER: AuditFilter = { tenant: "", actor: "", action: "", from: "", to: "" };
export default function Page() {
const [caller, setCaller] = useState("");
const [filter, setFilter] = useState<AuditFilter>(EMPTY_FILTER);
const [records, setRecords] = useState<AuditRecord[] | null>(null);
const [selected, setSelected] = useState<AuditRecord | null>(null);
const [error, setError] = useState<string | null>(null);
async function onSearch() {
setError(null);
setSelected(null);
try {
const data = await fetchRecords(caller.trim(), filter);
setRecords(data);
} catch (e: any) {
setError(e.message ?? "Unbekannter Fehler");
setRecords(null);
}
}
function onExport(format: "csv" | "json") {
const url = exportURL(caller.trim(), filter, format);
// Echter Datei-Download ueber Browser-Navigation, KEIN erneutes Parsen
// im Frontend (Akzeptanzkriterium 2: Export aus AUD-03 direkt ausloesbar).
window.open(url, "_blank");
}
return (
<main style={{ maxWidth: 1000, margin: "0 auto", padding: "2rem 1rem" }}>
<h1>Audit-Log</h1>
<div style={{ display: "grid", gap: "0.5rem", gridTemplateColumns: "repeat(3, 1fr)", marginBottom: "1rem" }}>
<input
value={caller}
onChange={(e) => setCaller(e.target.value)}
placeholder="Admin-Token (Berechtigung)"
style={{ padding: "0.5rem", gridColumn: "span 3" }}
/>
<input
value={filter.tenant}
onChange={(e) => setFilter({ ...filter, tenant: e.target.value })}
placeholder="Tenant"
style={{ padding: "0.5rem" }}
/>
<input
value={filter.actor}
onChange={(e) => setFilter({ ...filter, actor: e.target.value })}
placeholder="Akteur"
style={{ padding: "0.5rem" }}
/>
<input
value={filter.action}
onChange={(e) => setFilter({ ...filter, action: e.target.value })}
placeholder="Aktion"
style={{ padding: "0.5rem" }}
/>
<label>
Von
<input
type="datetime-local"
value={filter.from}
onChange={(e) =>
setFilter({ ...filter, from: e.target.value ? new Date(e.target.value).toISOString() : "" })
}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<label>
Bis
<input
type="datetime-local"
value={filter.to}
onChange={(e) =>
setFilter({ ...filter, to: e.target.value ? new Date(e.target.value).toISOString() : "" })
}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
</div>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
<button onClick={onSearch}>Suchen</button>
<button onClick={() => onExport("csv")}>Export als CSV</button>
<button onClick={() => onExport("json")}>Export als JSON</button>
</div>
{error && (
<p style={{ color: "#c62828" }} role="alert">
Fehler: {error}
</p>
)}
{records && (
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
<th style={{ padding: "0.5rem" }}>Zeitpunkt</th>
<th style={{ padding: "0.5rem" }}>Tenant</th>
<th style={{ padding: "0.5rem" }}>Akteur</th>
<th style={{ padding: "0.5rem" }}>Aktion</th>
<th style={{ padding: "0.5rem" }}></th>
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr key={i} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: "0.5rem" }}>{new Date(r.occurred_at).toLocaleString("de-DE")}</td>
<td style={{ padding: "0.5rem" }}>{r.tenant_slug}</td>
<td style={{ padding: "0.5rem" }}>{r.actor}</td>
<td style={{ padding: "0.5rem" }}>{r.action}</td>
<td style={{ padding: "0.5rem" }}>
<button onClick={() => setSelected(r)}>Details</button>
</td>
</tr>
))}
{records.length === 0 && (
<tr>
<td colSpan={5} style={{ padding: "0.5rem" }}>
Keine Einträge gefunden.
</td>
</tr>
)}
</tbody>
</table>
)}
{selected && (
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
<h2>Eintrag-Details</h2>
<dl>
<dt>Zeitpunkt</dt>
<dd>{new Date(selected.occurred_at).toLocaleString("de-DE")}</dd>
<dt>Tenant</dt>
<dd>{selected.tenant_slug}</dd>
<dt>Akteur</dt>
<dd>{selected.actor}</dd>
<dt>Aktion</dt>
<dd>{selected.action}</dd>
<dt>Ziel</dt>
<dd>{selected.target}</dd>
<dt>Metadaten</dt>
<dd>
<pre style={{ whiteSpace: "pre-wrap", background: "#f5f6f8", padding: "0.5rem" }}>
{JSON.stringify(selected.metadata, null, 2)}
</pre>
</dd>
</dl>
<button onClick={() => setSelected(null)}>Schließen</button>
</section>
)}
</main>
);
}
-63
View File
@@ -1,63 +0,0 @@
// Duenner Client der AUD-03-Export-API (internal/audit.ExportHandler) —
// keine eigene Aggregations-/Filterlogik im Frontend (Ticket-Vorgabe:
// "reiner Konsument der Export-API"). Die JSON-Lines-Antwort wird nur
// dekodiert, nicht neu berechnet oder gefiltert.
export type AuditRecord = {
occurred_at: string;
tenant_slug: string;
actor: string;
action: string;
target: string;
metadata: unknown;
};
export type AuditFilter = {
tenant: string;
actor: string;
action: string;
from: string; // RFC3339, leer = kein Filter
to: string;
};
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_AUDITLOG_API_URL;
if (!base) {
throw new Error(
"NEXT_PUBLIC_AUDITLOG_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
);
}
return base;
}
function buildParams(caller: string, filter: AuditFilter, format: "json" | "csv"): URLSearchParams {
const params = new URLSearchParams({ caller, format });
if (filter.tenant) params.set("tenant", filter.tenant);
if (filter.actor) params.set("actor", filter.actor);
if (filter.action) params.set("action", filter.action);
if (filter.from) params.set("from", filter.from);
if (filter.to) params.set("to", filter.to);
return params;
}
// exportURL liefert die Adresse desselben Endpunkts, den auch die
// Export-Datei-Funktion (Akzeptanzkriterium 2) verwendet — Liste und Export
// sind bewusst DERSELBE API-Aufruf mit unterschiedlichem "format".
export function exportURL(caller: string, filter: AuditFilter, format: "json" | "csv"): string {
return `${apiBase()}/audit/export?${buildParams(caller, filter, format)}`;
}
// fetchRecords laedt die Liste als JSON-Lines und dekodiert Zeile fuer
// Zeile — dieselben Filterparameter wie ein direkter API-Aufruf
// (Akzeptanzkriterium 1 / Pruefung 1).
export async function fetchRecords(caller: string, filter: AuditFilter): Promise<AuditRecord[]> {
const res = await fetch(exportURL(caller, filter, "json"), { cache: "no-store" });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Anfrage fehlgeschlagen (${res.status})`);
}
const text = await res.text();
return text
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => JSON.parse(line) as AuditRecord);
}
@@ -1,7 +1,7 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Audit-Log",
title: "NEXARCH Systemstatus",
};
export default function RootLayout({
+125
View File
@@ -0,0 +1,125 @@
"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>
);
}
+44
View File
@@ -0,0 +1,44 @@
// 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,5 +1,5 @@
{
"name": "nexarch-audit-log",
"name": "nexarch-status-page",
"private": true,
"scripts": {
"dev": "next dev",