syncova-backup/packages/platform/ratelimit/bandwidth_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

353 lines
11 KiB
Go

package ratelimit
import (
"bytes"
"context"
"errors"
"io"
"strings"
"sync"
"testing"
"time"
)
// fakeClock ersetzt die Zeit in Tests.
//
// Ein Test, der echte Sekunden verstreichen lässt, wird entweder langsam oder
// unzuverlässig. Mit einer gesteuerten Uhr lässt sich das Zeitverhalten genau
// prüfen, ohne zu warten.
type fakeClock struct {
// mutex schützt den Zustand.
mutex sync.Mutex
// currentTime ist die aktuelle Zeit.
currentTime time.Time
// totalSlept ist die insgesamt angeforderte Wartezeit.
totalSlept time.Duration
}
// now liefert die aktuelle Zeit der gesteuerten Uhr.
func (clock *fakeClock) now() time.Time {
clock.mutex.Lock()
defer clock.mutex.Unlock()
return clock.currentTime
}
// sleep zählt die Wartezeit und lässt die Uhr vorrücken.
func (clock *fakeClock) sleep(waitContext context.Context, waitDuration time.Duration) error {
if contextError := waitContext.Err(); contextError != nil {
return contextError
}
clock.mutex.Lock()
defer clock.mutex.Unlock()
clock.totalSlept += waitDuration
clock.currentTime = clock.currentTime.Add(waitDuration)
return nil
}
// newTestLimiter baut einen Begrenzer mit gesteuerter Uhr.
func newTestLimiter(testInstance *testing.T, bytesPerSecond int64) (*Limiter, *fakeClock) {
testInstance.Helper()
builtLimiter, limiterError := NewLimiter(bytesPerSecond)
if limiterError != nil {
testInstance.Fatalf("der Begrenzer ließ sich nicht bauen: %v", limiterError)
}
testClock := &fakeClock{currentTime: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)}
builtLimiter.nowFunction = testClock.now
builtLimiter.sleepFunction = testClock.sleep
builtLimiter.lastRefillTime = testClock.currentTime
return builtLimiter, testClock
}
// TestLimiterEnforcesRateOverTime prüft die Einhaltung der Rate.
func TestLimiterEnforcesRateOverTime(testInstance *testing.T) {
const bytesPerSecond int64 = 1 << 20 // 1 MiB/s
testLimiter, testClock := newTestLimiter(testInstance, bytesPerSecond)
// Der Eimer ist anfangs voll: 1 MiB geht ohne Warten durch.
if waitError := testLimiter.Wait(context.Background(), bytesPerSecond); waitError != nil {
testInstance.Fatalf("die erste Übertragung schlug fehl: %v", waitError)
}
if testClock.totalSlept != 0 {
testInstance.Errorf("die erste Übertragung wartete %s, obwohl der Eimer voll war", testClock.totalSlept)
}
// Weitere 4 MiB müssen ungefähr vier Sekunden kosten.
if waitError := testLimiter.Wait(context.Background(), 4*bytesPerSecond); waitError != nil {
testInstance.Fatalf("die zweite Übertragung schlug fehl: %v", waitError)
}
expectedWait := 4 * time.Second
tolerance := 200 * time.Millisecond
if testClock.totalSlept < expectedWait-tolerance || testClock.totalSlept > expectedWait+tolerance {
testInstance.Errorf("für 4 MiB bei 1 MiB/s wurde %s gewartet, erwartet waren etwa %s",
testClock.totalSlept, expectedWait)
}
}
// TestLimiterHandlesRequestLargerThanBurst prüft den Sonderfall.
//
// Eine Anforderung größer als der Eimer liesse sich nie erfüllen und blockierte
// für immer. Sie muss in Teilstücke zerlegt werden.
func TestLimiterHandlesRequestLargerThanBurst(testInstance *testing.T) {
const bytesPerSecond int64 = 1 << 20
testLimiter, testClock := newTestLimiter(testInstance, bytesPerSecond)
// Zehnmal die Eimergröße auf einmal.
requestedBytes := 10 * testLimiter.burstBytes
waitDone := make(chan error, 1)
go func() {
waitDone <- testLimiter.Wait(context.Background(), requestedBytes)
}()
select {
case waitError := <-waitDone:
if waitError != nil {
testInstance.Fatalf("die große Übertragung schlug fehl: %v", waitError)
}
case <-time.After(5 * time.Second):
testInstance.Fatal("eine Anforderung größer als der Eimer blockierte")
}
// Neun Eimerfüllungen müssen bezahlt werden, eine war vorrätig.
expectedWait := 9 * time.Second
tolerance := time.Second
if testClock.totalSlept < expectedWait-tolerance {
testInstance.Errorf("für %d Byte bei 1 MiB/s wurde nur %s gewartet", requestedBytes, testClock.totalSlept)
}
}
// TestLimiterRespectsContextCancellation prüft den Abbruch.
//
// Ohne ihn hinge ein abgebrochener Sicherungslauf noch minutenlang in einer
// Wartezeit, die niemand mehr braucht.
func TestLimiterRespectsContextCancellation(testInstance *testing.T) {
testLimiter, limiterError := NewLimiter(minimumBurstBytes)
if limiterError != nil {
testInstance.Fatalf("der Begrenzer ließ sich nicht bauen: %v", limiterError)
}
// Der Eimer wird geleert, damit der nächste Aufruf tatsächlich wartet.
if waitError := testLimiter.Wait(context.Background(), testLimiter.burstBytes); waitError != nil {
testInstance.Fatalf("die Vorbereitung schlug fehl: %v", waitError)
}
cancelledContext, cancelWait := context.WithCancel(context.Background())
cancelWait()
startTime := time.Now()
waitError := testLimiter.Wait(cancelledContext, testLimiter.burstBytes*4)
elapsedDuration := time.Since(startTime)
if !errors.Is(waitError, context.Canceled) {
testInstance.Fatalf("der Abbruch wurde nicht gemeldet: %v", waitError)
}
if elapsedDuration > time.Second {
testInstance.Errorf("der abgebrochene Aufruf dauerte %s", elapsedDuration)
}
}
// TestUnlimitedLimiterDoesNotWrap prüft den abgeschalteten Begrenzer.
//
// Eine Hülle, die nichts tut, kostet bei jedem Block einen Aufruf mehr.
func TestUnlimitedLimiterDoesNotWrap(testInstance *testing.T) {
unlimitedLimiter, limiterError := NewLimiter(Unlimited)
if limiterError != nil {
testInstance.Fatalf("der Begrenzer ließ sich nicht bauen: %v", limiterError)
}
if !unlimitedLimiter.IsUnlimited() {
testInstance.Error("ein Begrenzer mit Rate 0 meldete sich als begrenzend")
}
sourceReader := strings.NewReader("Inhalt")
wrappedReader := NewLimitedReader(context.Background(), sourceReader, unlimitedLimiter)
if wrappedReader != io.Reader(sourceReader) {
testInstance.Error("ohne Begrenzung wurde der Datenstrom trotzdem umhüllt")
}
// Auch ein nicht vorhandener Begrenzer darf nicht stören.
var missingLimiter *Limiter
if !missingLimiter.IsUnlimited() {
testInstance.Error("ein fehlender Begrenzer meldete sich als begrenzend")
}
}
// TestLimitedReaderDeliversAllBytes prüft die Unversehrtheit der Daten.
//
// Eine Bandbreitengrenze darf den Inhalt nicht antasten — sonst wäre sie die
// heimtückischste Art, ein Backup zu beschädigen.
func TestLimitedReaderDeliversAllBytes(testInstance *testing.T) {
testLimiter, _ := newTestLimiter(testInstance, 1<<20)
sourceData := bytes.Repeat([]byte("Syncova"), 50_000)
limitedReader := NewLimitedReader(context.Background(), bytes.NewReader(sourceData), testLimiter)
readData, readError := io.ReadAll(limitedReader)
if readError != nil {
testInstance.Fatalf("das Lesen schlug fehl: %v", readError)
}
if !bytes.Equal(readData, sourceData) {
testInstance.Fatal("die begrenzte Übertragung veränderte die Daten")
}
}
// TestLimitedWriterDeliversAllBytes prüft das Schreibziel.
func TestLimitedWriterDeliversAllBytes(testInstance *testing.T) {
testLimiter, _ := newTestLimiter(testInstance, 1<<20)
sourceData := bytes.Repeat([]byte("Backup"), 50_000)
var targetBuffer bytes.Buffer
limitedWriter := NewLimitedWriter(context.Background(), &targetBuffer, testLimiter)
writtenCount, writeError := limitedWriter.Write(sourceData)
if writeError != nil {
testInstance.Fatalf("das Schreiben schlug fehl: %v", writeError)
}
if writtenCount != len(sourceData) {
testInstance.Errorf("es wurden %d von %d Byte geschrieben", writtenCount, len(sourceData))
}
if !bytes.Equal(targetBuffer.Bytes(), sourceData) {
testInstance.Fatal("die begrenzte Übertragung veränderte die Daten")
}
}
// TestSharedLimiterSplitsRateBetweenWorkers prüft die gemeinsame Nutzung.
//
// Ein Begrenzer je Arbeiter ergäbe ein Vielfaches der vereinbarten Rate — genau
// der Fehler, der eine Bandbreitengrenze wirkungslos macht.
func TestSharedLimiterSplitsRateBetweenWorkers(testInstance *testing.T) {
const bytesPerSecond int64 = 1 << 20
testLimiter, testClock := newTestLimiter(testInstance, bytesPerSecond)
const workerCount = 4
const bytesPerWorker = 1 << 20
var waitGroup sync.WaitGroup
waitGroup.Add(workerCount)
for workerIndex := 0; workerIndex < workerCount; workerIndex++ {
go func() {
defer waitGroup.Done()
_ = testLimiter.Wait(context.Background(), bytesPerWorker)
}()
}
waitGroup.Wait()
// Vier Arbeiter übertragen zusammen 4 MiB; einer war vorrätig, für drei
// muss gewartet werden.
expectedMinimumWait := 2 * time.Second
if testClock.totalSlept < expectedMinimumWait {
testInstance.Fatalf("vier Arbeiter warteten zusammen nur %s; die Rate wurde vervielfacht", testClock.totalSlept)
}
}
// TestParseBandwidthLimitDistinguishesBitsAndBytes ist der wichtigste
// Auswertungstest.
//
// Netzwerkleute rechnen in Bit je Sekunde, Speicherleute in Byte. Wer
// „100Mbit" meint und „100MB" einträgt, vergibt das Achtfache.
func TestParseBandwidthLimitDistinguishesBitsAndBytes(testInstance *testing.T) {
expectedValues := map[string]int64{
"": Unlimited,
"0": Unlimited,
"unbegrenzt": Unlimited,
"50MB": 50 << 20,
"50 MB": 50 << 20,
"50MB/s": 50 << 20,
"1GB": 1 << 30,
"512KB": 512 << 10,
"100Mbit": 100 * 1000 * 1000 / 8,
"1Gbit": 1000 * 1000 * 1000 / 8,
"1.5MB": 1_572_864,
"1,5MB": 1_572_864,
"1048576": 1 << 20,
}
for limitText, expectedValue := range expectedValues {
parsedValue, parseError := ParseBandwidthLimit(limitText)
if parseError != nil {
testInstance.Errorf("%q wurde abgelehnt: %v", limitText, parseError)
continue
}
if parsedValue != expectedValue {
testInstance.Errorf("%q ergab %d, erwartet waren %d", limitText, parsedValue, expectedValue)
}
}
// Der Kern: 100Mbit ist deutlich weniger als 100MB.
bitValue, _ := ParseBandwidthLimit("100Mbit")
byteValue, _ := ParseBandwidthLimit("100MB")
if bitValue >= byteValue {
testInstance.Fatalf("100Mbit (%d) wurde nicht kleiner als 100MB (%d) ausgewertet", bitValue, byteValue)
}
}
// TestParseBandwidthLimitRejectsNonsense prüft die Eingabeprüfung.
func TestParseBandwidthLimitRejectsNonsense(testInstance *testing.T) {
invalidInputs := []string{"schnell", "-5MB", "MB", "1,2,3MB"}
for _, invalidInput := range invalidInputs {
if _, parseError := ParseBandwidthLimit(invalidInput); parseError == nil {
testInstance.Errorf("die Eingabe %q wurde angenommen", invalidInput)
}
}
}
// TestNewLimiterRejectsUnusablyLowRates prüft die Untergrenze.
//
// Kleiner als ein typischer Netzwerkblock zu begrenzen führt zu ständigem
// Warten in winzigen Schritten; der Durchsatz bräche stärker ein als vorgesehen.
func TestNewLimiterRejectsUnusablyLowRates(testInstance *testing.T) {
if _, limiterError := NewLimiter(100); !errors.Is(limiterError, ErrInvalidRate) {
testInstance.Errorf("eine zu kleine Rate wurde angenommen: %v", limiterError)
}
if _, limiterError := NewLimiter(-1); !errors.Is(limiterError, ErrInvalidRate) {
testInstance.Errorf("eine negative Rate wurde angenommen: %v", limiterError)
}
}
// TestFormatBandwidthLimit prüft die Ausgabe.
func TestFormatBandwidthLimit(testInstance *testing.T) {
expectedTexts := map[int64]string{
Unlimited: "unbegrenzt",
50 << 20: "50.0 MB/s",
1 << 30: "1.0 GB/s",
512 << 10: "512.0 KB/s",
500: "500 B/s",
}
for bytesPerSecond, expectedText := range expectedTexts {
if formattedText := FormatBandwidthLimit(bytesPerSecond); formattedText != expectedText {
testInstance.Errorf("%d ergab %q, erwartet war %q", bytesPerSecond, formattedText, expectedText)
}
}
}