Files
glpi-neural-brain/internal/engine/source_inbox_test.go
jbergner 440423c5b6
All checks were successful
release-tag / release-image (push) Successful in 2m43s
RC-3
2026-08-09 11:29:13 +02:00

131 lines
7.5 KiB
Go

package engine
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/local/glpi-neural-brain/internal/config"
"github.com/local/glpi-neural-brain/internal/model"
"github.com/local/glpi-neural-brain/internal/ollama"
"github.com/local/glpi-neural-brain/internal/sourceagent"
)
func testInboxEngine() *Engine {
return &Engine{Cfg: config.Config{SourceInboxMinSimilarity: .55, SourceInboxMinPriority: .55, SourceInboxNoveltyFloor: .35, SourceInboxSecurityMinPriority: .58}}
}
func TestSourceInboxSecurityAlertCanBecomeCandidateBelowSemanticThreshold(t *testing.T) {
now := time.Date(2026, 8, 7, 20, 0, 0, 0, time.UTC)
item := sourceagent.InboxDocument{Document: sourceagent.Document{Title: "Veeam One für Schadcode-Attacken anfällig", PublishedAt: now.Add(-2 * time.Hour), SourceName: "heise Security - Nur die Alerts", Categories: []string{"Security", "Alerts"}}, ReceivedAt: now}
got := testInboxEngine().classifySourceInboxItem(item, .49, "kb-veeam", now)
if got.Status != "candidate" || got.Priority < .55 || got.Semantic != .49 {
t.Fatalf("curated fresh security alert should remain searchable, got %+v", got)
}
if !got.Security {
t.Fatalf("curated security alert should be marked for proactive processing, got %+v", got)
}
}
func TestSourceInboxNoveltyFloorStillArchivesUnrelatedAlert(t *testing.T) {
now := time.Date(2026, 8, 7, 20, 0, 0, 0, time.UTC)
item := sourceagent.InboxDocument{Document: sourceagent.Document{Title: "Critical Security Advisory for unrelated appliance", PublishedAt: now, SourceName: "Security Alerts", Categories: []string{"Security"}}, ReceivedAt: now}
got := testInboxEngine().classifySourceInboxItem(item, .21, "kb-unrelated", now)
if got.Status != "archived" {
t.Fatalf("very low KB proximity must still be archived, got %+v", got)
}
}
func TestSourceInboxHighSemanticSimilarityRemainsCandidate(t *testing.T) {
now := time.Now().UTC()
item := sourceagent.InboxDocument{Document: sourceagent.Document{Title: "Evergreen Backup Guide"}, ReceivedAt: now.Add(-200 * 24 * time.Hour)}
got := testInboxEngine().classifySourceInboxItem(item, .72, "kb-backup", now)
if got.Status != "candidate" {
t.Fatalf("direct semantic match must remain candidate, got %+v", got)
}
}
func TestSourceInboxSecurityProactiveCanBeDisabledPerTask(t *testing.T) {
now := time.Now().UTC()
item := sourceagent.InboxDocument{Document: sourceagent.Document{Title: "Critical Security Advisory", SourceName: "Security Alerts", Categories: []string{"Security"}}, Metadata: map[string]any{"security_proactive": "false"}, ReceivedAt: now}
got := testInboxEngine().classifySourceInboxItem(item, .62, "kb-security", now)
if got.Status != "candidate" || got.Security {
t.Fatalf("explicit passive task must stay candidate without proactive security processing, got %+v", got)
}
}
func TestSourceInboxSecurityProactiveCanBeForcedPerTask(t *testing.T) {
now := time.Now().UTC()
item := sourceagent.InboxDocument{Document: sourceagent.Document{Title: "Product maintenance note", SourceName: "Vendor Updates", Categories: []string{"Vendor"}}, Metadata: map[string]any{"security_proactive": "true"}, ReceivedAt: now}
got := testInboxEngine().classifySourceInboxItem(item, .61, "kb-product", now)
if got.Status != "candidate" || !got.Security {
t.Fatalf("explicit proactive task must be eligible even without title markers, got %+v", got)
}
}
func TestAssessSecurityInboxUsesSynthesisModelAndSourceBoundSchema(t *testing.T) {
var requestedModel string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/tags":
_, _ = w.Write([]byte(`{"models":[{"name":"gemma3:12b","digest":"gemma"},{"name":"qwen3:8b","digest":"qwen"},{"name":"embeddinggemma","digest":"embed"}]}`))
case "/api/chat":
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
requestedModel, _ = req["model"].(string)
content := `{"materialize":true,"security_relevant":true,"confidence":0.91,"event_type":"vulnerability","severity":"critical","vendor":"Example","products":["Example Server"],"cves":["CVE-2026-1234"],"affected_versions":["1.x"],"fixed_versions":["1.2.3"],"summary":"Eine konkrete Schwachstelle betrifft Example Server.","facts":["Die Quelle nennt CVE-2026-1234 für Example Server."],"recommended_actions":["Auf Version 1.2.3 aktualisieren."],"research_needed":false,"research_queries":[],"reason":"Die Primärquelle enthält konkrete Produkt-, CVE- und Fix-Angaben."}`
_ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"content": content}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := ollama.New(server.URL, "qwen3:8b", "embeddinggemma")
e := &Engine{Cfg: config.Config{ArticleSynthesisModel: "gemma3:12b", ChatModel: "qwen3:8b"}, Ollama: client}
item := sourceagent.InboxDocument{ID: "inbox-1", AgentID: "agent-1", TaskID: "task-1", Relevance: .68, Document: sourceagent.Document{Title: "Critical Example Server vulnerability", URL: "https://example.org/advisory", CanonicalURL: "https://example.org/advisory", PublishedAt: time.Now().UTC(), Text: "The advisory explicitly identifies CVE-2026-1234 and says affected Example Server 1.x installations should update to version 1.2.3."}}
assessment, err := e.assessSecurityInbox(context.Background(), item, model.Node{ID: "kb-1", Label: "Example Server", Summary: "Internal KB context"}, model.ResearchResult{Title: item.Document.Title, URL: item.Document.URL, Content: item.Document.Text}, nil)
if err != nil {
t.Fatal(err)
}
if requestedModel != "gemma3:12b" || !assessment.Materialize || !assessment.SecurityRelevant || assessment.Confidence < .9 || len(assessment.Facts) != 1 || len(assessment.CVEs) != 1 {
t.Fatalf("unexpected security assessment/model: model=%q assessment=%+v", requestedModel, assessment)
}
}
func TestSecurityInboxApplicabilityRejectsRelatedButDifferentProduct(t *testing.T) {
assessment := securityInboxAssessment{Products: []string{"jsoup"}}
matched := model.Node{ID: "kb-csrf", Label: "CSRF Protection", Keywords: []string{"CSRF", "Web Security"}}
applicability, _ := securityInboxDirectApplicability(assessment, matched)
if applicability != "contextual" {
t.Fatalf("jsoup XSS must not become a direct CSRF update, got %q", applicability)
}
assessment = securityInboxAssessment{Products: []string{"Linux Kernel"}}
matched = model.Node{ID: "kb-secureboot", Label: "Secure Boot unter Linux", Keywords: []string{"Secure Boot", "Linux", "UEFI"}}
applicability, _ = securityInboxDirectApplicability(assessment, matched)
if applicability != "contextual" {
t.Fatalf("generic Linux Kernel advisory must not become a direct Secure Boot update, got %q", applicability)
}
}
func TestSecurityInboxApplicabilityAcceptsDirectProductOrCVE(t *testing.T) {
assessment := securityInboxAssessment{Products: []string{"systemd"}}
matched := model.Node{ID: "kb-systemd", Label: "systemd Hardening", Keywords: []string{"systemd", "Linux"}}
applicability, _ := securityInboxDirectApplicability(assessment, matched)
if applicability != "direct" {
t.Fatalf("same product should be direct, got %q", applicability)
}
assessment = securityInboxAssessment{CVEs: []string{"CVE-2026-1234"}, Products: []string{"Example Server"}}
matched = model.Node{ID: "kb-cve", Label: "Example Server CVE-2026-1234", Keywords: []string{"Example Server"}}
applicability, _ = securityInboxDirectApplicability(assessment, matched)
if applicability != "direct" {
t.Fatalf("same CVE should be direct, got %q", applicability)
}
}