API-06: wiederanlauf-nachsynchronisierung-nach-core-ausfall (postgres-puffer, service-credential, sofort-invalidate)
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
package moduletrust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIssueAndVerify_RoundTrip(t *testing.T) {
|
||||
km, err := NewKeyManager()
|
||||
if err != nil {
|
||||
t.Fatalf("new key manager: %v", err)
|
||||
}
|
||||
token, err := km.Issue("user-1", "acme", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
return km.PublicKeySet(), nil
|
||||
})
|
||||
claims, err := v.Verify(context.Background(), token)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if claims.Subject != "user-1" || claims.TenantSlug != "acme" {
|
||||
t.Fatalf("claims unerwartet: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1: Verifikation lokal, kein Request pro Aufruf.
|
||||
func TestVerify_DoesNotFetchPerCall(t *testing.T) {
|
||||
km, err := NewKeyManager()
|
||||
if err != nil {
|
||||
t.Fatalf("new key manager: %v", err)
|
||||
}
|
||||
token, err := km.Issue("user-1", "acme", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
fetchCalls := 0
|
||||
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
mu.Lock()
|
||||
fetchCalls++
|
||||
mu.Unlock()
|
||||
return km.PublicKeySet(), nil
|
||||
})
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := v.Verify(context.Background(), token); err != nil {
|
||||
t.Fatalf("verify %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if fetchCalls != 1 {
|
||||
t.Fatalf("erwartet genau 1 fetch fuer 10 Verify-Aufrufe innerhalb der TTL, habe %d", fetchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 1: Core simuliert nicht erreichbar,
|
||||
// bereits authentifizierte Nutzer bleiben funktionsfaehig (Fail-Open mit
|
||||
// letztbekanntem Schluesselstand).
|
||||
func TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist(t *testing.T) {
|
||||
km, err := NewKeyManager()
|
||||
if err != nil {
|
||||
t.Fatalf("new key manager: %v", err)
|
||||
}
|
||||
token, err := km.Issue("user-1", "acme", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
coreDown := false
|
||||
v := NewVerifier(30*time.Millisecond, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
if coreDown {
|
||||
return nil, errors.New("core nicht erreichbar (simuliert)")
|
||||
}
|
||||
return km.PublicKeySet(), nil
|
||||
})
|
||||
|
||||
// Cache vorwaermen, waehrend Core noch erreichbar ist.
|
||||
if _, err := v.Verify(context.Background(), token); err != nil {
|
||||
t.Fatalf("verify (warm): %v", err)
|
||||
}
|
||||
|
||||
// "Core abschalten" und TTL ablaufen lassen.
|
||||
coreDown = true
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if _, err := v.Verify(context.Background(), token); err != nil {
|
||||
t.Fatalf("verify sollte trotz core-ausfall mit letztbekanntem stand funktionieren: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 2: neue sicherheitskritische Aktionen
|
||||
// (z.B. neuer Login) schlagen bei Core-Ausfall klar fehl statt unsicher
|
||||
// durchgelassen zu werden — auch wenn ein (aelterer) Cache-Stand existiert.
|
||||
func TestRequireFreshKeys_FailsClosedWhenCoreUnreachable(t *testing.T) {
|
||||
km, err := NewKeyManager()
|
||||
if err != nil {
|
||||
t.Fatalf("new key manager: %v", err)
|
||||
}
|
||||
|
||||
coreDown := false
|
||||
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
if coreDown {
|
||||
return nil, errors.New("core nicht erreichbar (simuliert)")
|
||||
}
|
||||
return km.PublicKeySet(), nil
|
||||
})
|
||||
|
||||
// Cache vorwaermen (existiert jetzt ein "veralteter" gueltiger Stand).
|
||||
if _, _, err := v.cache.Get(context.Background()); err != nil {
|
||||
t.Fatalf("warm cache: %v", err)
|
||||
}
|
||||
|
||||
coreDown = true
|
||||
if err := v.RequireFreshKeys(context.Background()); err == nil {
|
||||
t.Fatal("erwartet fehler (fail-closed) bei core-ausfall, habe nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: Schluesselrotation ohne Ausfallzeit —
|
||||
// ein bereits ausgestelltes Token bleibt nach Rotation weiterhin
|
||||
// verifizierbar, ein zweites (simuliertes) Modul bekommt beide Schluessel.
|
||||
func TestRotate_NoDowntimeForAlreadyIssuedTokens(t *testing.T) {
|
||||
km, err := NewKeyManager()
|
||||
if err != nil {
|
||||
t.Fatalf("new key manager: %v", err)
|
||||
}
|
||||
|
||||
oldToken, err := km.Issue("user-1", "acme", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue (alt): %v", err)
|
||||
}
|
||||
|
||||
if _, err := km.Rotate(); err != nil {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
|
||||
newToken, err := km.Issue("user-2", "acme", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue (neu): %v", err)
|
||||
}
|
||||
|
||||
// Simuliertes zweites Modul: fragt den vollstaendigen Schluesselsatz ab.
|
||||
moduleB := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
return km.PublicKeySet(), nil
|
||||
})
|
||||
|
||||
if _, err := moduleB.Verify(context.Background(), oldToken); err != nil {
|
||||
t.Fatalf("altes token sollte nach rotation weiterhin gueltig sein: %v", err)
|
||||
}
|
||||
if _, err := moduleB.Verify(context.Background(), newToken); err != nil {
|
||||
t.Fatalf("neues token sollte gueltig sein: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_RejectsUnknownKid(t *testing.T) {
|
||||
km1, _ := NewKeyManager()
|
||||
km2, _ := NewKeyManager() // komplett anderer, unbekannter schluessel
|
||||
|
||||
token, err := km1.Issue("user-1", "acme", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
|
||||
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
|
||||
return km2.PublicKeySet(), nil // kennt km1s schluessel nicht
|
||||
})
|
||||
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrInvalidToken) {
|
||||
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKSRoundTrip(t *testing.T) {
|
||||
km, _ := NewKeyManager()
|
||||
km.Rotate()
|
||||
|
||||
var buf []byte
|
||||
rec := &captureWriter{}
|
||||
km.ServeJWKS(rec, nil)
|
||||
buf = rec.body
|
||||
|
||||
parsed, err := ParseJWKS(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("parse jwks: %v", err)
|
||||
}
|
||||
if len(parsed) != len(km.PublicKeySet()) {
|
||||
t.Fatalf("erwartet %d schluessel, habe %d", len(km.PublicKeySet()), len(parsed))
|
||||
}
|
||||
}
|
||||
|
||||
type captureWriter struct {
|
||||
body []byte
|
||||
header http.Header
|
||||
}
|
||||
|
||||
func (w *captureWriter) Header() http.Header {
|
||||
if w.header == nil {
|
||||
w.header = http.Header{}
|
||||
}
|
||||
return w.header
|
||||
}
|
||||
func (w *captureWriter) Write(p []byte) (int, error) { w.body = append(w.body, p...); return len(p), nil }
|
||||
func (w *captureWriter) WriteHeader(statusCode int) {}
|
||||
Reference in New Issue
Block a user