package soc import ( "bytes" "fmt" "net/http" "net/http/httptest" "os" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" domsoc "github.com/syntrex/gomcp/internal/domain/soc" "github.com/syntrex/gomcp/internal/infrastructure/audit" "github.com/syntrex/gomcp/internal/infrastructure/sqlite" ) // newTestServiceWithLogger creates a SOC service backed by in-memory SQLite WITH a decision logger. func newTestServiceWithLogger(t *testing.T) *Service { t.Helper() db, err := sqlite.OpenMemory() require.NoError(t, err) repo, err := sqlite.NewSOCRepo(db) require.NoError(t, err) logger, err := audit.NewDecisionLogger(t.TempDir()) require.NoError(t, err) // Close logger BEFORE TempDir cleanup (Windows file locking). t.Cleanup(func() { logger.Close() db.Close() }) return NewService(repo, logger) } // --- E2E: Full Pipeline (Ingest → Correlation → Incident → Playbook) --- func TestE2E_FullPipeline_IngestToIncident(t *testing.T) { svc := newTestServiceWithLogger(t) // Step 1: Ingest a jailbreak event. evt1 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityHigh, "jailbreak", "detected jailbreak attempt") evt1.SensorID = "sensor-e2e-1" id1, inc1, err := svc.IngestEvent(evt1) require.NoError(t, err) assert.NotEmpty(t, id1) assert.Nil(t, inc1, "single event should not trigger correlation") // Step 2: Ingest a tool_abuse event from same source — triggers SOC-CR-001. evt2 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityCritical, "tool_abuse", "tool abuse detected") evt2.SensorID = "sensor-e2e-1" id2, inc2, err := svc.IngestEvent(evt2) require.NoError(t, err) assert.NotEmpty(t, id2) // Correlation rule SOC-CR-001 (jailbreak + tool_abuse) should trigger an incident. require.NotNil(t, inc2, "jailbreak + tool_abuse should create an incident") assert.Equal(t, domsoc.SeverityCritical, inc2.Severity) assert.Equal(t, "Multi-stage Jailbreak", inc2.Title) assert.NotEmpty(t, inc2.ID) assert.NotEmpty(t, inc2.Events, "incident should reference triggering events") // Step 3: Verify incident is persisted. gotInc, err := svc.GetIncident(inc2.ID) require.NoError(t, err) assert.Equal(t, inc2.ID, gotInc.ID) // Step 4: Verify decision chain integrity. dash, err := svc.Dashboard("") require.NoError(t, err) assert.True(t, dash.ChainValid, "decision chain should be valid") assert.Greater(t, dash.TotalEvents, 0) } func TestE2E_TemporalSequenceCorrelation(t *testing.T) { svc := newTestServiceWithLogger(t) // Sequence rule SOC-CR-010: auth_bypass → tool_abuse (ordered). evt1 := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityHigh, "auth_bypass", "brute force detected") evt1.SensorID = "sensor-seq-1" _, _, err := svc.IngestEvent(evt1) require.NoError(t, err) evt2 := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityHigh, "tool_abuse", "tool escalation") evt2.SensorID = "sensor-seq-1" _, inc, err := svc.IngestEvent(evt2) require.NoError(t, err) // Should trigger either SOC-CR-010 (sequence) or another matching rule. if inc != nil { assert.NotEmpty(t, inc.KillChainPhase) assert.NotEmpty(t, inc.MITREMapping) } } // --- E2E: Sensor Authentication Flow --- func TestE2E_SensorAuth_FullFlow(t *testing.T) { svc := newTestServiceWithLogger(t) // Configure sensor keys. svc.SetSensorKeys(map[string]string{ "sensor-auth-1": "secret-key-1", "sensor-auth-2": "secret-key-2", }) // Valid auth — should succeed. evt := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "auth test") evt.SensorID = "sensor-auth-1" evt.SensorKey = "secret-key-1" id, _, err := svc.IngestEvent(evt) require.NoError(t, err) assert.NotEmpty(t, id) // Invalid key — should fail. evt2 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "bad key") evt2.SensorID = "sensor-auth-1" evt2.SensorKey = "wrong-key" _, _, err = svc.IngestEvent(evt2) require.Error(t, err) assert.Contains(t, err.Error(), "auth") // Missing SensorID — should fail (S-1 fix). evt3 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "no sensor id") _, _, err = svc.IngestEvent(evt3) require.Error(t, err) assert.Contains(t, err.Error(), "sensor_id required") // Unknown sensor — should fail. evt4 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "unknown sensor") evt4.SensorID = "sensor-unknown" evt4.SensorKey = "whatever" _, _, err = svc.IngestEvent(evt4) require.Error(t, err) assert.Contains(t, err.Error(), "auth") } // --- E2E: Drain Mode --- func TestE2E_DrainMode_RejectsNewEvents(t *testing.T) { svc := newTestServiceWithLogger(t) // Ingest works before drain. evt := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "pre-drain") evt.SensorID = "sensor-drain" _, _, err := svc.IngestEvent(evt) require.NoError(t, err) // Activate drain mode. svc.Drain() assert.True(t, svc.IsDraining()) // New events should be rejected. evt2 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "during-drain") evt2.SensorID = "sensor-drain" _, _, err = svc.IngestEvent(evt2) require.Error(t, err) assert.Contains(t, err.Error(), "draining") // Resume. svc.Resume() assert.False(t, svc.IsDraining()) // Events should work again. evt3 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "post-drain") evt3.SensorID = "sensor-drain" _, _, err = svc.IngestEvent(evt3) require.NoError(t, err) } // --- E2E: Webhook Delivery --- func TestE2E_WebhookFiredOnIncident(t *testing.T) { svc := newTestServiceWithLogger(t) // Set up a test webhook server. var mu sync.Mutex var received []string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() received = append(received, r.URL.Path) mu.Unlock() w.WriteHeader(http.StatusOK) })) defer ts.Close() svc.SetWebhookConfig(WebhookConfig{ Endpoints: []string{ts.URL + "/webhook"}, MaxRetries: 1, TimeoutSec: 5, }) // Trigger an incident via correlation. evt1 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityHigh, "jailbreak", "jailbreak e2e") evt1.SensorID = "sensor-wh" svc.IngestEvent(evt1) evt2 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityCritical, "tool_abuse", "tool abuse e2e") evt2.SensorID = "sensor-wh" _, inc, err := svc.IngestEvent(evt2) require.NoError(t, err) if inc != nil { // Give the async webhook goroutine time to fire. time.Sleep(200 * time.Millisecond) mu.Lock() assert.GreaterOrEqual(t, len(received), 1, "webhook should have been called") mu.Unlock() } } // --- E2E: Verdict Flow --- func TestE2E_VerdictFlow(t *testing.T) { svc := newTestServiceWithLogger(t) // Create an incident via correlation. evt1 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityHigh, "jailbreak", "verdict test 1") evt1.SensorID = "sensor-vd" svc.IngestEvent(evt1) evt2 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityCritical, "tool_abuse", "verdict test 2") evt2.SensorID = "sensor-vd" _, inc, _ := svc.IngestEvent(evt2) if inc == nil { t.Skip("no incident created — correlation rules may not match with current sliding window state") } // Verify initial status is OPEN. got, err := svc.GetIncident(inc.ID) require.NoError(t, err) assert.Equal(t, domsoc.StatusOpen, got.Status) // Update to INVESTIGATING. err = svc.UpdateVerdict(inc.ID, domsoc.StatusInvestigating) require.NoError(t, err) got, _ = svc.GetIncident(inc.ID) assert.Equal(t, domsoc.StatusInvestigating, got.Status) // Update to RESOLVED. err = svc.UpdateVerdict(inc.ID, domsoc.StatusResolved) require.NoError(t, err) got, _ = svc.GetIncident(inc.ID) assert.Equal(t, domsoc.StatusResolved, got.Status) } // --- E2E: Analytics Report --- func TestE2E_AnalyticsReport(t *testing.T) { svc := newTestServiceWithLogger(t) // Ingest several events. categories := []string{"jailbreak", "injection", "exfiltration", "auth_bypass", "tool_abuse"} for i, cat := range categories { evt := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityHigh, cat, fmt.Sprintf("analytics test %d", i)) evt.SensorID = "sensor-analytics" svc.IngestEvent(evt) } report, err := svc.Analytics(24) require.NoError(t, err) assert.NotNil(t, report) assert.Greater(t, len(report.TopCategories), 0) assert.Greater(t, len(report.TopSources), 0) assert.GreaterOrEqual(t, report.EventsPerHour, float64(0)) } // --- E2E: Multi-Sensor Concurrent Ingest --- func TestE2E_ConcurrentIngest(t *testing.T) { svc := newTestServiceWithLogger(t) var wg sync.WaitGroup errors := make([]error, 0) var mu sync.Mutex // 10 sensors × 10 events each = 100 concurrent ingests. for s := 0; s < 10; s++ { wg.Add(1) go func(sensorNum int) { defer wg.Done() for i := 0; i < 10; i++ { evt := domsoc.NewSOCEvent( domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", fmt.Sprintf("concurrent sensor-%d event-%d", sensorNum, i), ) evt.SensorID = fmt.Sprintf("sensor-conc-%d", sensorNum) _, _, err := svc.IngestEvent(evt) if err != nil { mu.Lock() errors = append(errors, err) mu.Unlock() } } }(s) } wg.Wait() // Some events may be rate-limited (100 events/sec per sensor), // but there should be no panics or data corruption. dash, err := svc.Dashboard("") require.NoError(t, err) assert.Greater(t, dash.TotalEvents, 0, "at least some events should have been ingested") } // --- E2E: Lattice TSA Chain Violation (SOC-CR-012) --- func TestE2E_TSAChainViolation(t *testing.T) { svc := newTestServiceWithLogger(t) // SOC-CR-012 requires: auth_bypass → tool_abuse → exfiltration within 15 min. events := []struct { category string severity domsoc.EventSeverity }{ {"auth_bypass", domsoc.SeverityHigh}, {"tool_abuse", domsoc.SeverityHigh}, {"exfiltration", domsoc.SeverityCritical}, } var lastInc *domsoc.Incident for _, e := range events { evt := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, e.severity, e.category, "TSA chain test: "+e.category) evt.SensorID = "sensor-tsa" _, inc, err := svc.IngestEvent(evt) require.NoError(t, err) if inc != nil { lastInc = inc } } // The TSA chain (auth_bypass + tool_abuse + exfiltration) should trigger // SOC-CR-012 or another matching rule. require.NotNil(t, lastInc, "TSA chain (auth_bypass → tool_abuse → exfiltration) should create an incident") assert.Equal(t, domsoc.SeverityCritical, lastInc.Severity) assert.NotEmpty(t, lastInc.MITREMapping) // Verify incident is persisted. got, err := svc.GetIncident(lastInc.ID) require.NoError(t, err) assert.Equal(t, lastInc.ID, got.ID) } // --- E2E: Zero-G Mode Excludes Playbook Auto-Response --- func TestE2E_ZeroGExcludedFromAutoResponse(t *testing.T) { svc := newTestServiceWithLogger(t) // Set up a test webhook server to track playbook webhook notifications. var mu sync.Mutex var webhookCalls int ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() webhookCalls++ mu.Unlock() w.WriteHeader(http.StatusOK) })) defer ts.Close() svc.SetWebhookConfig(WebhookConfig{ Endpoints: []string{ts.URL + "/webhook"}, MaxRetries: 1, TimeoutSec: 5, }) // Ingest jailbreak + tool_abuse with ZeroGMode=true. // This should trigger correlation (incident created) but NOT playbooks. evt1 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityHigh, "jailbreak", "zero-g jailbreak test") evt1.SensorID = "sensor-zg" evt1.ZeroGMode = true _, _, err := svc.IngestEvent(evt1) require.NoError(t, err) evt2 := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityCritical, "tool_abuse", "zero-g tool abuse test") evt2.SensorID = "sensor-zg" evt2.ZeroGMode = true _, inc, err := svc.IngestEvent(evt2) require.NoError(t, err) // Correlation should still run — incident should be created. if inc != nil { assert.Equal(t, domsoc.SeverityCritical, inc.Severity) // Wait for any async webhook goroutines. time.Sleep(200 * time.Millisecond) // Webhook should NOT have been called (playbook skipped for Zero-G). mu.Lock() assert.Equal(t, 0, webhookCalls, "webhooks should NOT fire for Zero-G events — playbook must be skipped") mu.Unlock() } // Verify decision log records the PLAYBOOK_SKIPPED:ZERO_G entry. logPath := svc.DecisionLogPath() if logPath != "" { valid, broken, err := audit.VerifyChainFromFile(logPath) require.NoError(t, err) assert.Equal(t, 0, broken, "decision chain should be intact") assert.Greater(t, valid, 0, "should have decision entries") } } // --- E2E: Decision Logger Tamper Detection --- func TestE2E_DecisionLoggerTampering(t *testing.T) { svc := newTestServiceWithLogger(t) // Ingest several events to build up a decision chain. for i := 0; i < 10; i++ { evt := domsoc.NewSOCEvent( domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", fmt.Sprintf("tamper test event %d", i), ) evt.SensorID = "sensor-tamper" _, _, err := svc.IngestEvent(evt) require.NoError(t, err) } // Step 1: Verify chain is valid. logPath := svc.DecisionLogPath() require.NotEmpty(t, logPath, "decision log path should be set") validCount, brokenLine, err := audit.VerifyChainFromFile(logPath) require.NoError(t, err) assert.Equal(t, 0, brokenLine, "chain should be intact before tampering") assert.GreaterOrEqual(t, validCount, 10, "should have at least 10 decision entries") // Step 2: Tamper with the log file — modify a line mid-chain. data, err := os.ReadFile(logPath) require.NoError(t, err) lines := bytes.Split(data, []byte("\n")) if len(lines) > 5 { // Corrupt line 5 by altering content. lines[4] = []byte("TAMPERED|2026-01-01T00:00:00Z|SOC|FAKE|fake_reason|0000000000") err = os.WriteFile(logPath, bytes.Join(lines, []byte("\n")), 0644) require.NoError(t, err) // Step 3: Verify chain detects the tamper. _, brokenLine2, err2 := audit.VerifyChainFromFile(logPath) require.NoError(t, err2) assert.Greater(t, brokenLine2, 0, "chain should detect tampering — broken line reported") } } // --- E2E: Cross-Sensor Session Correlation (SOC-CR-011) --- func TestE2E_CrossSensorSessionCorrelation(t *testing.T) { svc := newTestServiceWithLogger(t) // SOC-CR-011 requires 3+ events from different sensors with same session_id. sessionID := "session-xsensor-e2e-001" sources := []struct { source domsoc.EventSource sensor string category string }{ {domsoc.SourceShield, "sensor-shield-1", "auth_bypass"}, {domsoc.SourceSentinelCore, "sensor-core-1", "jailbreak"}, {domsoc.SourceImmune, "sensor-immune-1", "exfiltration"}, } var lastInc *domsoc.Incident for _, s := range sources { evt := domsoc.NewSOCEvent(s.source, domsoc.SeverityHigh, s.category, "cross-sensor test: "+s.category) evt.SensorID = s.sensor evt.SessionID = sessionID _, inc, err := svc.IngestEvent(evt) require.NoError(t, err) if inc != nil { lastInc = inc } } // After 3 events from different sensors/sources with same session_id, // at least one correlation rule should have matched. require.NotNil(t, lastInc, "cross-sensor session attack (3 sources, same session_id) should create incident") assert.NotEmpty(t, lastInc.ID) assert.NotEmpty(t, lastInc.Events, "incident should reference triggering events") } // --- E2E: Crescendo Escalation (SOC-CR-015) --- func TestE2E_CrescendoEscalation(t *testing.T) { svc := newTestServiceWithLogger(t) // SOC-CR-015: 3+ jailbreak events with ascending severity within 15 min. severities := []domsoc.EventSeverity{ domsoc.SeverityLow, domsoc.SeverityMedium, domsoc.SeverityHigh, } var lastInc *domsoc.Incident for i, sev := range severities { evt := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, sev, "jailbreak", fmt.Sprintf("crescendo jailbreak attempt %d", i+1)) evt.SensorID = "sensor-crescendo" _, inc, err := svc.IngestEvent(evt) require.NoError(t, err) if inc != nil { lastInc = inc } } // The ascending severity pattern (LOW→MEDIUM→HIGH) should trigger SOC-CR-015. require.NotNil(t, lastInc, "crescendo pattern (LOW→MEDIUM→HIGH jailbreaks) should create incident") assert.Equal(t, domsoc.SeverityCritical, lastInc.Severity) assert.Contains(t, lastInc.MITREMapping, "T1059") }