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