Files
2026-08-05 19:13:45 +02:00

518 lines
17 KiB
Go

package config
import (
"fmt"
"os"
"testing"
"time"
)
func validConfig() Config {
return Config{
GLPIURL: "https://glpi.internal.example",
GLPIClientID: "client-id",
GLPIClientSecret: "real-secret-value",
GLPIUsername: "svc-agent",
GLPIPassword: "real-password-value",
GLPIAllowedStatusIDs: []int64{1},
WebAllowAnonymous: true,
Workers: 1,
QueueSize: 1,
CategoryConfidence: .9,
ReplyConfidence: .9,
KnowledgeMinScore: .8,
KnowledgeAllowedSources: []string{"internal-kb"},
KnowledgeAutoReplySources: []string{"internal-kb"},
CommunicationLanguage: "de-DE",
CommunicationStyle: "formal",
OllamaTimeout: time.Minute,
OllamaNumPredict: 256,
OllamaKeepAlive: 10 * time.Minute,
OllamaThink: false,
OllamaMaxConcurrent: 1,
}
}
func TestValidateRejectsPlainHTTPByDefault(t *testing.T) {
c := validConfig()
c.GLPIURL = "http://glpi.internal.example"
if err := c.Validate(); err == nil {
t.Fatal("expected plain HTTP to be rejected")
}
c.GLPIAllowInsecureHTTP = true
if err := c.Validate(); err != nil {
t.Fatalf("explicit insecure override should validate: %v", err)
}
}
func TestValidateRejectsPlaceholderSecrets(t *testing.T) {
c := validConfig()
c.GLPIClientSecret = "CHANGE_ME"
if err := c.Validate(); err == nil {
t.Fatal("expected placeholder secret to be rejected")
}
}
func TestValidateRequiresAllowedStatus(t *testing.T) {
c := validConfig()
c.GLPIAllowedStatusIDs = nil
if err := c.Validate(); err == nil {
t.Fatal("expected empty status whitelist to be rejected")
}
}
func TestValidateRejectsAutoReplySourceOutsideAllowlist(t *testing.T) {
c := validConfig()
c.KnowledgeAutoReplySources = []string{"vendor-docs"}
if err := c.Validate(); err == nil {
t.Fatal("expected auto-reply source outside allowlist to be rejected")
}
}
func TestValidateRejectsUnknownCommunicationStyle(t *testing.T) {
c := validConfig()
c.CommunicationStyle = "super-friendly"
if err := c.Validate(); err == nil {
t.Fatal("expected unsupported communication style to be rejected")
}
}
func TestValidateRequiresMajorIncidentFilter(t *testing.T) {
c := validConfig()
c.ContextEnabled = true
c.ContextTimeout = 1
c.MajorIncidentsEnabled = true
c.GLPIMajorIncidentLimit = 20
if err := c.Validate(); err == nil {
t.Fatal("expected major incident filter to be required")
}
}
func TestValidateUptimeKumaMetricsRequiresAPIKey(t *testing.T) {
c := validConfig()
c.ContextEnabled = true
c.ContextTimeout = 1
c.UptimeKumaEnabled = true
c.UptimeKumaURL = "https://uptime.internal.example"
c.UptimeKumaMode = "metrics"
c.UptimeKumaMaxIssues = 20
if err := c.Validate(); err == nil {
t.Fatal("expected Uptime Kuma API key to be required in metrics mode")
}
c.UptimeKumaAPIKey = "test-api-key"
if err := c.Validate(); err != nil {
t.Fatalf("expected metrics mode with API key to validate: %v", err)
}
}
func TestValidateUserDeviceFilterRequiresUserPlaceholder(t *testing.T) {
c := validConfig()
c.ContextEnabled = true
c.ContextTimeout = 1
c.UserDeviceContextEnabled = true
c.GLPIUserDevicePaths = []string{"/Assets/Computer"}
c.GLPIUserDeviceFilterTemplate = "user.id==42"
c.GLPIUserDeviceLimit = 20
if err := c.Validate(); err == nil {
t.Fatal("expected user device filter template to require {{user_id}}")
}
}
func TestKnowledgeWebEditRequiresAuthentication(t *testing.T) {
c := validConfig()
c.KnowledgeWebEditEnabled = true
c.WebAllowAnonymous = true
if err := c.Validate(); err == nil {
t.Fatal("expected anonymous KB editing to be rejected")
}
}
func TestValidateGLPIKBRequiresAllowedSource(t *testing.T) {
c := validConfig()
c.GLPIKBEnabled = true
c.GLPIKBPath = "auto"
c.GLPIKBLimit = 100
c.GLPIKBSyncInterval = 10 * time.Minute
c.GLPIKBSource = "glpi-kb"
if err := c.Validate(); err == nil {
t.Fatal("expected GLPI KB source outside allowlist to be rejected")
}
c.KnowledgeAllowedSources = []string{"internal-kb", "glpi-kb"}
if err := c.Validate(); err != nil {
t.Fatalf("expected GLPI KB config to validate: %v", err)
}
}
func TestValidateGLPIKBAutoReplyRequiresCategoryWhitelist(t *testing.T) {
c := validConfig()
c.GLPIKBEnabled = true
c.GLPIKBPath = "auto"
c.GLPIKBLimit = 100
c.GLPIKBSyncInterval = 10 * time.Minute
c.GLPIKBSource = "glpi-kb"
c.KnowledgeAllowedSources = []string{"internal-kb", "glpi-kb"}
c.KnowledgeAutoReplySources = []string{"internal-kb", "glpi-kb"}
c.GLPIKBAutoReply = true
if err := c.Validate(); err == nil {
t.Fatal("expected an auto reply category whitelist to be required")
}
c.GLPIKBAutoReplyCategoryIDs = []int64{3}
if err := c.Validate(); err != nil {
t.Fatalf("expected explicit category whitelist to validate: %v", err)
}
c.GLPIKBAutoReplyCategoryIDs = nil
c.GLPIKBAutoReplyITILCategoryIDs = []int64{38}
if err := c.Validate(); err == nil {
t.Fatal("expected legacy ITIL category list not to approve GLPI KB auto reply")
}
c.GLPIKBAutoReplyAllowUncategorized = true
c.GLPIKBAutoReplyUncategorizedArticleIDs = []int64{1}
if err := c.Validate(); err != nil {
t.Fatalf("expected explicit uncategorized article allowlist to validate: %v", err)
}
}
func TestValidateRejectsInvalidKnowledgeScoringConfig(t *testing.T) {
c := validConfig()
c.KnowledgeSemanticWeight = 1.2
if err := c.Validate(); err == nil {
t.Fatal("expected knowledge weight > 1 to be rejected")
}
c = validConfig()
c.KnowledgeChunkWords = 100
c.KnowledgeChunkOverlapWords = 100
if err := c.Validate(); err == nil {
t.Fatal("expected overlap >= chunk size to be rejected")
}
}
func TestValidateKnowledgeCategoryModeTrimsWhitespace(t *testing.T) {
c := validConfig()
c.KnowledgeCategoryMode = " UNSCOPED "
if err := c.Validate(); err != nil {
t.Fatalf("expected whitespace/case normalized category mode to validate: %v", err)
}
}
func TestEnvNormalizedLowerDefaultsBlankOrWhitespace(t *testing.T) {
t.Setenv("KNOWLEDGE_CATEGORY_MODE", " ")
if got := envNormalizedLower("KNOWLEDGE_CATEGORY_MODE", "unscoped"); got != "unscoped" {
t.Fatalf("expected unscoped default, got %q", got)
}
t.Setenv("KNOWLEDGE_CATEGORY_MODE", " STRICT ")
if got := envNormalizedLower("KNOWLEDGE_CATEGORY_MODE", "unscoped"); got != "strict" {
t.Fatalf("expected strict, got %q", got)
}
}
func TestKnowledgeIndexSourcesIncludesCategoryOnlySources(t *testing.T) {
c := validConfig()
c.KnowledgeAllowedSources = []string{"internal-kb", "glpi-kb"}
c.KnowledgeCategorySources = []string{"internal-category", "INTERNAL-KB"}
got := c.KnowledgeIndexSources()
want := []string{"internal-kb", "glpi-kb", "internal-category"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("index sources=%v want=%v", got, want)
}
}
func TestValidateAllowsCategoryOnlyGLPIKBSourceWithoutAutoReply(t *testing.T) {
c := validConfig()
c.GLPIKBEnabled = true
c.GLPIKBPath = "auto"
c.GLPIKBLimit = 100
c.GLPIKBSyncInterval = 10 * time.Minute
c.GLPIKBSource = "category-sync"
c.KnowledgeCategorySources = []string{"category-sync"}
if err := c.Validate(); err != nil {
t.Fatalf("category-only GLPI KB source should validate: %v", err)
}
c.GLPIKBAutoReply = true
c.GLPIKBAutoReplyCategoryIDs = []int64{3}
if err := c.Validate(); err == nil {
t.Fatal("category-only GLPI KB source must not be allowed for auto-reply")
}
}
func TestLoadDefaultsCategorySourcesToAllowedSources(t *testing.T) {
for key, value := range map[string]string{
"GLPI_URL": "https://glpi.internal.example",
"GLPI_CLIENT_ID": "client-id",
"GLPI_CLIENT_SECRET": "real-secret-value",
"GLPI_USERNAME": "svc-agent",
"GLPI_PASSWORD": "real-password-value",
"WEB_ALLOW_ANONYMOUS": "true",
"KNOWLEDGE_ALLOWED_SOURCES": "internal-kb,vendor-docs",
} {
t.Setenv(key, value)
}
old, existed := os.LookupEnv("KNOWLEDGE_CATEGORY_SOURCES")
if err := os.Unsetenv("KNOWLEDGE_CATEGORY_SOURCES"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if existed {
_ = os.Setenv("KNOWLEDGE_CATEGORY_SOURCES", old)
} else {
_ = os.Unsetenv("KNOWLEDGE_CATEGORY_SOURCES")
}
})
c, err := Load()
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(c.KnowledgeCategorySources) != "[internal-kb vendor-docs]" {
t.Fatalf("category sources=%v", c.KnowledgeCategorySources)
}
}
func TestValidateStatusReplyRequiresTemplates(t *testing.T) {
c := validConfig()
c.ContextEnabled = true
c.ContextTimeout = time.Second
c.UptimeKumaEnabled = true
c.UptimeKumaURL = "https://uptime.internal.example"
c.UptimeKumaMode = "metrics"
c.UptimeKumaAPIKey = "key"
c.UptimeKumaMaxIssues = 20
c.ContextStatusReplyEnabled = true
c.ContextStatusReplyMinRelevance = .5
c.ContextStatusReplyMinAIConfidence = .8
c.ContextStatusReplyMinFinalScore = .5
if err := c.Validate(); err == nil {
t.Fatal("expected missing status templates to be rejected")
}
c.ContextIncidentReplyText = "Störung"
c.ContextMaintenanceReplyText = "Wartung"
if err := c.Validate(); err != nil {
t.Fatalf("expected configured status templates to validate: %v", err)
}
}
func TestEnvTemplateExpandsNewlines(t *testing.T) {
t.Setenv("STATUS_TEXT_TEST", `Erste Zeile\nZweite Zeile`)
if got := envTemplate("STATUS_TEXT_TEST", ""); got != "Erste Zeile\nZweite Zeile" {
t.Fatalf("unexpected template %q", got)
}
}
func TestValidatePriorityAndEscalationFailClosed(t *testing.T) {
c := validConfig()
c.PriorityEnabled = true
c.PriorityAllowedReasonCodes = nil
if err := c.Validate(); err == nil {
t.Fatal("expected empty priority reason allowlist to be rejected")
}
c = validConfig()
c.PriorityEnabled = true
c.AutoPriority = true
c.PriorityAllowedReasonCodes = []string{"core_service_unavailable"}
c.PriorityMaxIncrease = 0
if err := c.Validate(); err == nil {
t.Fatal("expected zero automatic priority increase to be rejected")
}
c = validConfig()
c.EscalationEnabled = true
c.EscalationScanInterval = time.Minute
c.EscalationMinAge = time.Hour
c.EscalationMaxLevel = 3
c.GLPIEscalationLimit = 100
c.EscalationAllowedReasonCodes = []string{"no_human_response"}
c.EscalationAllowedActions = []string{"none"}
c.AutoEscalation = true
if err := c.Validate(); err == nil {
t.Fatal("expected automatic escalation without agent ID/implemented action to be rejected")
}
c.GLPIAgentUserID = 42
c.EscalationAllowedActions = []string{"none", "raise_priority"}
if err := c.Validate(); err != nil {
t.Fatalf("expected safe escalation config to validate: %v", err)
}
}
func validEscalationConfig() Config {
c := validConfig()
c.EscalationEnabled = true
c.EscalationScanInterval = 15 * time.Minute
c.EscalationMinAge = time.Hour
c.EscalationMinInactivity = 30 * time.Minute
c.EscalationAnalysisTimeout = 45 * time.Second
c.EscalationConfidence = .9
c.EscalationMaxLevel = 4
c.EscalationSLARiskWindow = 2 * time.Hour
c.EscalationServiceOwnerMinLevel = 2
c.EscalationManagerReviewMinLevel = 3
c.EscalationMajorIncidentMinScore = .5
c.EscalationAllowedReasonCodes = []string{"no_human_response", "security_incident_suspected", "major_incident_candidate"}
c.EscalationAllowedActions = []string{"none", "raise_priority"}
c.GLPIEscalationLimit = 100
c.GLPIEscalationGroupPatchField = "assigned_groups"
c.GLPIEscalationUserPatchField = "assigned_users"
return c
}
func TestValidateEscalationActionTargets(t *testing.T) {
c := validEscalationConfig()
c.AutoEscalation = true
c.GLPIAgentUserID = 42
c.EscalationAllowedActions = []string{"assign_second_level"}
if err := c.Validate(); err == nil {
t.Fatal("expected missing second-level group to be rejected")
}
c.EscalationSecondLevelGroupID = 9
if err := c.Validate(); err != nil {
t.Fatalf("second-level target should validate: %v", err)
}
c = validEscalationConfig()
c.AutoEscalation = true
c.GLPIAgentUserID = 42
c.EscalationAllowedActions = []string{"assign_security_team"}
if err := c.Validate(); err == nil {
t.Fatal("expected missing security group to be rejected")
}
c.EscalationSecurityGroupID = 10
if err := c.Validate(); err != nil {
t.Fatalf("security target should validate: %v", err)
}
}
func TestValidateEscalationNotificationAndLinkAdapters(t *testing.T) {
c := validEscalationConfig()
c.AutoEscalation = true
c.GLPIAgentUserID = 42
c.EscalationAllowedActions = []string{"notify_service_owner", "request_manager_review"}
if err := c.Validate(); err == nil {
t.Fatal("expected missing owner/manager targets to be rejected")
}
c.EscalationWebhookURL = "https://hooks.internal.example/escalation"
c.EscalationWebhookTimeout = 5 * time.Second
if err := c.Validate(); err != nil {
t.Fatalf("webhook-backed notification targets should validate: %v", err)
}
c = validEscalationConfig()
c.AutoEscalation = true
c.GLPIAgentUserID = 42
c.EscalationAllowedActions = []string{"link_major_incident"}
c.ContextEnabled = true
c.ContextTimeout = time.Second
c.MajorIncidentsEnabled = true
c.GLPIMajorIncidentFilter = "status.id==1"
c.GLPIMajorIncidentLimit = 20
if err := c.Validate(); err == nil {
t.Fatal("expected missing ITIL link adapter to be rejected")
}
c.GLPIEscalationITILLinkPath = "/ITIL/Link/{{ticket_id}}"
c.GLPIEscalationITILLinkBody = `{"source":{{ticket_id}},"target":{{major_incident_id}}}`
if err := c.Validate(); err != nil {
t.Fatalf("configured ITIL link adapter should validate: %v", err)
}
}
func TestValidateRejectsUnknownEscalationActionAndUnsafeField(t *testing.T) {
c := validEscalationConfig()
c.EscalationAllowedActions = []string{"run_arbitrary_command"}
if err := c.Validate(); err == nil {
t.Fatal("expected unknown escalation action to be rejected")
}
c = validEscalationConfig()
c.GLPIEscalationGroupPatchField = "assigned_groups;drop"
if err := c.Validate(); err == nil {
t.Fatal("expected unsafe actor field to be rejected")
}
}
func TestValidateEscalationWebhookRequiresHTTPSByDefault(t *testing.T) {
c := validEscalationConfig()
c.EscalationWebhookURL = "http://hooks.internal.example/escalation"
c.EscalationWebhookTimeout = time.Second
if err := c.Validate(); err == nil {
t.Fatal("expected plain HTTP escalation webhook to be rejected")
}
c.EscalationWebhookAllowInsecureHTTP = true
if err := c.Validate(); err != nil {
t.Fatalf("explicit insecure webhook override should validate: %v", err)
}
}
func TestValidateEscalationLinkAdapterRequiresSourceAndTarget(t *testing.T) {
c := validEscalationConfig()
c.GLPIEscalationITILLinkPath = "/ITIL/Link"
c.GLPIEscalationITILLinkBody = `{"target":99}`
if err := c.Validate(); err == nil {
t.Fatal("expected fixed link adapter without placeholders to be rejected")
}
c.GLPIEscalationITILLinkBody = `{"source":{{ticket_id}},"target":{{major_incident_id}}}`
if err := c.Validate(); err != nil {
t.Fatalf("valid link adapter rejected: %v", err)
}
}
func TestValidateOllamaPoolConfiguration(t *testing.T) {
c := validConfig()
c.OllamaURLs = []string{"http://ollama-1.internal:11434", "http://ollama-2.internal:11434"}
c.OllamaNodeNames = []string{"lenovo-1", "lenovo-2"}
c.OllamaNodeWeights = []int{1, 2}
c.OllamaNodeMaxInflight = 1
c.OllamaRoutingMode = "weighted"
c.OllamaNodeHealthInterval = 10 * time.Second
c.OllamaNodeFailureCooldown = 30 * time.Second
c.OllamaNodeRequestTimeout = time.Minute
c.OllamaFailoverAttempts = 2
if err := c.Validate(); err != nil {
t.Fatalf("valid pool rejected: %v", err)
}
c.OllamaNodeNames = []string{"only-one"}
if err := c.Validate(); err == nil {
t.Fatal("expected mismatched node names to be rejected")
}
c.OllamaNodeNames = []string{"duplicate", "duplicate"}
if err := c.Validate(); err == nil {
t.Fatal("expected duplicate node names to be rejected")
}
c = validConfig()
c.OllamaURLs = []string{"http://user:secret@ollama-1.internal:11434"}
c.OllamaNodeMaxInflight = 1
c.OllamaNodeRequestTimeout = time.Minute
c.OllamaFailoverAttempts = 1
if err := c.Validate(); err == nil {
t.Fatal("expected credentials in Ollama node URL to be rejected")
}
c = validConfig()
c.OllamaURLs = []string{"http://ollama-1.internal:11434", "http://ollama-2.internal:11434"}
c.OllamaNodeMaxInflight = 1
c.OllamaNodeRequestTimeout = time.Minute
c.OllamaRoutingMode = "unknown"
c.OllamaFailoverAttempts = 2
if err := c.Validate(); err == nil {
t.Fatal("expected unknown routing mode to be rejected")
}
}
func TestValidateGLPIKBAutoReplyUncategorizedRequiresArticleAllowlist(t *testing.T) {
c := validConfig()
c.GLPIKBEnabled = true
c.GLPIKBPath = "auto"
c.GLPIKBLimit = 100
c.GLPIKBSyncInterval = 10 * time.Minute
c.GLPIKBSource = "glpi-kb"
c.KnowledgeAllowedSources = []string{"internal-kb", "glpi-kb"}
c.KnowledgeAutoReplySources = []string{"internal-kb", "glpi-kb"}
c.GLPIKBAutoReply = true
c.GLPIKBAutoReplyAllowUncategorized = true
if err := c.Validate(); err == nil {
t.Fatal("expected uncategorized auto reply to require an explicit article allowlist")
}
c.GLPIKBAutoReplyUncategorizedArticleIDs = []int64{1}
if err := c.Validate(); err != nil {
t.Fatalf("expected uncategorized auto reply with explicit article allowlist to validate: %v", err)
}
}