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") } }