syncova-backup/packages/agentregistry/service_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

557 lines
19 KiB
Go

package agentregistry
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/audit"
"github.com/syncova/syncova/packages/auth"
)
// memoryStore ist eine Ablage im Arbeitsspeicher für Tests.
//
// Sie erlaubt es, die Sicherheitslogik ohne Datenbank zu prüfen — die Regeln
// selbst sind wichtiger als ihre Ablage.
type memoryStore struct {
// mutex schützt die Karten gegen gleichzeitige Zugriffe.
mutex sync.Mutex
// enrollmentTokens hält die Aufnahme-Tokens nach ihrem Hash.
enrollmentTokens map[string]*memoryEnrollmentToken
// agents hält die Agents nach ihrer Kennung.
agents map[uuid.UUID]*Agent
// agentTokens ordnet Tokenhashes den Agents zu.
agentTokens map[string]uuid.UUID
// revokedTokens hält widerrufene Tokenhashes fest.
revokedTokens map[string]bool
}
// memoryEnrollmentToken ist ein Aufnahme-Token im Arbeitsspeicher.
type memoryEnrollmentToken struct {
// agentName ist der vorgesehene Name des Agents.
agentName string
// expiresAt ist die Ablaufzeit.
expiresAt time.Time
// usedAt ist der Einlösezeitpunkt; nil bedeutet noch offen.
usedAt *time.Time
}
// newMemoryStore erzeugt eine leere Ablage.
func newMemoryStore() *memoryStore {
return &memoryStore{
enrollmentTokens: make(map[string]*memoryEnrollmentToken),
agents: make(map[uuid.UUID]*Agent),
agentTokens: make(map[string]uuid.UUID),
revokedTokens: make(map[string]bool),
}
}
func (store *memoryStore) CreateEnrollmentToken(createContext context.Context, tokenHash string, agentName string, expiresAt time.Time, createdBy *uuid.UUID) (uuid.UUID, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
store.enrollmentTokens[tokenHash] = &memoryEnrollmentToken{agentName: agentName, expiresAt: expiresAt}
return uuid.New(), nil
}
func (store *memoryStore) PeekEnrollmentToken(peekContext context.Context, tokenHash string) (string, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
enrollmentToken, tokenExists := store.enrollmentTokens[tokenHash]
if !tokenExists || enrollmentToken.usedAt != nil || time.Now().After(enrollmentToken.expiresAt) {
return "", ErrEnrollmentTokenInvalid
}
return enrollmentToken.agentName, nil
}
func (store *memoryStore) ConsumeEnrollmentToken(consumeContext context.Context, tokenHash string, agentID uuid.UUID) (string, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
enrollmentToken, tokenExists := store.enrollmentTokens[tokenHash]
if !tokenExists || enrollmentToken.usedAt != nil || time.Now().After(enrollmentToken.expiresAt) {
return "", ErrEnrollmentTokenInvalid
}
consumedAt := time.Now()
enrollmentToken.usedAt = &consumedAt
return enrollmentToken.agentName, nil
}
func (store *memoryStore) CreateAgent(createContext context.Context, agentToCreate Agent) (uuid.UUID, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
agentToCreate.ID = uuid.New()
store.agents[agentToCreate.ID] = &agentToCreate
return agentToCreate.ID, nil
}
func (store *memoryStore) CreateAgentToken(createContext context.Context, agentID uuid.UUID, tokenHash string) (uuid.UUID, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
store.agentTokens[tokenHash] = agentID
return uuid.New(), nil
}
func (store *memoryStore) FindAgentByTokenHash(queryContext context.Context, tokenHash string) (Agent, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
if store.revokedTokens[tokenHash] {
return Agent{}, ErrAgentTokenInvalid
}
agentID, tokenExists := store.agentTokens[tokenHash]
if !tokenExists {
return Agent{}, ErrAgentTokenInvalid
}
foundAgent, agentExists := store.agents[agentID]
if !agentExists {
return Agent{}, ErrAgentTokenInvalid
}
return *foundAgent, nil
}
func (store *memoryStore) FindAgentByID(queryContext context.Context, agentID uuid.UUID) (Agent, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
foundAgent, agentExists := store.agents[agentID]
if !agentExists {
return Agent{}, ErrAgentNotFound
}
return *foundAgent, nil
}
func (store *memoryStore) ListAgents(queryContext context.Context, agentFilter AgentFilter) ([]Agent, int64, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
listedAgents := make([]Agent, 0, len(store.agents))
for _, storedAgent := range store.agents {
listedAgents = append(listedAgents, *storedAgent)
}
return listedAgents, int64(len(listedAgents)), nil
}
func (store *memoryStore) RecordHeartbeat(updateContext context.Context, agentID uuid.UUID, agentVersion string, ipAddress string) error {
store.mutex.Lock()
defer store.mutex.Unlock()
storedAgent, agentExists := store.agents[agentID]
if !agentExists {
return ErrAgentNotFound
}
heartbeatTime := time.Now().UTC()
storedAgent.LastHeartbeatAt = &heartbeatTime
if agentVersion != "" {
storedAgent.Version = agentVersion
}
return nil
}
func (store *memoryStore) RevokeAgent(updateContext context.Context, agentID uuid.UUID) error {
store.mutex.Lock()
defer store.mutex.Unlock()
storedAgent, agentExists := store.agents[agentID]
if !agentExists {
return ErrAgentNotFound
}
storedAgent.Status = AgentStatusRevoked
// Sämtliche Tokens des Agents werden mit widerrufen.
for tokenHash, tokenAgentID := range store.agentTokens {
if tokenAgentID == agentID {
store.revokedTokens[tokenHash] = true
}
}
return nil
}
func (store *memoryStore) RotateAgentToken(updateContext context.Context, agentID uuid.UUID, newTokenHash string) error {
store.mutex.Lock()
defer store.mutex.Unlock()
for tokenHash, tokenAgentID := range store.agentTokens {
if tokenAgentID == agentID {
store.revokedTokens[tokenHash] = true
}
}
store.agentTokens[newTokenHash] = agentID
return nil
}
// noopAuditRecorder verwirft Auditereignisse.
type noopAuditRecorder struct{}
func (recorder *noopAuditRecorder) Record(recordContext context.Context, auditEvent audit.Event) error {
return nil
}
func (recorder *noopAuditRecorder) Query(queryContext context.Context, queryFilter audit.Filter) ([]audit.StoredEvent, int64, error) {
return nil, 0, nil
}
// newTestService baut einen Agent-Dienst mit Ablage im Arbeitsspeicher.
func newTestService() (*Service, *memoryStore) {
testStore := newMemoryStore()
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
return NewService(testStore, &noopAuditRecorder{}, testLogger), testStore
}
// testUser ist der handelnde Benutzer in den Tests.
var testUser = auth.User{ID: uuid.New(), Username: "administrator"}
// enrollTestAgent nimmt einen Agent auf und liefert das Ergebnis.
func enrollTestAgent(testInstance *testing.T, agentService *Service, agentName string) RegistrationResult {
testInstance.Helper()
enrollmentToken, issueError := agentService.IssueEnrollmentToken(context.Background(),
agentName, time.Hour, testUser, auth.RequestContext{})
if issueError != nil {
testInstance.Fatalf("das Aufnahme-Token konnte nicht ausgestellt werden: %v", issueError)
}
registrationResult, registerError := agentService.Register(context.Background(), RegistrationRequest{
EnrollmentToken: enrollmentToken.Token,
Hostname: "testhost",
Platform: PlatformLinux,
Architecture: "amd64",
Version: "0.1.0-test",
IPAddress: "192.0.2.10",
})
if registerError != nil {
testInstance.Fatalf("die Registrierung schlug fehl: %v", registerError)
}
return registrationResult
}
// ---------------------------------------------------------------------------
// Aufnahme
// ---------------------------------------------------------------------------
func TestEnrollmentTokenIsReturnedOnlyOnce(testInstance *testing.T) {
agentService, testStore := newTestService()
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
"Server01", time.Hour, testUser, auth.RequestContext{})
if enrollmentToken.Token == "" {
testInstance.Fatal("es wurde kein Token ausgegeben")
}
// Gespeichert wird nur der Hash: ein Datenbankleck erlaubt damit keine
// Aufnahme fremder Agents.
testStore.mutex.Lock()
defer testStore.mutex.Unlock()
for storedHash := range testStore.enrollmentTokens {
if storedHash == enrollmentToken.Token {
testInstance.Fatal("das Token liegt im Klartext in der Ablage")
}
}
}
func TestRegistrationConsumesEnrollmentToken(testInstance *testing.T) {
agentService, _ := newTestService()
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
"Server01", time.Hour, testUser, auth.RequestContext{})
registrationRequest := RegistrationRequest{
EnrollmentToken: enrollmentToken.Token,
Hostname: "server01",
Platform: PlatformWindows,
Version: "0.1.0",
}
if _, firstError := agentService.Register(context.Background(), registrationRequest); firstError != nil {
testInstance.Fatalf("die erste Registrierung schlug fehl: %v", firstError)
}
// Ein Aufnahme-Token gilt genau einmal: sonst könnte ein Angreifer damit
// beliebig viele Agents anmelden.
_, secondError := agentService.Register(context.Background(), registrationRequest)
if !errors.Is(secondError, ErrEnrollmentTokenInvalid) {
testInstance.Fatalf("ein zweites Einlösen muss scheitern, war: %v", secondError)
}
}
func TestRegistrationRejectsExpiredToken(testInstance *testing.T) {
agentService, _ := newTestService()
// Ein Token, das bereits abgelaufen ist.
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
"Server01", time.Millisecond, testUser, auth.RequestContext{})
time.Sleep(10 * time.Millisecond)
_, registerError := agentService.Register(context.Background(), RegistrationRequest{
EnrollmentToken: enrollmentToken.Token,
Platform: PlatformLinux,
})
if !errors.Is(registerError, ErrEnrollmentTokenInvalid) {
testInstance.Fatalf("ein abgelaufenes Token muss abgelehnt werden, war: %v", registerError)
}
}
func TestRegistrationRejectsUnknownToken(testInstance *testing.T) {
agentService, _ := newTestService()
_, registerError := agentService.Register(context.Background(), RegistrationRequest{
EnrollmentToken: "voellig-erfundenes-token", // secretscan:erlaubt: erfundener Testwert
Platform: PlatformLinux,
})
if !errors.Is(registerError, ErrEnrollmentTokenInvalid) {
testInstance.Fatalf("ein unbekanntes Token muss abgelehnt werden, war: %v", registerError)
}
}
func TestEnrollmentErrorDoesNotRevealReason(testInstance *testing.T) {
// Unbekannt, abgelaufen und bereits eingelöst liefern denselben Fehler:
// eine Unterscheidung erlaubte es, gültige Tokens zu erraten.
errorMessage := ErrEnrollmentTokenInvalid.Error()
for _, revealingWord := range []string{"existiert nicht", "unbekannt"} {
if errors.Is(ErrEnrollmentTokenInvalid, errors.New(revealingWord)) {
testInstance.Errorf("die Meldung verrät die Ursache: %q", errorMessage)
}
}
}
func TestAgentNameComesFromTokenNotFromAgent(testInstance *testing.T) {
agentService, _ := newTestService()
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
"Vorgegebener-Name", time.Hour, testUser, auth.RequestContext{})
registrationResult, _ := agentService.Register(context.Background(), RegistrationRequest{
EnrollmentToken: enrollmentToken.Token,
Hostname: "beliebiger-hostname",
Platform: PlatformLinux,
})
// Der Agent bestimmt seinen Namen nicht selbst, sondern die ausstellende
// Administration. Sonst könnte er sich als ein anderes System ausgeben.
if registrationResult.Agent.Name != "Vorgegebener-Name" {
testInstance.Errorf("der Name soll vom Token stammen, war %q", registrationResult.Agent.Name)
}
}
func TestRegistrationRejectsUnsupportedPlatform(testInstance *testing.T) {
agentService, _ := newTestService()
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
"Server01", time.Hour, testUser, auth.RequestContext{})
_, registerError := agentService.Register(context.Background(), RegistrationRequest{
EnrollmentToken: enrollmentToken.Token,
Platform: Platform("solaris"),
})
if !errors.Is(registerError, ErrUnsupportedPlatform) {
testInstance.Fatalf("eine unbekannte Plattform muss abgelehnt werden, war: %v", registerError)
}
}
// ---------------------------------------------------------------------------
// Betriebstoken
// ---------------------------------------------------------------------------
func TestAgentTokenAuthenticatesAgent(testInstance *testing.T) {
agentService, _ := newTestService()
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
authenticatedAgent, authenticateError := agentService.Authenticate(context.Background(), registrationResult.AgentToken)
if authenticateError != nil {
testInstance.Fatalf("die Anmeldung mit dem Betriebstoken schlug fehl: %v", authenticateError)
}
if authenticatedAgent.ID != registrationResult.Agent.ID {
testInstance.Error("es wurde der falsche Agent geliefert")
}
}
func TestUnknownAgentTokenIsRejected(testInstance *testing.T) {
agentService, _ := newTestService()
_, authenticateError := agentService.Authenticate(context.Background(), "erfundenes-token")
if !errors.Is(authenticateError, ErrAgentTokenInvalid) {
testInstance.Fatalf("ein unbekanntes Token muss abgelehnt werden, war: %v", authenticateError)
}
}
func TestEnrollmentTokenCannotBeUsedAsAgentToken(testInstance *testing.T) {
agentService, _ := newTestService()
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
"Server01", time.Hour, testUser, auth.RequestContext{})
// Ein Aufnahme-Token taugt ausschließlich zur Registrierung, nicht zum
// Betrieb. Andernfalls wäre die Trennung der beiden Tokenarten wirkungslos.
_, authenticateError := agentService.Authenticate(context.Background(), enrollmentToken.Token)
if authenticateError == nil {
testInstance.Fatal("ein Aufnahme-Token darf nicht zur Anmeldung taugen")
}
}
func TestRevokedAgentLosesAccessImmediately(testInstance *testing.T) {
agentService, _ := newTestService()
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
// Vor der Sperre funktioniert die Anmeldung.
if _, authenticateError := agentService.Authenticate(context.Background(), registrationResult.AgentToken); authenticateError != nil {
testInstance.Fatalf("die Anmeldung schlug vor der Sperre fehl: %v", authenticateError)
}
if revokeError := agentService.RevokeAgent(context.Background(), registrationResult.Agent.ID,
testUser, auth.RequestContext{}); revokeError != nil {
testInstance.Fatalf("die Sperre schlug fehl: %v", revokeError)
}
// Ein gesperrter Agent darf sich nicht über sein altes Token zurückmelden.
if _, authenticateError := agentService.Authenticate(context.Background(), registrationResult.AgentToken); authenticateError == nil {
testInstance.Fatal("ein gesperrter Agent konnte sich weiterhin anmelden")
}
}
func TestRotationInvalidatesPreviousToken(testInstance *testing.T) {
agentService, _ := newTestService()
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
previousToken := registrationResult.AgentToken
newToken, rotateError := agentService.RotateCredentials(context.Background(),
registrationResult.Agent.ID, testUser, auth.RequestContext{})
if rotateError != nil {
testInstance.Fatalf("der Tokenwechsel schlug fehl: %v", rotateError)
}
if newToken == previousToken {
testInstance.Fatal("das neue Token entspricht dem alten")
}
// Das bisherige Token muss ungültig sein.
if _, authenticateError := agentService.Authenticate(context.Background(), previousToken); authenticateError == nil {
testInstance.Error("das alte Token gilt weiterhin")
}
// Das neue muss funktionieren.
if _, authenticateError := agentService.Authenticate(context.Background(), newToken); authenticateError != nil {
testInstance.Errorf("das neue Token wurde abgelehnt: %v", authenticateError)
}
}
func TestRotationRefusedForRevokedAgent(testInstance *testing.T) {
agentService, _ := newTestService()
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
_ = agentService.RevokeAgent(context.Background(), registrationResult.Agent.ID, testUser, auth.RequestContext{})
// Einem gesperrten Agent ein neues Token auszustellen hübe die Sperre auf.
if _, rotateError := agentService.RotateCredentials(context.Background(),
registrationResult.Agent.ID, testUser, auth.RequestContext{}); !errors.Is(rotateError, ErrAgentRevoked) {
testInstance.Fatalf("für einen gesperrten Agent darf kein Token ausgestellt werden, war: %v", rotateError)
}
}
// ---------------------------------------------------------------------------
// Lebendmeldung
// ---------------------------------------------------------------------------
func TestHeartbeatUpdatesTimestampAndVersion(testInstance *testing.T) {
agentService, _ := newTestService()
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
if heartbeatError := agentService.RecordHeartbeat(context.Background(), registrationResult.Agent,
HeartbeatRequest{Version: "0.2.0", IPAddress: "192.0.2.10"}); heartbeatError != nil {
testInstance.Fatalf("die Lebendmeldung schlug fehl: %v", heartbeatError)
}
updatedAgent, _ := agentService.GetAgent(context.Background(), registrationResult.Agent.ID)
if updatedAgent.LastHeartbeatAt == nil {
testInstance.Fatal("der Zeitpunkt der Lebendmeldung wurde nicht vermerkt")
}
// Eine neue Programmversion muss sofort sichtbar sein, damit veraltete
// Agents erkennbar bleiben.
if updatedAgent.Version != "0.2.0" {
testInstance.Errorf("die Version soll übernommen werden, war %q", updatedAgent.Version)
}
}
func TestIsOfflineDetectsSilentAgent(testInstance *testing.T) {
referenceTime := time.Now()
allowedSilence := 10 * time.Minute
recentHeartbeat := referenceTime.Add(-time.Minute)
activeAgent := Agent{LastHeartbeatAt: &recentHeartbeat, RegisteredAt: referenceTime.Add(-time.Hour)}
if activeAgent.IsOffline(referenceTime, allowedSilence) {
testInstance.Error("ein Agent mit frischer Meldung gilt nicht als offline")
}
staleHeartbeat := referenceTime.Add(-time.Hour)
silentAgent := Agent{LastHeartbeatAt: &staleHeartbeat, RegisteredAt: referenceTime.Add(-2 * time.Hour)}
if !silentAgent.IsOffline(referenceTime, allowedSilence) {
testInstance.Error("ein stummer Agent muss als offline gelten")
}
}
func TestAgentWithoutHeartbeatIsOfflineAfterGracePeriod(testInstance *testing.T) {
referenceTime := time.Now()
allowedSilence := 10 * time.Minute
// Ein gerade aufgenommener Agent hatte noch keine Gelegenheit, sich zu melden.
freshAgent := Agent{RegisteredAt: referenceTime.Add(-time.Minute)}
if freshAgent.IsOffline(referenceTime, allowedSilence) {
testInstance.Error("ein gerade aufgenommener Agent gilt noch nicht als offline")
}
// Meldet er sich dauerhaft nicht, ist das ein Problem.
neverSeenAgent := Agent{RegisteredAt: referenceTime.Add(-time.Hour)}
if !neverSeenAgent.IsOffline(referenceTime, allowedSilence) {
testInstance.Error("ein nie erschienener Agent muss als offline gelten")
}
// Ein Agent ohne Meldung ist etwas anderes als einer mit alter Meldung.
if _, hasHeartbeat := neverSeenAgent.HeartbeatAge(referenceTime); hasHeartbeat {
testInstance.Error("ohne Lebendmeldung darf kein Alter gemeldet werden")
}
}