mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-24 20:06:21 +02:00
211 lines
6.7 KiB
Go
211 lines
6.7 KiB
Go
package soc
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
domsoc "github.com/syntrex-lab/gomcp/internal/domain/soc"
|
|
"github.com/syntrex-lab/gomcp/internal/infrastructure/sqlite"
|
|
)
|
|
|
|
// newTestService creates a SOC service backed by in-memory SQLite, without a decision logger.
|
|
func newTestService(t *testing.T) *Service {
|
|
t.Helper()
|
|
db, err := sqlite.OpenMemory()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
repo, err := sqlite.NewSOCRepo(db)
|
|
require.NoError(t, err)
|
|
|
|
return NewService(repo, nil)
|
|
}
|
|
|
|
// --- Rate Limiting Tests (§17.3, §18.2 PB-05) ---
|
|
|
|
func TestIsRateLimited_UnderLimit(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
// 100 events should NOT trigger rate limit.
|
|
for i := 0; i < 100; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "rate test")
|
|
event.ID = fmt.Sprintf("evt-under-%d", i) // Unique ID
|
|
event.SensorID = "sensor-A"
|
|
_, _, err := svc.IngestEvent(event)
|
|
require.NoError(t, err, "event %d should not be rate limited", i+1)
|
|
}
|
|
}
|
|
|
|
func TestIsRateLimited_OverLimit(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
// Send 101 events — the 101st should be rate limited.
|
|
for i := 0; i < MaxEventsPerSecondPerSensor; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityLow, "test", "rate test")
|
|
event.ID = fmt.Sprintf("evt-over-%d", i) // Unique ID
|
|
event.SensorID = "sensor-B"
|
|
_, _, err := svc.IngestEvent(event)
|
|
require.NoError(t, err, "event %d should pass", i+1)
|
|
}
|
|
|
|
// 101st event — should be rejected.
|
|
event := domsoc.NewSOCEvent(domsoc.SourceShield, domsoc.SeverityLow, "test", "overflow")
|
|
event.ID = "evt-over-101"
|
|
event.SensorID = "sensor-B"
|
|
_, _, err := svc.IngestEvent(event)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "rate limit exceeded")
|
|
assert.Contains(t, err.Error(), "sensor-B")
|
|
}
|
|
|
|
func TestIsRateLimited_DifferentSensors(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
// 100 events from sensor-C.
|
|
for i := 0; i < MaxEventsPerSecondPerSensor; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceGoMCP, domsoc.SeverityLow, "test", "sensor C")
|
|
event.ID = fmt.Sprintf("evt-diff-C-%d", i) // Unique ID
|
|
event.SensorID = "sensor-C"
|
|
_, _, err := svc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// sensor-D should still accept events (independent rate limiter).
|
|
event := domsoc.NewSOCEvent(domsoc.SourceGoMCP, domsoc.SeverityLow, "test", "sensor D")
|
|
event.ID = "evt-diff-D-0"
|
|
event.SensorID = "sensor-D"
|
|
_, _, err := svc.IngestEvent(event)
|
|
require.NoError(t, err, "sensor-D should not be affected by sensor-C rate limit")
|
|
}
|
|
|
|
func TestIsRateLimited_FallsBackToSource(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
// When SensorID is empty, should use Source as key.
|
|
for i := 0; i < MaxEventsPerSecondPerSensor; i++ {
|
|
event := domsoc.NewSOCEvent(domsoc.SourceExternal, domsoc.SeverityLow, "test", "no sensor id")
|
|
event.ID = fmt.Sprintf("evt-fb-%d", i) // Unique ID
|
|
_, _, err := svc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// 101st from same source — should be limited.
|
|
event := domsoc.NewSOCEvent(domsoc.SourceExternal, domsoc.SeverityLow, "test", "overflow no sensor")
|
|
event.ID = "evt-fb-101"
|
|
_, _, err := svc.IngestEvent(event)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "rate limit exceeded")
|
|
}
|
|
|
|
// --- Compliance Report Tests (§12.3) ---
|
|
|
|
func TestComplianceReport_GeneratesReport(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
report, err := svc.ComplianceReport()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, report)
|
|
|
|
assert.Equal(t, "EU AI Act Article 15", report.Framework)
|
|
assert.NotEmpty(t, report.Requirements)
|
|
assert.Len(t, report.Requirements, 6) // 15.1 through 15.6
|
|
|
|
// Without a decision logger, chain is invalid → 15.2/15.4 are NON_COMPLIANT.
|
|
// With NON_COMPLIANT present, overall is NON_COMPLIANT.
|
|
// 15.5 Transparency is always PARTIAL.
|
|
foundPartial := false
|
|
for _, r := range report.Requirements {
|
|
if r.Status == "PARTIAL" {
|
|
foundPartial = true
|
|
assert.NotEmpty(t, r.Gap)
|
|
}
|
|
}
|
|
assert.True(t, foundPartial, "should have at least one PARTIAL requirement")
|
|
|
|
// Overall should be NON_COMPLIANT because no Decision Logger → chain invalid.
|
|
assert.Equal(t, "NON_COMPLIANT", report.Overall)
|
|
}
|
|
|
|
// --- RunPlaybook Tests (§10, §12.1) ---
|
|
|
|
func TestRunPlaybook_NotFound(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
_, err := svc.RunPlaybook("nonexistent-pb", "inc-123")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "playbook not found")
|
|
}
|
|
|
|
func TestRunPlaybook_IncidentNotFound(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
// Use a valid playbook ID from defaults.
|
|
_, err := svc.RunPlaybook("pb-block-jailbreak", "nonexistent-inc")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "incident not found")
|
|
}
|
|
|
|
// --- Secret Scanner Integration Tests (§5.4) ---
|
|
|
|
func TestSecretScanner_RejectsSecrets(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
event := domsoc.NewSOCEvent(domsoc.SourceExternal, domsoc.SeverityMedium, "test", "test event")
|
|
event.Payload = "my API key is AKIA1234567890ABCDEF" // AWS-style key
|
|
_, _, err := svc.IngestEvent(event)
|
|
if err != nil {
|
|
// If ScanForSecrets detected it, we expect rejection.
|
|
assert.Contains(t, err.Error(), "secret scanner rejected")
|
|
}
|
|
// If no secrets detected (depends on oracle implementation), event passes.
|
|
}
|
|
|
|
func TestSecretScanner_AllowsClean(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
event := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityLow, "test", "clean event")
|
|
event.Payload = "this is a normal log message with no secrets"
|
|
id, _, err := svc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, id)
|
|
}
|
|
|
|
// --- Zero-G Mode Tests (§13.4) ---
|
|
|
|
func TestZeroGMode_SkipsPlaybook(t *testing.T) {
|
|
svc := newTestService(t)
|
|
|
|
event := domsoc.NewSOCEvent(domsoc.SourceSentinelCore, domsoc.SeverityCritical, "jailbreak", "zero-g test")
|
|
event.ZeroGMode = true
|
|
id, _, err := svc.IngestEvent(event)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, id)
|
|
}
|
|
|
|
// --- Helper tests ---
|
|
|
|
func TestBoolToCompliance(t *testing.T) {
|
|
assert.Equal(t, "COMPLIANT", boolToCompliance(true))
|
|
assert.Equal(t, "NON_COMPLIANT", boolToCompliance(false))
|
|
}
|
|
|
|
func TestOverallStatus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
reqs []ComplianceRequirement
|
|
want string
|
|
}{
|
|
{"all compliant", []ComplianceRequirement{{Status: "COMPLIANT"}, {Status: "COMPLIANT"}}, "COMPLIANT"},
|
|
{"one partial", []ComplianceRequirement{{Status: "COMPLIANT"}, {Status: "PARTIAL"}}, "PARTIAL"},
|
|
{"one non-compliant", []ComplianceRequirement{{Status: "COMPLIANT"}, {Status: "NON_COMPLIANT"}}, "NON_COMPLIANT"},
|
|
{"non-compliant wins", []ComplianceRequirement{{Status: "PARTIAL"}, {Status: "NON_COMPLIANT"}}, "NON_COMPLIANT"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, overallStatus(tt.reqs))
|
|
})
|
|
}
|
|
}
|