syncova-backup/apps/api/internal/httpapi/auth_middleware_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

173 lines
5.2 KiB
Go

package httpapi
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func TestExtractBearerToken(testInstance *testing.T) {
validHeaders := map[string]string{
"Bearer abc123": "abc123",
"bearer abc123": "abc123",
"BEARER abc123": "abc123",
"Bearer abc123": "abc123",
}
for headerValue, expectedToken := range validHeaders {
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
testRequest.Header.Set("Authorization", headerValue)
extractedToken, extractError := extractBearerToken(testRequest)
if extractError != nil {
testInstance.Errorf("der Header %q wurde abgelehnt: %v", headerValue, extractError)
continue
}
if extractedToken != expectedToken {
testInstance.Errorf("der Header %q lieferte %q statt %q", headerValue, extractedToken, expectedToken)
}
}
}
func TestExtractBearerTokenRejectsInvalidHeaders(testInstance *testing.T) {
invalidHeaders := map[string]string{
"leer": "",
"nur Schema": "Bearer",
"leeres Token": "Bearer ",
"falsches Schema": "Basic abc123",
"ohne Schema": "abc123",
}
for headerDescription, headerValue := range invalidHeaders {
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
if headerValue != "" {
testRequest.Header.Set("Authorization", headerValue)
}
// Ein unbrauchbarer Header darf niemals als gültiger Nachweis durchgehen.
if _, extractError := extractBearerToken(testRequest); extractError == nil {
testInstance.Errorf("%s: der Header %q wurde akzeptiert", headerDescription, headerValue)
}
}
}
func TestClientIPAddressIgnoresForwardedHeaders(testInstance *testing.T) {
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
testRequest.RemoteAddr = "192.0.2.10:54321"
// Diese Header sind frei fälschbar. Würden sie ausgewertet, könnte ein
// Angreifer die Herkunft im Auditprotokoll beliebig verfälschen.
testRequest.Header.Set("X-Forwarded-For", "10.0.0.1")
testRequest.Header.Set("X-Real-IP", "10.0.0.2")
determinedAddress := clientIPAddress(testRequest)
if determinedAddress != "192.0.2.10" {
testInstance.Fatalf("es soll die echte Absenderadresse verwendet werden, war %q", determinedAddress)
}
}
func TestRateLimiterAllowsUpToLimit(testInstance *testing.T) {
testLimiter := NewRateLimiter(3, time.Minute)
for attemptIndex := 1; attemptIndex <= 3; attemptIndex++ {
if !testLimiter.Allow("192.0.2.1") {
testInstance.Fatalf("Versuch %d soll erlaubt sein", attemptIndex)
}
}
// Der vierte Versuch überschreitet das Limit.
if testLimiter.Allow("192.0.2.1") {
testInstance.Fatal("der vierte Versuch soll abgelehnt werden")
}
}
func TestRateLimiterSeparatesSenders(testInstance *testing.T) {
testLimiter := NewRateLimiter(2, time.Minute)
testLimiter.Allow("192.0.2.1")
testLimiter.Allow("192.0.2.1")
// Ein anderer Absender darf durch fremde Versuche nicht ausgesperrt werden.
if !testLimiter.Allow("192.0.2.2") {
testInstance.Fatal("ein anderer Absender soll unabhängig gezählt werden")
}
}
func TestRateLimiterForgetsOldAttempts(testInstance *testing.T) {
// Ein sehr kurzes Fenster macht das Ablaufen im Test beobachtbar.
testLimiter := NewRateLimiter(1, 50*time.Millisecond)
if !testLimiter.Allow("192.0.2.1") {
testInstance.Fatal("der erste Versuch soll erlaubt sein")
}
if testLimiter.Allow("192.0.2.1") {
testInstance.Fatal("der zweite Versuch soll im selben Fenster abgelehnt werden")
}
time.Sleep(60 * time.Millisecond)
if !testLimiter.Allow("192.0.2.1") {
testInstance.Fatal("nach Ablauf des Fensters soll wieder ein Versuch erlaubt sein")
}
}
func TestRateLimiterCleanupRemovesStaleEntries(testInstance *testing.T) {
testLimiter := NewRateLimiter(5, 50*time.Millisecond)
testLimiter.Allow("192.0.2.1")
testLimiter.Allow("192.0.2.2")
time.Sleep(60 * time.Millisecond)
testLimiter.Cleanup()
testLimiter.mutex.Lock()
remainingEntries := len(testLimiter.attemptsByKey)
testLimiter.mutex.Unlock()
// Ohne Bereinigung wüchse die Karte mit jeder neuen Absenderadresse.
if remainingEntries != 0 {
testInstance.Fatalf("die Bereinigung soll abgelaufene Einträge entfernen, es blieben %d", remainingEntries)
}
}
func TestRateLimiterIsSafeForConcurrentUse(testInstance *testing.T) {
testLimiter := NewRateLimiter(1000, time.Minute)
// Der Begrenzer wird aus vielen Requests gleichzeitig aufgerufen.
var waitGroup sync.WaitGroup
for goroutineIndex := 0; goroutineIndex < 50; goroutineIndex++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for callIndex := 0; callIndex < 20; callIndex++ {
testLimiter.Allow("192.0.2.1")
}
}()
}
waitGroup.Wait()
testLimiter.mutex.Lock()
recordedAttempts := len(testLimiter.attemptsByKey["192.0.2.1"])
testLimiter.mutex.Unlock()
if recordedAttempts != 1000 {
testInstance.Fatalf("es sollen 1000 Versuche gezählt sein, waren %d", recordedAttempts)
}
}
func TestAuthenticatedUserFromContextWithoutUser(testInstance *testing.T) {
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
// Ohne Authentifizierung darf kein Benutzer im Context liegen.
if _, isPresent := AuthenticatedUserFromContext(testRequest.Context()); isPresent {
testInstance.Fatal("ohne Anmeldung darf kein Benutzer gefunden werden")
}
}