All checks were successful
release-tag / release-image (push) Successful in 2m43s
274 lines
16 KiB
Go
274 lines
16 KiB
Go
package engine
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/local/glpi-neural-brain/internal/config"
|
||
"github.com/local/glpi-neural-brain/internal/model"
|
||
)
|
||
|
||
func TestDetectArticleFreshnessNeedStaticTopicDoesNotForceWeb(t *testing.T) {
|
||
plan := model.ArticlePlanDecision{ExpectedValue: "Least Privilege in Windows-Netzwerken konfigurieren", ArticleType: "how_to"}
|
||
relation := model.RelationDecision{TopicLabel: "Least Privilege", Keywords: []string{"Windows", "Berechtigungen"}}
|
||
got := detectArticleFreshnessNeed(plan, relation, []articleSource{{Node: model.Node{Label: "Least Privilege Grundlagen"}}})
|
||
if got.Required {
|
||
t.Fatalf("static configuration topic must not force web research: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestDetectArticleFreshnessNeedCurrentVersionForcesWeb(t *testing.T) {
|
||
plan := model.ArticlePlanDecision{ExpectedValue: "Aktuell unterstützte Kubernetes Versionen und Supportstatus"}
|
||
got := detectArticleFreshnessNeed(plan, model.RelationDecision{}, nil)
|
||
if !got.Required || len(got.Queries) == 0 {
|
||
t.Fatalf("freshness-sensitive topic should trigger focused web research: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestEffectiveArticleResearchStrategyAutoFollowsProcessingMode(t *testing.T) {
|
||
e := &Engine{Cfg: config.Config{ArticleResearchStrategy: "auto"}}
|
||
e.runtime.ProcessingMode = "clustered"
|
||
if got := e.effectiveArticleResearchStrategy(); got != "adaptive" {
|
||
t.Fatalf("clustered auto strategy=%q", got)
|
||
}
|
||
e.runtime.ProcessingMode = "precise"
|
||
if got := e.effectiveArticleResearchStrategy(); got != "always" {
|
||
t.Fatalf("precise auto strategy=%q", got)
|
||
}
|
||
}
|
||
|
||
func TestNormalizeArticleContentClearsUnusedResearchQueries(t *testing.T) {
|
||
got := normalizeArticleContent(model.KnowledgeArticleContent{ResearchNeeded: false, ResearchQueries: []string{"current version"}, ResearchReason: "not needed"})
|
||
if len(got.ResearchQueries) != 0 {
|
||
t.Fatalf("unused author research queries must be cleared: %#v", got.ResearchQueries)
|
||
}
|
||
}
|
||
|
||
func TestArticleSourceFingerprintChangesWithContent(t *testing.T) {
|
||
plan := model.ArticlePlanDecision{Action: "update", TargetArticleID: "target", ArticleType: "how_to"}
|
||
a := []articleSource{{Node: model.Node{ID: "A"}, Content: "old content"}}
|
||
b := []articleSource{{Node: model.Node{ID: "A"}, Content: "new content"}}
|
||
if articleSourceFingerprint(a, plan, nil, "test-pipeline") == articleSourceFingerprint(b, plan, nil, "test-pipeline") {
|
||
t.Fatal("source fingerprint must change when source content changes")
|
||
}
|
||
}
|
||
|
||
func TestReviewReferenceUsesExactReviewerSubset(t *testing.T) {
|
||
full := []model.ResearchResult{
|
||
{Title: "weak", URL: "https://a.example/weak", Relevance: .1, SourceQualityScore: .1},
|
||
{Title: "best", URL: "https://b.example/best", Relevance: .99, SourceQualityScore: .99, Fetched: true},
|
||
{Title: "other", URL: "https://c.example/other", Relevance: .8, SourceQualityScore: .8, Fetched: true},
|
||
}
|
||
subset := selectReviewEvidence(full, 2)
|
||
if len(subset) != 2 || subset[0].URL != "https://b.example/best" {
|
||
t.Fatalf("unexpected reviewer subset: %#v", subset)
|
||
}
|
||
grounded := reviewedResearchEvidence(subset, []model.ArticleClaimReview{{Claim: "x", Verdict: "supported", SourceRefs: []string{"R1"}}})
|
||
if len(grounded) != 1 || grounded[0].URL != "https://b.example/best" {
|
||
t.Fatalf("R1 must resolve against the exact reviewer subset, got %#v", grounded)
|
||
}
|
||
}
|
||
|
||
func TestClusterPendingArticleCandidatesGroupsSharedSeed(t *testing.T) {
|
||
shared := model.Node{ID: "shared", Label: "Backup Repository", Categories: []string{"Backup"}}
|
||
a := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "a", Label: "Ransomware Schutz"}}, Relation: model.RelationDecision{TopicLabel: "Backup Hardening"}}
|
||
b := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "b", Label: "Immutable Backup"}}, Relation: model.RelationDecision{TopicLabel: "Backup Resilience"}}
|
||
clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b})
|
||
if len(clusters) != 1 || len(clusters[0]) != 2 {
|
||
t.Fatalf("shared seed should produce one article cluster: %#v", clusters)
|
||
}
|
||
}
|
||
|
||
func TestClusterPendingArticleCandidatesDoesNotLetSharedGenericSeedBypassTopicGuard(t *testing.T) {
|
||
shared := model.Node{ID: "shared", Label: "Security-Handbuch Übersicht"}
|
||
a := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "water", Label: "Water Leak Detection"}}, Relation: model.RelationDecision{TopicLabel: "Water Leak Detection"}}
|
||
b := &pendingArticleCandidate{Seeds: []model.Node{shared, {ID: "bgp", Label: "BGP Prefix Filtering"}}, Relation: model.RelationDecision{TopicLabel: "BGP Prefix Filtering"}}
|
||
clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b})
|
||
if len(clusters) != 2 {
|
||
t.Fatalf("shared generic seed must not bypass strict topic guard, got %#v", clusters)
|
||
}
|
||
}
|
||
|
||
func TestArticleWorkFingerprintChangesWhenGroundedResearchChanges(t *testing.T) {
|
||
sources := []articleSource{{Node: model.Node{ID: "A"}, Content: "stable internal"}}
|
||
relation := model.RelationDecision{RelationType: "same_topic", TopicLabel: "Backup Hardening", Keywords: []string{"backup"}}
|
||
a := articleWorkFingerprint(sources, relation, []model.ResearchResult{{URL: "https://example.test/a", Content: "old external evidence"}}, "test-pipeline")
|
||
b := articleWorkFingerprint(sources, relation, []model.ResearchResult{{URL: "https://example.test/a", Content: "new external evidence"}}, "test-pipeline")
|
||
if a == b {
|
||
t.Fatal("new grounded research must invalidate the work fingerprint")
|
||
}
|
||
}
|
||
|
||
func TestClusterPendingArticleCandidatesDoesNotMergeSecurityBoilerplateTopics(t *testing.T) {
|
||
values := []*pendingArticleCandidate{
|
||
{Seeds: []model.Node{{ID: "water-a", Label: "Water Leak Detection – präventiv absichern"}, {ID: "water-b", Label: "Water Leak Detection – Vorfälle erkennen und untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "Water Leak Detection Sicherheitsmaßnahmen"}},
|
||
{Seeds: []model.Node{{ID: "triple-a", Label: "Triple Extortion Risiko – präventiv und resilient gestalten"}, {ID: "triple-b", Label: "Triple Extortion Risiko – incident-forensisch untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "Triple Extortion Risiko Sicherheitsmaßnahmen"}},
|
||
{Seeds: []model.Node{{ID: "bgp-a", Label: "BGP Prefix Filtering – sicher gestalten und härten"}, {ID: "bgp-b", Label: "BGP Prefix Filtering – bei Sicherheitsvorfällen untersuchen"}}, Relation: model.RelationDecision{TopicLabel: "BGP Prefix Filtering Sicherheitsmaßnahmen"}},
|
||
}
|
||
clusters := clusterPendingArticleCandidates(values)
|
||
if len(clusters) != 3 {
|
||
t.Fatalf("unrelated template-heavy security topics must remain separate, got %d clusters: %#v", len(clusters), clusters)
|
||
}
|
||
}
|
||
|
||
func TestClusterPendingArticleCandidatesMergesStrongTopicOverlap(t *testing.T) {
|
||
a := &pendingArticleCandidate{Seeds: []model.Node{{ID: "a1", Label: "AI Security Guardrails – härten"}}, Relation: model.RelationDecision{TopicLabel: "AI Security Guardrails"}}
|
||
b := &pendingArticleCandidate{Seeds: []model.Node{{ID: "b1", Label: "AI Safety Guardrails – überwachen"}}, Relation: model.RelationDecision{TopicLabel: "AI Safety Guardrails"}}
|
||
clusters := clusterPendingArticleCandidates([]*pendingArticleCandidate{a, b})
|
||
if len(clusters) != 1 {
|
||
t.Fatalf("strong topic/entity overlap should still batch, got %#v", clusters)
|
||
}
|
||
}
|
||
|
||
func TestArticleDraftTopicConflictGroupsRejectsObservedWaterLeakMix(t *testing.T) {
|
||
draft := model.KnowledgeArticleDraft{Title: "Water Leak Detection in der IT-Security"}
|
||
sources := []articleSource{
|
||
{Node: model.Node{ID: "w1", Label: "Water Leak Detection – präventiv absichern"}},
|
||
{Node: model.Node{ID: "w2", Label: "Water Leak Detection – Vorfälle erkennen und untersuchen"}},
|
||
{Node: model.Node{ID: "t1", Label: "Triple Extortion Risiko – präventiv und resilient gestalten"}},
|
||
{Node: model.Node{ID: "t2", Label: "Triple Extortion Risiko – incident-forensisch untersuchen"}},
|
||
{Node: model.Node{ID: "b1", Label: "BGP Prefix Filtering – sicher entwerfen und härten"}},
|
||
{Node: model.Node{ID: "b2", Label: "BGP Prefix Filtering – bei Sicherheitsvorfällen untersuchen"}},
|
||
}
|
||
conflicts := articleDraftTopicConflictGroups(draft, sources)
|
||
if len(conflicts) != 2 {
|
||
t.Fatalf("expected Triple Extortion and BGP conflict groups, got %#v", conflicts)
|
||
}
|
||
}
|
||
|
||
func TestArticleDraftTopicConflictGroupsAllowsSingleSupportingOutlier(t *testing.T) {
|
||
draft := model.KnowledgeArticleDraft{Title: "HAProxy Hardening und Überwachung"}
|
||
sources := []articleSource{
|
||
{Node: model.Node{ID: "h1", Label: "HAProxy Hardening – präventiv absichern"}},
|
||
{Node: model.Node{ID: "h2", Label: "HAProxy Hardening – forensisch untersuchen"}},
|
||
{Node: model.Node{ID: "tls", Label: "TLS Cipher Suites Referenz"}},
|
||
}
|
||
if conflicts := articleDraftTopicConflictGroups(draft, sources); len(conflicts) != 0 {
|
||
t.Fatalf("one supporting outlier must not reject a coherent article: %#v", conflicts)
|
||
}
|
||
}
|
||
|
||
func TestArticleTopicTermsIgnoreTemplatedSuffix(t *testing.T) {
|
||
a := articleTopicTermsFromText("Rate Limit Testing – Ergebnisse verifizieren")
|
||
b := articleTopicTermsFromText("Purple Team Lessons Learned – Ergebnisse verifizieren")
|
||
if a["ergebnisse"] || a["verifizieren"] || b["ergebnisse"] || b["verifizieren"] {
|
||
t.Fatalf("templated suffix leaked into topic terms: a=%v b=%v", a, b)
|
||
}
|
||
if boolSetIntersectionSize(a, b) != 0 {
|
||
t.Fatalf("unrelated topics became related through suffix terms: a=%v b=%v", a, b)
|
||
}
|
||
}
|
||
|
||
func TestFilterTopicCoherentArticleSourcesKeepsOnlyOneSupportingOutlier(t *testing.T) {
|
||
sources := []articleSource{
|
||
{Node: model.Node{ID: "r1", Label: "Rate Limit Testing – planen"}, Score: 10},
|
||
{Node: model.Node{ID: "r2", Label: "Rate Limit Testing – verifizieren"}, Score: 9},
|
||
{Node: model.Node{ID: "w1", Label: "Web Security Testing – planen"}, Score: 8.5},
|
||
{Node: model.Node{ID: "p1", Label: "Purple Team Lessons Learned – planen"}, Score: 8},
|
||
{Node: model.Node{ID: "s1", Label: "Security Test Reporting – dokumentieren"}, Score: 7},
|
||
}
|
||
kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"})
|
||
if len(kept) != 3 || len(removed) != 2 {
|
||
t.Fatalf("expected two topical sources plus one outlier; kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed))
|
||
}
|
||
if kept[2].Node.ID != "w1" || removed[0].Node.ID != "p1" || removed[1].Node.ID != "s1" {
|
||
t.Fatalf("highest-scoring outlier should be retained, kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed))
|
||
}
|
||
}
|
||
|
||
func TestArticleSourceIDJaccardMatchesObservedOverlap(t *testing.T) {
|
||
wanted := map[string]bool{"a": true, "b": true, "c": true, "d": true, "e": true, "f": true, "g": true, "h": true}
|
||
existing := []string{"a", "b", "c", "d", "e", "f", "x", "y"}
|
||
if got := articleSourceIDJaccard(wanted, existing); got != .6 {
|
||
t.Fatalf("expected 0.6 source jaccard, got %.4f", got)
|
||
}
|
||
}
|
||
|
||
func TestArticlePlanTopicTermsPreferMergeTarget(t *testing.T) {
|
||
plan := model.ArticlePlanDecision{Action: "merge", TargetArticleID: "target", ExpectedValue: "Generischer Security-Artikel"}
|
||
sources := []articleSource{
|
||
{Node: model.Node{ID: "target", Label: "Ransomware Containment – im Vorfall erkennen"}},
|
||
{Node: model.Node{ID: "other", Label: "Ransomware Detection – präventiv gestalten"}},
|
||
}
|
||
terms := articlePlanTopicTerms(plan, sources)
|
||
if !terms["ransomware"] || !terms["containment"] || terms["security"] {
|
||
t.Fatalf("merge target should define the core topic, got %v", terms)
|
||
}
|
||
}
|
||
|
||
func TestArticleDirectTopicSourceCountDoesNotCountSupportingOutlier(t *testing.T) {
|
||
sources := []articleSource{
|
||
{Node: model.Node{ID: "r1", Label: "Rate Limit Testing – planen"}},
|
||
{Node: model.Node{ID: "r2", Label: "Rate Limit Testing – verifizieren"}},
|
||
{Node: model.Node{ID: "w1", Label: "Web Security Testing – planen"}},
|
||
{Node: model.Node{ID: "p1", Label: "Purple Team Lessons Learned – planen"}},
|
||
}
|
||
if got := articleDirectTopicSourceCount(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"}); got != 2 {
|
||
t.Fatalf("supporting outlier must not satisfy direct topic evidence, got %d", got)
|
||
}
|
||
}
|
||
|
||
func TestArticlePlanOperationalResearchQueryUsesTopicAndTaskType(t *testing.T) {
|
||
plan := model.ArticlePlanDecision{ArticleType: "how_to", ExpectedValue: "Generischer Plan"}
|
||
got := articlePlanOperationalResearchQuery(plan, model.RelationDecision{TopicLabel: "Rate Limit Testing"})
|
||
if got == "" || got[:18] != "Rate Limit Testing" {
|
||
t.Fatalf("query must start from relation topic, got %q", got)
|
||
}
|
||
trouble := articlePlanOperationalResearchQuery(model.ArticlePlanDecision{ArticleType: "troubleshooting"}, model.RelationDecision{TopicLabel: "Kafka Netzwerkzugriff"})
|
||
if trouble == "" || trouble == got {
|
||
t.Fatalf("troubleshooting query should be task-specific, got %q", trouble)
|
||
}
|
||
}
|
||
|
||
func TestObservedRateLimitClusterIsReducedToDirectTopicEvidence(t *testing.T) {
|
||
sources := []articleSource{
|
||
{Node: model.Node{ID: "rate-verify", Label: "Rate Limit Testing – Ergebnisse verifizieren"}, Score: 10},
|
||
{Node: model.Node{ID: "report", Label: "Security Test Reporting – Ergebnisse verifizieren"}, Score: 9},
|
||
{Node: model.Node{ID: "web-plan", Label: "Web Security Testing – sicher planen und durchführen"}, Score: 8},
|
||
{Node: model.Node{ID: "purple-verify", Label: "Purple Team Lessons Learned – Ergebnisse verifizieren"}, Score: 7},
|
||
{Node: model.Node{ID: "purple-plan", Label: "Purple Team Lessons Learned – sicher planen und durchführen"}, Score: 6},
|
||
{Node: model.Node{ID: "rate-plan", Label: "Rate Limit Testing – sicher planen und durchführen"}, Score: 5},
|
||
{Node: model.Node{ID: "architecture", Label: "Security Architecture Testing – sicher planen und durchführen"}, Score: 4},
|
||
{Node: model.Node{ID: "web-verify", Label: "Web Security Testing – Ergebnisse verifizieren"}, Score: 3},
|
||
}
|
||
kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"})
|
||
if got := articleDirectTopicSourceCount(kept, nil, model.RelationDecision{TopicLabel: "Rate Limit Testing"}); got != 2 {
|
||
t.Fatalf("observed cluster should contain exactly two direct Rate Limit sources after filtering, got %d (%v)", got, nodeIDsFromArticleSources(kept))
|
||
}
|
||
if len(kept) != 3 || len(removed) != 5 {
|
||
t.Fatalf("observed 8-source cluster should become 2 direct + 1 support; kept=%v removed=%v", nodeIDsFromArticleSources(kept), nodeIDsFromArticleSources(removed))
|
||
}
|
||
}
|
||
|
||
func TestFilterTopicCoherentArticleSourcesKeepsRequiredAutonomousSeeds(t *testing.T) {
|
||
sources := []articleSource{
|
||
{Node: model.Node{ID: "a", Kind: "knowledge", Status: "production", Label: "OWASP SAMM Governance"}},
|
||
{Node: model.Node{ID: "b", Kind: "knowledge", Status: "production", Label: "MITRE ATT&CK Mapping"}},
|
||
{Node: model.Node{ID: "c", Kind: "knowledge", Status: "production", Label: "Unrelated Proxmox Hardening"}},
|
||
}
|
||
required := map[string]bool{"a": true, "b": true}
|
||
kept, removed := filterTopicCoherentArticleSources(sources, nil, model.RelationDecision{TopicLabel: "Frameworks & Standards / Security Framework / Standard"}, required)
|
||
if len(kept) != 3 {
|
||
t.Fatalf("required seeds plus at most one supporting source should remain, got kept=%#v removed=%#v", kept, removed)
|
||
}
|
||
ids := map[string]bool{}
|
||
for _, source := range kept {
|
||
ids[source.Node.ID] = true
|
||
}
|
||
if !ids["a"] || !ids["b"] {
|
||
t.Fatalf("required autonomous seeds must survive topic filtering: %#v", ids)
|
||
}
|
||
}
|
||
|
||
func TestMergeRequiredArticleSourceIDsPrependsOpportunitySeeds(t *testing.T) {
|
||
got := mergeRequiredArticleSourceIDs([]string{"c", "a"}, []string{"a", "b", "c", "d"}, map[string]bool{"a": true, "b": true})
|
||
want := []string{"a", "b", "c"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("unexpected merged ids: %#v", got)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Fatalf("required source ordering mismatch: got %#v want %#v", got, want)
|
||
}
|
||
}
|
||
}
|