package auth import ( "encoding/base64" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" ) var testSigner = NewSigner([]byte("ein-test-secret-das-lang-genug-ist-fuer-tests")) // ── Sitzungen ──────────────────────────────────────────────────────────────── func TestSessionRoundTrip(t *testing.T) { cookie := testSigner.SignSession("key:abc123") subject, ok := testSigner.VerifySession(cookie) if !ok { t.Fatal("frisch signierte Sitzung wurde als ungültig abgelehnt") } if subject != "key:abc123" { t.Errorf("subject = %q, erwartet %q", subject, "key:abc123") } } // TestSessionRejectsWrongSecret ist der Kern des Verfahrens: ohne das richtige // Geheimnis darf sich niemand eine Sitzung ausstellen können. func TestSessionRejectsWrongSecret(t *testing.T) { cookie := testSigner.SignSession(SubjectPlatformAdmin) fremder := NewSigner([]byte("ein-voellig-anderes-secret")) if _, ok := fremder.VerifySession(cookie); ok { t.Error("Sitzung wurde mit falschem Secret akzeptiert") } } // TestSessionRejectsTampering prüft den naheliegendsten Angriff: den Cookie // auslesen (er ist nur base64, nicht verschlüsselt), "key:xyz" durch "platform" // ersetzen und wieder einsetzen. func TestSessionRejectsTampering(t *testing.T) { cookie := testSigner.SignSession("key:abc123") _, signature, _ := strings.Cut(cookie, ".") gefaelscht := base64.RawURLEncoding.EncodeToString( fmt.Appendf(nil, "%s|%d", SubjectPlatformAdmin, time.Now().Add(time.Hour).Unix()), ) if _, ok := testSigner.VerifySession(gefaelscht + "." + signature); ok { t.Error("manipulierter Cookie wurde akzeptiert — die Signatur greift nicht") } } // TestSessionRejectsExpired baut die Nutzlast von Hand mit einem Zeitstempel in // der Vergangenheit und signiert sie korrekt — so wird wirklich nur die // Ablaufprüfung getestet, ohne eine Stunde zu warten. func TestSessionRejectsExpired(t *testing.T) { payload := fmt.Sprintf("key:abc123|%d", time.Now().Add(-time.Minute).Unix()) cookie := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + testSigner.sign(payload) if _, ok := testSigner.VerifySession(cookie); ok { t.Error("abgelaufene Sitzung wurde akzeptiert") } } // TestSessionRejectsMalformed wirft der Prüfung kaputte Eingaben vor die Füße. // Wichtig ist vor allem, dass nichts davon zu einem Absturz führt. func TestSessionRejectsMalformed(t *testing.T) { cases := []string{ "", "kein-punkt-drin", ".", "nicht-base64!!!.abcdef", base64.RawURLEncoding.EncodeToString([]byte("ohne-trenner")) + ".abcdef", base64.RawURLEncoding.EncodeToString([]byte("key:a|keine-zahl")) + ".abcdef", } for _, cookie := range cases { if _, ok := testSigner.VerifySession(cookie); ok { t.Errorf("kaputter Cookie wurde akzeptiert: %q", cookie) } } } func TestKeyIDFromSubject(t *testing.T) { if id, ok := KeyIDFromSubject("key:abc123"); !ok || id != "abc123" { t.Errorf("KeyIDFromSubject = (%q, %v), erwartet (\"abc123\", true)", id, ok) } if _, ok := KeyIDFromSubject(SubjectPlatformAdmin); ok { t.Error("Plattform-Admin hat fälschlich eine Key-ID geliefert") } } // ── CSRF ───────────────────────────────────────────────────────────────────── func TestCSRFToken(t *testing.T) { token := testSigner.CSRFToken("key:abc123") if !testSigner.ValidCSRFToken(token, "key:abc123") { t.Error("gültiges CSRF-Token wurde abgelehnt") } if testSigner.ValidCSRFToken(token, "key:anderer") { t.Error("CSRF-Token einer Sitzung galt auch für eine andere") } if testSigner.ValidCSRFToken("", "key:abc123") { t.Error("leeres CSRF-Token wurde akzeptiert") } } // TestTokenDomainSeparation prüft, dass ein CSRF-Token (das offen im HTML steht) // nicht als Session-Signatur durchgeht. func TestTokenDomainSeparation(t *testing.T) { subject := "key:abc123" csrf := testSigner.CSRFToken(subject) if _, ok := testSigner.VerifySession( base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + csrf, ); ok { t.Error("ein CSRF-Token wurde als gültige Session-Signatur akzeptiert") } } // ── API-Keys ───────────────────────────────────────────────────────────────── func TestGenerateKeyFormat(t *testing.T) { key, err := GenerateKey() if err != nil { t.Fatalf("Key konnte nicht erzeugt werden: %v", err) } if !strings.HasPrefix(key, KeyPrefix) { t.Errorf("Key %q hat nicht das Präfix %q", key, KeyPrefix) } if !LooksLikeKey(key) { t.Errorf("selbst erzeugter Key besteht die Formprüfung nicht: %q", key) } } // TestGenerateKeyIsUnique prüft, dass wirklich Zufall im Spiel ist. func TestGenerateKeyIsUnique(t *testing.T) { gesehen := make(map[string]bool) for i := 0; i < 100; i++ { key, err := GenerateKey() if err != nil { t.Fatal(err) } if gesehen[key] { t.Fatal("zweimal derselbe Key erzeugt") } gesehen[key] = true } } func TestHashKey(t *testing.T) { key := "wk_" + strings.Repeat("a", 64) if HashKey(key) != HashKey(key) { t.Error("derselbe Key ergibt unterschiedliche Hashes") } if HashKey(key) == HashKey(key+"x") { t.Error("unterschiedliche Keys ergeben denselben Hash") } // Der Hash darf den Key nicht enthalten — sonst wäre die Datenbank so gut // wie eine Klartext-Ablage. if strings.Contains(HashKey(key), key) { t.Error("der Hash enthält den Key im Klartext") } } func TestLooksLikeKey(t *testing.T) { gueltig := KeyPrefix + strings.Repeat("ab", 32) cases := map[string]bool{ gueltig: true, "": false, "wk_": false, strings.Repeat("ab", 32): false, // ohne Präfix KeyPrefix + "zz": false, // zu kurz KeyPrefix + strings.Repeat("g", 64): false, // kein Hex } for key, erwartet := range cases { if got := LooksLikeKey(key); got != erwartet { t.Errorf("LooksLikeKey(%.20q…) = %v, erwartet %v", key, got, erwartet) } } } // TestWorkspaceAdminTokenIsNoKey: Die beiden Arten dürfen sich nie // überschneiden. Ginge ein Workspace-Admin-Token als API-Key durch, stünde // ihm die MCP-Schnittstelle offen — und umgekehrt käme ein Projekt-Key an // die Workspace-Verwaltung. func TestWorkspaceAdminTokenIsNoKey(t *testing.T) { token, err := GenerateWorkspaceAdminToken() if err != nil { t.Fatal(err) } if !strings.HasPrefix(token, WorkspaceAdminPrefix) || !LooksLikeWorkspaceAdminToken(token) { t.Errorf("selbst erzeugtes Token besteht die Formprüfung nicht: %q", token) } if LooksLikeKey(token) { t.Error("ein Workspace-Admin-Token geht als API-Key durch") } key, err := GenerateKey() if err != nil { t.Fatal(err) } if LooksLikeWorkspaceAdminToken(key) { t.Error("ein API-Key geht als Workspace-Admin-Token durch") } } // ── Rollen ─────────────────────────────────────────────────────────────────── func TestRolePermissions(t *testing.T) { cases := []struct { role Role valid bool write bool manageKeys bool }{ {RoleRead, true, false, false}, {RoleWrite, true, true, false}, {RoleAdmin, true, true, true}, {Role("unsinn"), false, false, false}, {Role(""), false, false, false}, } for _, tc := range cases { t.Run(string(tc.role), func(t *testing.T) { if tc.role.Valid() != tc.valid { t.Errorf("Valid() = %v, erwartet %v", tc.role.Valid(), tc.valid) } if tc.role.CanWrite() != tc.write { t.Errorf("CanWrite() = %v, erwartet %v", tc.role.CanWrite(), tc.write) } if tc.role.CanManageKeys() != tc.manageKeys { t.Errorf("CanManageKeys() = %v, erwartet %v", tc.role.CanManageKeys(), tc.manageKeys) } }) } } // ── Cookie-Policy ──────────────────────────────────────────────────────────── func TestCookiePolicySecure(t *testing.T) { cases := []struct { name string env string host string erwartet bool }{ {"Standard, echte Domain", "", "munin.intern", true}, {"Standard, localhost", "", "localhost:8080", false}, {"Standard, 127.0.0.1", "", "127.0.0.1:8080", false}, {"erzwungen an, localhost", "true", "localhost:8080", true}, {"erzwungen aus, echte Domain", "false", "munin.intern", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { policy := NewCookiePolicy(tc.env) r := httptest.NewRequest(http.MethodGet, "http://"+tc.host+"/", nil) r.Host = tc.host if got := policy.Secure(r); got != tc.erwartet { t.Errorf("Secure = %v, erwartet %v", got, tc.erwartet) } }) } } func TestCookiePolicyApplySetsFlags(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "http://munin.intern/", nil) r.Host = "munin.intern" cookie := &http.Cookie{Name: "test", Value: "wert"} NewCookiePolicy("").Apply(cookie, r) if !cookie.HttpOnly { t.Error("HttpOnly fehlt") } if !cookie.Secure { t.Error("Secure fehlt") } if cookie.SameSite != http.SameSiteLaxMode { t.Errorf("SameSite = %v, erwartet Lax", cookie.SameSite) } if cookie.Path != "/" { t.Errorf("Path = %q, erwartet \"/\"", cookie.Path) } } // ── Rate-Limiting ──────────────────────────────────────────────────────────── func TestRateLimiterBlocksAfterMax(t *testing.T) { limiter := NewRateLimiter() const ip = "203.0.113.7" for i := 0; i < rateLimitMax; i++ { if allowed, _ := limiter.Allow(ip); !allowed { t.Fatalf("bereits nach %d Versuchen gesperrt — zu früh", i) } limiter.RecordFailure(ip) } allowed, retryAfter := limiter.Allow(ip) if allowed { t.Errorf("nach %d Fehlversuchen immer noch erlaubt", rateLimitMax) } if retryAfter <= 0 { t.Errorf("retryAfter = %v, erwartet einen positiven Wert", retryAfter) } } func TestRateLimiterIsPerIP(t *testing.T) { limiter := NewRateLimiter() for i := 0; i < rateLimitMax; i++ { limiter.RecordFailure("203.0.113.7") } if allowed, _ := limiter.Allow("198.51.100.4"); !allowed { t.Error("eine andere IP wurde mitgesperrt") } } func TestRateLimiterResetClearsCounter(t *testing.T) { limiter := NewRateLimiter() const ip = "203.0.113.7" for i := 0; i < rateLimitMax; i++ { limiter.RecordFailure(ip) } limiter.Reset(ip) if allowed, _ := limiter.Allow(ip); !allowed { t.Error("nach Reset immer noch gesperrt") } } // ── Schreibbudget ──────────────────────────────────────────────────────────── func TestWriteBudgetAllowsUpToLimit(t *testing.T) { budget := NewWriteBudget(3, time.Hour) const projekt = "p1" for i := 1; i <= 3; i++ { ok, _, ueber := budget.Consume(projekt, 0) if !ok { t.Fatalf("Aufruf %d von 3 abgelehnt", i) } if ueber != 0 { t.Errorf("Aufruf %d: ueberschreitung = %d, erwartet 0", i, ueber) } } ok, retryAfter, ueber := budget.Consume(projekt, 0) if ok { t.Error("der vierte Aufruf wurde angenommen") } if retryAfter <= 0 { t.Errorf("retryAfter = %v, erwartet einen positiven Wert", retryAfter) } // Genau 1 beim ersten abgelehnten Aufruf — daran hängt, dass die // Überschreitung nur einmal je Fenster protokolliert wird. if ueber != 1 { t.Errorf("ueberschreitung = %d, erwartet 1 beim ersten abgelehnten Aufruf", ueber) } if _, _, ueber := budget.Consume(projekt, 0); ueber != 2 { t.Errorf("ueberschreitung = %d, erwartet 2 beim zweiten abgelehnten Aufruf", ueber) } } func TestWriteBudgetIsPerProject(t *testing.T) { budget := NewWriteBudget(1, time.Hour) budget.Consume("p1", 0) if ok, _, _ := budget.Consume("p1", 0); ok { t.Fatal("Vorbedingung verfehlt: p1 ist noch nicht am Limit") } if ok, _, _ := budget.Consume("p2", 0); !ok { t.Error("ein anderes Projekt wurde mitgesperrt") } } // TestWriteBudgetProjectLimitBeatsDefault prüft die Auflösung der Obergrenze: // erst die eigene Angabe des Projekts, sonst der Standard. func TestWriteBudgetProjectLimitBeatsDefault(t *testing.T) { budget := NewWriteBudget(100, time.Hour) budget.Consume("eng", 1) if ok, _, _ := budget.Consume("eng", 1); ok { t.Error("die eigene Obergrenze (1) wurde nicht angewandt") } // 0 heißt "keine eigene Angabe" — dann gilt der großzügige Standard. if ok, _, _ := budget.Consume("standard", 0); !ok { t.Error("bei projektLimit 0 wurde nicht der Standard verwendet") } } // TestWriteBudgetZeroDefaultDisables prüft die Notbremse: WRITE_LIMIT_HOURLY=0 // schaltet die Begrenzung vollständig ab. func TestWriteBudgetZeroDefaultDisables(t *testing.T) { budget := NewWriteBudget(0, time.Hour) for i := 0; i < 50; i++ { if ok, _, _ := budget.Consume("p1", 0); !ok { t.Fatalf("Aufruf %d abgelehnt, obwohl die Begrenzung aus ist", i+1) } } } func TestWriteBudgetRemainingCountsDown(t *testing.T) { budget := NewWriteBudget(5, time.Hour) if frei, grenze := budget.Remaining("p1", 0); frei != 5 || grenze != 5 { t.Errorf("frei/grenze = %d/%d, erwartet 5/5 vor dem ersten Aufruf", frei, grenze) } budget.Consume("p1", 0) budget.Consume("p1", 0) if frei, _ := budget.Remaining("p1", 0); frei != 3 { t.Errorf("frei = %d, erwartet 3 nach zwei Aufrufen", frei) } // Remaining selbst darf nichts verbrauchen. if frei, _ := budget.Remaining("p1", 0); frei != 3 { t.Errorf("frei = %d — Remaining hat mitgezählt", frei) } } // TestWriteBudgetWindowExpires prüft, dass ein abgelaufenes Fenster den Zähler // zurücksetzt statt ihn fortzuschreiben. func TestWriteBudgetWindowExpires(t *testing.T) { budget := NewWriteBudget(1, time.Millisecond) budget.Consume("p1", 0) if ok, _, _ := budget.Consume("p1", 0); ok { t.Fatal("Vorbedingung verfehlt: p1 ist noch nicht am Limit") } time.Sleep(5 * time.Millisecond) if ok, _, _ := budget.Consume("p1", 0); !ok { t.Error("nach Ablauf des Fensters immer noch gesperrt") } } // ── Client-IP / Trusted Proxy ──────────────────────────────────────────────── func TestClientIPIgnoresForwardedByDefault(t *testing.T) { if err := SetTrustedProxies(""); err != nil { t.Fatal(err) } r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "10.0.0.5:1234" r.Header.Set("X-Forwarded-For", "1.2.3.4") // Ohne TRUSTED_PROXY ist der Header frei erfunden und muss ignoriert werden — // sonst könnte sich jeder Angreifer bei jedem Versuch eine neue IP geben und // das Rate-Limiting damit aushebeln. if got := ClientIP(r); got != "10.0.0.5" { t.Errorf("ClientIP = %q, erwartet %q", got, "10.0.0.5") } } func TestClientIPUsesForwardedFromTrustedProxy(t *testing.T) { if err := SetTrustedProxies("10.0.0.0/8"); err != nil { t.Fatal(err) } defer SetTrustedProxies("") r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "10.0.0.5:1234" r.Header.Set("X-Forwarded-For", "1.2.3.4") if got := ClientIP(r); got != "1.2.3.4" { t.Errorf("ClientIP = %q, erwartet %q", got, "1.2.3.4") } } // TestClientIPTakesRightmostUntrusted prüft den Fall, dass der Client selbst // schon einen X-Forwarded-For mitschickt und der Proxy nur anhängt. func TestClientIPTakesRightmostUntrusted(t *testing.T) { if err := SetTrustedProxies("10.0.0.0/8"); err != nil { t.Fatal(err) } defer SetTrustedProxies("") r := httptest.NewRequest(http.MethodGet, "/", nil) r.RemoteAddr = "10.0.0.5:1234" // "9.9.9.9" ist die Erfindung des Angreifers, "1.2.3.4" seine echte Adresse. r.Header.Set("X-Forwarded-For", "9.9.9.9, 1.2.3.4") if got := ClientIP(r); got != "1.2.3.4" { t.Errorf("ClientIP = %q, erwartet %q — die erfundene IP wurde übernommen", got, "1.2.3.4") } } func TestSetTrustedProxiesRejectsGarbage(t *testing.T) { if err := SetTrustedProxies("keine-ip"); err == nil { t.Error("unsinnige Angabe wurde akzeptiert") } SetTrustedProxies("") } func TestPlatformSubjectHinUndZurueck(t *testing.T) { faelle := []struct{ fingerabdruck, workspaceID string }{ {"abc123", ""}, {"abc123", "ws-1"}, } for _, f := range faelle { subject := PlatformSubject(f.fingerabdruck, f.workspaceID) fp, ws, ok := ParsePlatformSubject(subject) if !ok || fp != f.fingerabdruck || ws != f.workspaceID { t.Errorf("%q → (%q, %q, %v), erwartet (%q, %q, true)", subject, fp, ws, ok, f.fingerabdruck, f.workspaceID) } } // Alte Formen ohne Fingerabdruck und fremde Subjects sind keine. for _, subject := range []string{"platform", "platform:ws-1", "platform/", "platform/:ws-1", "key:ws:1"} { if _, _, ok := ParsePlatformSubject(subject); ok { t.Errorf("%q wurde als Subject des Plattform-Admins erkannt", subject) } } } func TestFingerprintHaengtVomGeheimnisAb(t *testing.T) { a := NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert") b := NewSigner([]byte("geheimnis-zwei-geheimnis-zwei-geheimnis")).Fingerprint("wert") if a == b { t.Error("gleicher Fingerabdruck trotz anderem SESSION_SECRET") } if a != NewSigner([]byte("geheimnis-eins-geheimnis-eins-geheimnis")).Fingerprint("wert") { t.Error("Fingerabdruck ist nicht stabil") } }