projection: begin the exact-path public epoch

The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

View file

@ -0,0 +1,247 @@
package event
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/gofrs/uuid/v5"
"sync"
"github.com/Fimeg/RedFlag/agent/internal/models"
)
const (
defaultMaxBufferSize = 1000 // Max events to buffer
)
// Buffer handles local event buffering for offline resilience
type Buffer struct {
filePath string
maxSize int
mu sync.Mutex
}
// NewBuffer creates a new event buffer with the specified file path
func NewBuffer(filePath string) *Buffer {
return &Buffer{
filePath: filePath,
maxSize: defaultMaxBufferSize,
}
}
// BufferEvent saves an event to the local buffer file
func (b *Buffer) BufferEvent(event *models.SystemEvent) error {
b.mu.Lock()
defer b.mu.Unlock()
// Ensure event has an ID
if event.ID == uuid.Nil {
return fmt.Errorf("event ID cannot be nil")
}
// Create directory if needed
dir := filepath.Dir(b.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create buffer directory: %w", err)
}
// Read existing buffer
var events []*models.SystemEvent
if data, err := os.ReadFile(b.filePath); err == nil {
if err := json.Unmarshal(data, &events); err != nil {
// If we can't unmarshal, start fresh
events = []*models.SystemEvent{}
}
}
// Append new event
events = append(events, event)
// Desktop history survives successful fleet delivery. Seed it from the
// pending queue on upgrade, then deduplicate retries by the event identity.
historyErr := b.retainHistoryLocked(events)
// Keep only last N events if buffer too large (circular buffer)
if len(events) > b.maxSize {
events = events[len(events)-b.maxSize:]
}
// Write back to file
data, err := json.Marshal(events)
if err != nil {
return fmt.Errorf("failed to marshal events: %w", err)
}
if err := os.WriteFile(b.filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write buffer file: %w", err)
}
return historyErr
}
const localHistoryLimit = 5000
func (b *Buffer) historyPath() string { return b.filePath + ".history.json" }
func (b *Buffer) retainHistoryLocked(pending []*models.SystemEvent) error {
var history []*models.SystemEvent
data, err := os.ReadFile(b.historyPath())
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read local history: %w", err)
}
if err == nil {
if err := json.Unmarshal(data, &history); err != nil {
return fmt.Errorf("decode local history: %w", err)
}
}
seen := make(map[uuid.UUID]bool, len(history))
for _, e := range history {
if e != nil {
seen[e.ID] = true
}
}
for _, e := range pending {
if e != nil && !seen[e.ID] {
history = append(history, e)
seen[e.ID] = true
}
}
if len(history) > localHistoryLimit {
history = history[len(history)-localHistoryLimit:]
}
data, err = json.Marshal(history)
if err != nil {
return fmt.Errorf("encode local history: %w", err)
}
f, err := os.CreateTemp(filepath.Dir(b.filePath), ".history-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(data); err != nil {
f.Close()
return err
}
if err = f.Sync(); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
if err = os.Rename(f.Name(), b.historyPath()); err != nil {
return fmt.Errorf("save local history: %w", err)
}
return nil
}
// ReadHistory returns retained events independently of the delivery queue.
// Before the first new event after upgrade, the existing queue remains visible.
func (b *Buffer) ReadHistory() ([]*models.SystemEvent, error) {
b.mu.Lock()
defer b.mu.Unlock()
data, err := os.ReadFile(b.historyPath())
if os.IsNotExist(err) {
data, err = os.ReadFile(b.filePath)
}
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var events []*models.SystemEvent
if err := json.Unmarshal(data, &events); err != nil {
return nil, err
}
return events, nil
}
// GetBufferedEvents retrieves and clears the buffer atomically.
func (b *Buffer) GetBufferedEvents() ([]*models.SystemEvent, error) {
b.mu.Lock()
defer b.mu.Unlock()
var events []*models.SystemEvent
data, err := os.ReadFile(b.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read buffer file: %w", err)
}
if err := json.Unmarshal(data, &events); err != nil {
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
}
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
return nil, err
}
return events, nil
}
// ReadBufferedEvents retrieves buffered events without clearing them.
func (b *Buffer) ReadBufferedEvents() ([]*models.SystemEvent, error) {
b.mu.Lock()
defer b.mu.Unlock()
// Read buffer file
var events []*models.SystemEvent
data, err := os.ReadFile(b.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No buffer file means no events
}
return nil, fmt.Errorf("failed to read buffer file: %w", err)
}
if err := json.Unmarshal(data, &events); err != nil {
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
}
return events, nil
}
// Clear removes the current buffer file.
func (b *Buffer) Clear() error {
b.mu.Lock()
defer b.mu.Unlock()
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// SetMaxSize sets the maximum number of events to buffer
func (b *Buffer) SetMaxSize(size int) {
b.mu.Lock()
defer b.mu.Unlock()
b.maxSize = size
}
// GetStats returns buffer statistics
func (b *Buffer) GetStats() (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
data, err := os.ReadFile(b.filePath)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
var events []*models.SystemEvent
if err := json.Unmarshal(data, &events); err != nil {
return 0, err
}
return len(events), nil
}

View file

@ -0,0 +1,318 @@
// Package event provides event helper functions and buffering for the RedFlag agent.
//
// ETHOS Compliance:
// (1) Errors are History, Not /dev/null
// - All failures are captured as events
// - Events include full context and metadata
// - Events are buffered for offline scenarios
//
// (3) Assume Failure; Build for Resilience
// - Best-effort event buffering (fails gracefully if buffer unavailable)
// - Events persist to disk for recovery
package event
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
// ScannerEventType represents a scanner operation event
type ScannerEventType string
const (
// Event types for agent operations
EventTypeAgentScan = "agent_scan"
EventTypeAgentConfig = "agent_config"
EventTypeAgentCheckIn = "agent_checkin"
EventTypeAgentOffline = "agent_offline"
)
// ScanResult indicates the outcome of a scan operation
type ScanResult string
const (
ScanResultSuccess ScanResult = "success"
ScanResultFailed ScanResult = "failed"
ScanResultTimeout ScanResult = "timeout"
ScanResultSkipped ScanResult = "skipped"
)
// BufferSystemEvent records a SystemEvent into the operational event buffer.
// A nil buffer degrades to local-only behavior; callers should still log or
// report command results through their normal channel.
func BufferSystemEvent(buffer *Buffer, agentID uuid.UUID, eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
if buffer == nil {
return
}
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
evt := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: eventType,
EventSubtype: eventSubtype,
Severity: severity,
Component: component,
Message: message,
Metadata: metadata,
CreatedAt: time.Now().UTC(),
}
if err := buffer.BufferEvent(evt); err != nil {
log.Printf("[WARNING] [agent] [event] buffer_system_event_failed error=%v", err)
}
}
// NewScanFailureEvent creates a SystemEvent for a failed scan operation
//
// Parameters:
// - scannerName: The name of the scanner that failed (e.g., "apt", "docker")
// - err: The error that occurred
// - duration: How long the scan took before failing
// - agentID: The agent UUID (empty for client-side creation without binding)
//
// Returns a SystemEvent ready to buffer or send to the server.
func NewScanFailureEvent(scannerName string, err error, duration time.Duration, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
// Determine severity based on error type and scanner
severity := models.SeverityWarning
if isCriticalScanner(scannerName) {
severity = models.SeverityCritical
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentScan,
EventSubtype: models.SubtypeFailed,
Severity: severity,
Component: scannerName + "_scanner",
Message: fmt.Sprintf("%s scan failed after %v: %s", scannerName, duration.Round(time.Millisecond), err.Error()),
Metadata: map[string]interface{}{
"scanner": scannerName,
"error": err.Error(),
"duration_ms": duration.Milliseconds(),
"result": string(ScanResultFailed),
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewScanSuccessEvent creates a SystemEvent for a successful scan operation
//
// Parameters:
// - scannerName: The name of the scanner
// - updateCount: Number of updates found
// - duration: How long the scan took
// - agentID: The agent UUID (empty for client-side creation without binding)
func NewScanSuccessEvent(scannerName string, updateCount int, duration time.Duration, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
message := fmt.Sprintf("%s scan completed in %v", scannerName, duration.Round(time.Millisecond))
if updateCount > 0 {
message = fmt.Sprintf("%s scan found %d update(s) in %v", scannerName, updateCount, duration.Round(time.Millisecond))
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentScan,
EventSubtype: models.SubtypeSuccess,
Severity: models.SeverityInfo,
Component: scannerName + "_scanner",
Message: message,
Metadata: map[string]interface{}{
"scanner": scannerName,
"update_count": updateCount,
"duration_ms": duration.Milliseconds(),
"result": string(ScanResultSuccess),
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewScanTimeoutEvent creates a SystemEvent for a scan that timed out
func NewScanTimeoutEvent(scannerName string, duration time.Duration, timeout time.Duration, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentScan,
EventSubtype: models.SubtypeNetworkTimeout,
Severity: models.SeverityWarning,
Component: scannerName + "_scanner",
Message: fmt.Sprintf("%s scan timed out after %v (timeout: %v)", scannerName, duration.Round(time.Millisecond), timeout),
Metadata: map[string]interface{}{
"scanner": scannerName,
"duration_ms": duration.Milliseconds(),
"timeout_ms": timeout.Milliseconds(),
"result": string(ScanResultTimeout),
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewConfigSyncEvent creates a SystemEvent for a config sync operation
func NewConfigSyncEvent(success bool, changes []string, attempt int, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
subtype := models.SubtypeSuccess
severity := models.SeverityInfo
message := fmt.Sprintf("Config synced successfully (attempt %d)", attempt)
if !success {
subtype = models.SubtypeFailed
severity = models.SeverityWarning
message = fmt.Sprintf("Config sync failed (attempt %d)", attempt)
} else if len(changes) > 0 {
message = fmt.Sprintf("Config updated: %v (attempt %d)", changes, attempt)
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentConfig,
EventSubtype: subtype,
Severity: severity,
Component: models.ComponentAgent,
Message: message,
Metadata: map[string]interface{}{
"success": success,
"changes": changes,
"attempt": attempt,
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewOfflineEvent creates a SystemEvent when the agent goes offline
func NewOfflineEvent(reason string, lastCheckIn time.Time, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
message := "Agent went offline"
if reason != "" {
message = fmt.Sprintf("Agent went offline: %s", reason)
}
var offlineDuration string
if !lastCheckIn.IsZero() {
offlineDuration = fmt.Sprintf("%v", time.Since(lastCheckIn).Round(time.Second))
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentOffline,
EventSubtype: models.SubtypeTokenExpired, // Reusing existing subtype
Severity: models.SeverityWarning,
Component: models.ComponentAgent,
Message: message,
Metadata: map[string]interface{}{
"reason": reason,
"last_check_in": lastCheckIn.Format(time.RFC3339),
"offline_duration": offlineDuration,
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewReconnectionEvent creates a SystemEvent when the agent reconnects
func NewReconnectionEvent(offlineDuration time.Duration, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentCheckIn,
EventSubtype: models.SubtypeSuccess,
Severity: models.SeverityInfo,
Component: models.ComponentAgent,
Message: fmt.Sprintf("Agent reconnected after %v offline", offlineDuration.Round(time.Second)),
Metadata: map[string]interface{}{
"offline_duration_ms": offlineDuration.Milliseconds(),
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewCheckInEvent creates a SystemEvent for a successful check-in
func NewCheckInEvent(hasCommands bool, commandCount int, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
subtype := models.SubtypeInfo
message := "Check-in successful - no new commands"
if hasCommands {
subtype = models.SubtypeSuccess
message = fmt.Sprintf("Check-in successful - received %d command(s)", commandCount)
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentCheckIn,
EventSubtype: subtype,
Severity: models.SeverityInfo,
Component: models.ComponentAgent,
Message: message,
Metadata: map[string]interface{}{
"has_commands": hasCommands,
"command_count": commandCount,
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// isCriticalScanner returns true if the scanner is considered critical
// Critical scanner failures are logged with SeverityCritical
func isCriticalScanner(scannerName string) bool {
// Update-related scanners that indicate potential security or update issues
criticalScanners := map[string]bool{
"apt": true, // Security patches
"dnf": true, // Security patches
"windows": true, // Security patches
"winget": false, // Optional updates
"docker": false, // Optional feature
"storage": false, // Monitoring only
"system": false, // Monitoring only
}
return criticalScanners[scannerName]
}

View file

@ -0,0 +1,56 @@
package event
import (
"os"
"path/filepath"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
func TestLocalHistorySurvivesDeliveryAndRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.json")
buffer := NewBuffer(path)
e := &models.SystemEvent{ID: uuid.Must(uuid.NewV4()), Message: "scan failed", Severity: "error"}
if err := buffer.BufferEvent(e); err != nil {
t.Fatal(err)
}
if err := buffer.Clear(); err != nil {
t.Fatal(err)
}
reopened := NewBuffer(path)
history, err := reopened.ReadHistory()
if err != nil || len(history) != 1 || history[0].ID != e.ID {
t.Fatalf("history=%v error=%v", history, err)
}
if err := reopened.BufferEvent(e); err != nil {
t.Fatal(err)
}
if _, err := reopened.GetBufferedEvents(); err != nil {
t.Fatal(err)
}
history, err = reopened.ReadHistory()
if err != nil || len(history) != 1 {
t.Fatalf("retried event duplicated in history: count=%d error=%v", len(history), err)
}
}
func TestHistoryFailureDoesNotDiscardDeliveryQueue(t *testing.T) {
buffer := NewBuffer(filepath.Join(t.TempDir(), "events.json"))
if err := os.WriteFile(buffer.historyPath(), []byte("damaged history"), 0600); err != nil {
t.Fatal(err)
}
e := &models.SystemEvent{ID: uuid.Must(uuid.NewV4()), Message: "keep this event"}
if err := buffer.BufferEvent(e); err == nil {
t.Fatal("history damage was silent")
}
pending, err := buffer.ReadBufferedEvents()
if err != nil || len(pending) != 1 {
t.Fatalf("delivery queue lost: count=%d error=%v", len(pending), err)
}
data, err := os.ReadFile(buffer.historyPath())
if err != nil || string(data) != "damaged history" {
t.Fatal("damaged history was overwritten")
}
}

View file

@ -0,0 +1,166 @@
// Package event provides event helper functions and buffering for the RedFlag agent.
//
// TeeLogger emits an ETHOS-tagged log.Printf AND a buffered SystemEvent in a
// single call. When the internal buffer is nil (e.g. during early boot or in
// tests), it degrades to log-only mode.
//
// ETHOS Compliance:
// (1) Errors are History, Not /dev/null
// - Every TeeLogger call produces a log line (stderr/journald)
// - Every TeeLogger call buffers a SystemEvent for server delivery
// - Buffer failures are logged, never silently dropped
//
// (3) Assume Failure; Build for Resilience
// - Nil buffer degrades gracefully to log-only
// - Buffer write is best-effort; log line is the durability guarantee
package event
import (
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
// TeeLogger emits an ETHOS-tagged log.Printf AND a buffered SystemEvent
// in a single call. When the internal buffer is nil (e.g. during early boot
// or in test), it degrades to log-only mode.
type TeeLogger struct {
buffer *Buffer
agentID uuid.UUID
}
// NewTeeLogger creates a TeeLogger. Pass a nil buffer for log-only mode.
func NewTeeLogger(buffer *Buffer, agentID uuid.UUID) *TeeLogger {
return &TeeLogger{buffer: buffer, agentID: agentID}
}
// Buffer returns the operational event buffer used by this logger.
func (l *TeeLogger) Buffer() *Buffer {
if l == nil {
return nil
}
return l.buffer
}
// LogParams carries all structured fields for a dual-output event.
type LogParams struct {
Level string // ETHOS level tag: "INFO", "WARNING", "ERROR", "CRITICAL"
System string // ETHOS system tag: "agent"
Component string // ETHOS component tag: "migration", "crypto", "config"
EventType string // SystemEvent.EventType
EventSubtype string // SystemEvent.EventSubtype
Severity string // SystemEvent.Severity
ServerComponent string // SystemEvent.Component
Message string // Human-readable message
Metadata map[string]interface{} // Structured key-value pairs
}
// Log is the core dual-output method.
func (l *TeeLogger) Log(params LogParams) {
// Nil receiver: log-only mode (no buffer, no agent ID).
if l == nil {
log.Printf("[%s] [%s] [%s] %s", params.Level, params.System, params.Component, params.Message)
return
}
// 1. ETHOS-tagged log line
log.Printf("[%s] [%s] [%s] %s", params.Level, params.System, params.Component, params.Message)
// 2. Buffer SystemEvent (best-effort, nil-safe)
if l.buffer == nil {
return
}
var agentIDPtr *uuid.UUID
if l.agentID != uuid.Nil {
agentIDPtr = &l.agentID
}
evt := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: params.EventType,
EventSubtype: params.EventSubtype,
Severity: params.Severity,
Component: params.ServerComponent,
Message: params.Message,
Metadata: params.Metadata,
CreatedAt: time.Now().UTC(),
}
if err := l.buffer.BufferEvent(evt); err != nil {
log.Printf("[WARNING] [agent] [event] tee_buffer_failed error=%v", err)
}
}
// Info emits an INFO-level dual-output event.
func (l *TeeLogger) Info(system, component, serverComponent, message string, metadata map[string]interface{}) {
l.Log(LogParams{
Level: "INFO", System: system, Component: component,
EventType: deriveEventType(component), EventSubtype: models.SubtypeInfo,
Severity: models.SeverityInfo, ServerComponent: serverComponent,
Message: message, Metadata: metadata,
})
}
// Warning emits a WARNING-level dual-output event.
func (l *TeeLogger) Warning(system, component, serverComponent, message string, metadata map[string]interface{}) {
l.Log(LogParams{
Level: "WARNING", System: system, Component: component,
EventType: deriveEventType(component), EventSubtype: models.SubtypeWarning,
Severity: models.SeverityWarning, ServerComponent: serverComponent,
Message: message, Metadata: metadata,
})
}
// Error emits an ERROR-level dual-output event.
func (l *TeeLogger) Error(system, component, serverComponent, message string, metadata map[string]interface{}) {
l.Log(LogParams{
Level: "ERROR", System: system, Component: component,
EventType: deriveEventType(component), EventSubtype: models.SubtypeFailed,
Severity: models.SeverityError, ServerComponent: serverComponent,
Message: message, Metadata: metadata,
})
}
// Critical emits a CRITICAL-level dual-output event.
func (l *TeeLogger) Critical(system, component, serverComponent, message string, metadata map[string]interface{}) {
l.Log(LogParams{
Level: "CRITICAL", System: system, Component: component,
EventType: deriveEventType(component), EventSubtype: models.SubtypeCritical,
Severity: models.SeverityCritical, ServerComponent: serverComponent,
Message: message, Metadata: metadata,
})
}
// deriveEventType maps an ETHOS component tag to a SystemEvent.EventType.
// Callers can always override by using Log() directly with explicit params.
func deriveEventType(component string) string {
switch component {
case "migration":
return models.EventTypeAgentMigration
case "crypto":
return models.EventTypeAgentCrypto
case "config":
return models.EventTypeAgentConfig
case "docker":
return models.EventTypeAgentDocker
case "installer":
return models.EventTypeAgentInstall
case "kernel":
return models.EventTypeAgentStartup
case "acknowledgment":
return models.EventTypeAgentCheckIn
case "receipt":
return models.EventTypeAgentCheckIn
case "localapi":
return models.EventTypeAgentStartup
case "loop":
return models.EventTypeAgentStartup
default:
return models.EventTypeError
}
}

View file

@ -0,0 +1,169 @@
package event
import (
"testing"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
func TestTeeLoggerNilBuffer(t *testing.T) {
// TeeLogger with nil buffer should not panic
logger := NewTeeLogger(nil, uuid.Nil)
// Should not panic — log-only mode
logger.Info("agent", "test", "test_component", "test message", nil)
logger.Warning("agent", "test", "test_component", "test warning", nil)
logger.Error("agent", "test", "test_component", "test error", nil)
logger.Critical("agent", "test", "test_component", "test critical", nil)
}
func TestTeeLoggerNilAgentID(t *testing.T) {
// TeeLogger with Nil agent ID should produce events with nil AgentID
buffer := NewBuffer(t.TempDir() + "/events.json")
logger := NewTeeLogger(buffer, uuid.Nil)
logger.Info("agent", "test", "test_component", "test message", nil)
events, err := buffer.GetBufferedEvents()
if err != nil {
t.Fatalf("GetBufferedEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].AgentID != nil {
t.Errorf("expected nil AgentID, got %v", events[0].AgentID)
}
}
func TestTeeLoggerEventFields(t *testing.T) {
buffer := NewBuffer(t.TempDir() + "/events.json")
agentID := uuid.Must(uuid.NewV4())
logger := NewTeeLogger(buffer, agentID)
metadata := map[string]interface{}{
"key1": "value1",
"key2": 42,
}
logger.Warning("agent", "crypto", "pubkey", "key fetch failed", metadata)
events, err := buffer.GetBufferedEvents()
if err != nil {
t.Fatalf("GetBufferedEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
evt := events[0]
if evt.AgentID == nil || *evt.AgentID != agentID {
t.Errorf("expected AgentID %v, got %v", agentID, evt.AgentID)
}
if evt.EventType != models.EventTypeAgentCrypto {
t.Errorf("expected EventType %q, got %q", models.EventTypeAgentCrypto, evt.EventType)
}
if evt.EventSubtype != models.SubtypeWarning {
t.Errorf("expected EventSubtype %q, got %q", models.SubtypeWarning, evt.EventSubtype)
}
if evt.Severity != models.SeverityWarning {
t.Errorf("expected Severity %q, got %q", models.SeverityWarning, evt.Severity)
}
if evt.Component != "pubkey" {
t.Errorf("expected Component %q, got %q", "pubkey", evt.Component)
}
if evt.Message != "key fetch failed" {
t.Errorf("expected Message %q, got %q", "key fetch failed", evt.Message)
}
if evt.Metadata["key1"] != "value1" {
t.Errorf("expected metadata key1=value1, got %v", evt.Metadata["key1"])
}
// JSON round-trip converts int to float64
if int(evt.Metadata["key2"].(float64)) != 42 {
t.Errorf("expected metadata key2=42, got %v", evt.Metadata["key2"])
}
}
func TestTeeLoggerConvenienceMethods(t *testing.T) {
buffer := NewBuffer(t.TempDir() + "/events.json")
logger := NewTeeLogger(buffer, uuid.Must(uuid.NewV4()))
tests := []struct {
name string
call func()
expectedLevel string
expectedSubtype string
expectedSeverity string
}{
{"Info", func() { logger.Info("agent", "config", "config", "loaded", nil) }, "INFO", models.SubtypeInfo, models.SeverityInfo},
{"Warning", func() { logger.Warning("agent", "config", "config", "stale", nil) }, "WARNING", models.SubtypeWarning, models.SeverityWarning},
{"Error", func() { logger.Error("agent", "config", "config", "failed", nil) }, "ERROR", models.SubtypeFailed, models.SeverityError},
{"Critical", func() { logger.Critical("agent", "config", "config", "corrupt", nil) }, "CRITICAL", models.SubtypeCritical, models.SeverityCritical},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.call()
})
}
events, err := buffer.GetBufferedEvents()
if err != nil {
t.Fatalf("GetBufferedEvents failed: %v", err)
}
if len(events) != len(tests) {
t.Fatalf("expected %d events, got %d", len(tests), len(events))
}
for i, tt := range tests {
evt := events[i]
if evt.EventSubtype != tt.expectedSubtype {
t.Errorf("%s: expected EventSubtype %q, got %q", tt.name, tt.expectedSubtype, evt.EventSubtype)
}
if evt.Severity != tt.expectedSeverity {
t.Errorf("%s: expected Severity %q, got %q", tt.name, tt.expectedSeverity, evt.Severity)
}
}
}
func TestDeriveEventType(t *testing.T) {
tests := []struct {
component string
expected string
}{
{"migration", models.EventTypeAgentMigration},
{"crypto", models.EventTypeAgentCrypto},
{"config", models.EventTypeAgentConfig},
{"docker", models.EventTypeAgentDocker},
{"installer", models.EventTypeAgentInstall},
{"unknown", models.EventTypeError},
}
for _, tt := range tests {
t.Run(tt.component, func(t *testing.T) {
got := deriveEventType(tt.component)
if got != tt.expected {
t.Errorf("deriveEventType(%q) = %q, want %q", tt.component, got, tt.expected)
}
})
}
}
func TestTeeLoggerMetadataNil(t *testing.T) {
buffer := NewBuffer(t.TempDir() + "/events.json")
logger := NewTeeLogger(buffer, uuid.Must(uuid.NewV4()))
// Should not panic with nil metadata
logger.Info("agent", "test", "test_component", "test message", nil)
events, err := buffer.GetBufferedEvents()
if err != nil {
t.Fatalf("GetBufferedEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].Metadata != nil {
t.Errorf("expected nil metadata, got %v", events[0].Metadata)
}
}