@@ -0,0 +1,475 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
DataDir string
|
||||
DryRun bool
|
||||
LogLevel string
|
||||
|
||||
WebUsername string
|
||||
WebPassword string
|
||||
WebAllowAnonymous bool
|
||||
WebhookSecret string
|
||||
|
||||
GLPIURL string
|
||||
GLPIAPIVersion string
|
||||
GLPIClientID string
|
||||
GLPIClientSecret string
|
||||
GLPIUsername string
|
||||
GLPIPassword string
|
||||
GLPIPollInterval time.Duration
|
||||
GLPIPollLimit int
|
||||
GLPITicketFilter string
|
||||
GLPITimeout time.Duration
|
||||
GLPIAgentUserID int64
|
||||
GLPIAllowInsecureHTTP bool
|
||||
GLPIAllowedStatusIDs []int64
|
||||
|
||||
OllamaURL string
|
||||
OllamaModel string
|
||||
OllamaEmbeddingModel string
|
||||
OllamaTimeout time.Duration
|
||||
|
||||
KnowledgeDir string
|
||||
RAGEnabled bool
|
||||
KnowledgeTopK int
|
||||
CategoryPromptLimit int
|
||||
KnowledgeAllowedSources []string
|
||||
KnowledgeAutoReplySources []string
|
||||
|
||||
CommunicationLanguage string
|
||||
CommunicationStyle string
|
||||
CommunicationSalutation string
|
||||
CommunicationClosing string
|
||||
CommunicationSignature string
|
||||
|
||||
AutoCategory bool
|
||||
AutoReply bool
|
||||
CategoryConfidence float64
|
||||
ReplyConfidence float64
|
||||
KnowledgeMinScore float64
|
||||
|
||||
ContextEnabled bool
|
||||
ContextTimeout time.Duration
|
||||
ContextRelevanceMinScore float64
|
||||
ContextBlockReplyOnError bool
|
||||
ContextBlockReplyOnIncident bool
|
||||
ChangeCalendarEnabled bool
|
||||
GLPIChangePath string
|
||||
GLPIChangeFilter string
|
||||
GLPIChangeLimit int
|
||||
ChangeLookback time.Duration
|
||||
ChangeLookahead time.Duration
|
||||
MajorIncidentsEnabled bool
|
||||
GLPIMajorIncidentFilter string
|
||||
GLPIMajorIncidentLimit int
|
||||
UserDeviceContextEnabled bool
|
||||
GLPIUserDevicePaths []string
|
||||
GLPIUserDeviceFilterTemplate string
|
||||
GLPIUserDeviceLimit int
|
||||
UptimeKumaEnabled bool
|
||||
UptimeKumaURL string
|
||||
UptimeKumaMode string
|
||||
UptimeKumaAPIKey string
|
||||
UptimeKumaStatusPages []string
|
||||
UptimeKumaTimeout time.Duration
|
||||
UptimeKumaMaxIssues int
|
||||
UptimeKumaIncludeMaintenance bool
|
||||
|
||||
QueueSize int
|
||||
Workers int
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
c := Config{
|
||||
HTTPAddr: env("HTTP_ADDR", ":8080"),
|
||||
DataDir: env("DATA_DIR", "./data"),
|
||||
DryRun: envBool("DRY_RUN", true),
|
||||
LogLevel: env("LOG_LEVEL", "info"),
|
||||
WebUsername: os.Getenv("WEB_USERNAME"),
|
||||
WebPassword: os.Getenv("WEB_PASSWORD"),
|
||||
WebAllowAnonymous: envBool("WEB_ALLOW_ANONYMOUS", false),
|
||||
WebhookSecret: os.Getenv("WEBHOOK_SECRET"),
|
||||
GLPIURL: strings.TrimRight(os.Getenv("GLPI_URL"), "/"),
|
||||
GLPIAPIVersion: env("GLPI_API_VERSION", "v2.3"),
|
||||
GLPIClientID: os.Getenv("GLPI_CLIENT_ID"),
|
||||
GLPIClientSecret: os.Getenv("GLPI_CLIENT_SECRET"),
|
||||
GLPIUsername: os.Getenv("GLPI_USERNAME"),
|
||||
GLPIPassword: os.Getenv("GLPI_PASSWORD"),
|
||||
GLPIPollInterval: envDuration("GLPI_POLL_INTERVAL", 30*time.Second),
|
||||
GLPIPollLimit: envInt("GLPI_POLL_LIMIT", 50),
|
||||
GLPITicketFilter: os.Getenv("GLPI_TICKET_FILTER"),
|
||||
GLPITimeout: envDuration("GLPI_TIMEOUT", 20*time.Second),
|
||||
GLPIAgentUserID: envInt64("GLPI_AGENT_USER_ID", 0),
|
||||
GLPIAllowInsecureHTTP: envBool("GLPI_ALLOW_INSECURE_HTTP", false),
|
||||
GLPIAllowedStatusIDs: envInt64List("GLPI_ALLOWED_STATUS_IDS", "1"),
|
||||
OllamaURL: strings.TrimRight(env("OLLAMA_URL", "http://ollama:11434"), "/"),
|
||||
OllamaModel: env("OLLAMA_MODEL", "qwen3:8b"),
|
||||
OllamaEmbeddingModel: env("OLLAMA_EMBEDDING_MODEL", "embeddinggemma"),
|
||||
OllamaTimeout: envDuration("OLLAMA_TIMEOUT", 120*time.Second),
|
||||
KnowledgeDir: env("KNOWLEDGE_DIR", "./knowledge"),
|
||||
RAGEnabled: envBool("RAG_ENABLED", true),
|
||||
KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 3),
|
||||
CategoryPromptLimit: envInt("CATEGORY_PROMPT_LIMIT", 80),
|
||||
KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"),
|
||||
KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"),
|
||||
CommunicationLanguage: env("COMMUNICATION_LANGUAGE", "de-DE"),
|
||||
CommunicationStyle: strings.ToLower(env("COMMUNICATION_STYLE", "formal")),
|
||||
CommunicationSalutation: env("COMMUNICATION_SALUTATION", "Guten Tag,"),
|
||||
CommunicationClosing: env("COMMUNICATION_CLOSING", "Mit freundlichen Grüßen"),
|
||||
CommunicationSignature: env("COMMUNICATION_SIGNATURE", "IT-Service"),
|
||||
AutoCategory: envBool("AUTO_CATEGORY", true),
|
||||
AutoReply: envBool("AUTO_REPLY", false),
|
||||
CategoryConfidence: envFloat("CATEGORY_CONFIDENCE", 0.90),
|
||||
ReplyConfidence: envFloat("REPLY_CONFIDENCE", 0.97),
|
||||
KnowledgeMinScore: envFloat("KNOWLEDGE_MIN_SCORE", 0.88),
|
||||
|
||||
ContextEnabled: envBool("CONTEXT_ENABLED", true),
|
||||
ContextTimeout: envDuration("CONTEXT_TIMEOUT", 12*time.Second),
|
||||
ContextRelevanceMinScore: envFloat("CONTEXT_RELEVANCE_MIN_SCORE", 0.20),
|
||||
ContextBlockReplyOnError: envBool("CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS", true),
|
||||
ContextBlockReplyOnIncident: envBool("CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT", true),
|
||||
ChangeCalendarEnabled: envBool("CHANGE_CALENDAR_ENABLED", true),
|
||||
GLPIChangePath: env("GLPI_CHANGE_PATH", "/Assistance/Change"),
|
||||
GLPIChangeFilter: os.Getenv("GLPI_CHANGE_FILTER"),
|
||||
GLPIChangeLimit: envInt("GLPI_CHANGE_LIMIT", 100),
|
||||
ChangeLookback: envDuration("CHANGE_LOOKBACK", 48*time.Hour),
|
||||
ChangeLookahead: envDuration("CHANGE_LOOKAHEAD", 24*time.Hour),
|
||||
MajorIncidentsEnabled: envBool("MAJOR_INCIDENTS_ENABLED", false),
|
||||
GLPIMajorIncidentFilter: strings.TrimSpace(os.Getenv("GLPI_MAJOR_INCIDENT_FILTER")),
|
||||
GLPIMajorIncidentLimit: envInt("GLPI_MAJOR_INCIDENT_LIMIT", 20),
|
||||
UserDeviceContextEnabled: envBool("USER_DEVICE_CONTEXT_ENABLED", true),
|
||||
GLPIUserDevicePaths: envPathList("GLPI_USER_DEVICE_PATHS", "/Assets/Computer"),
|
||||
GLPIUserDeviceFilterTemplate: env("GLPI_USER_DEVICE_FILTER_TEMPLATE", "user.id=={{user_id}}"),
|
||||
GLPIUserDeviceLimit: envInt("GLPI_USER_DEVICE_LIMIT", 20),
|
||||
UptimeKumaEnabled: envBool("UPTIME_KUMA_ENABLED", false),
|
||||
UptimeKumaURL: strings.TrimRight(os.Getenv("UPTIME_KUMA_URL"), "/"),
|
||||
UptimeKumaMode: strings.ToLower(env("UPTIME_KUMA_MODE", "metrics")),
|
||||
UptimeKumaAPIKey: os.Getenv("UPTIME_KUMA_API_KEY"),
|
||||
UptimeKumaStatusPages: envStringListPreserveCase("UPTIME_KUMA_STATUS_PAGES", ""),
|
||||
UptimeKumaTimeout: envDuration("UPTIME_KUMA_TIMEOUT", 10*time.Second),
|
||||
UptimeKumaMaxIssues: envInt("UPTIME_KUMA_MAX_ISSUES", 20),
|
||||
UptimeKumaIncludeMaintenance: envBool("UPTIME_KUMA_INCLUDE_MAINTENANCE", true),
|
||||
|
||||
QueueSize: envInt("QUEUE_SIZE", 256),
|
||||
Workers: envInt("WORKERS", 2),
|
||||
}
|
||||
return c, c.Validate()
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
var missing []string
|
||||
for name, value := range map[string]string{
|
||||
"GLPI_URL": c.GLPIURL,
|
||||
"GLPI_CLIENT_ID": c.GLPIClientID,
|
||||
"GLPI_CLIENT_SECRET": c.GLPIClientSecret,
|
||||
"GLPI_USERNAME": c.GLPIUsername,
|
||||
"GLPI_PASSWORD": c.GLPIPassword,
|
||||
} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
if !c.WebAllowAnonymous && (c.WebUsername == "" || c.WebPassword == "") {
|
||||
return errors.New("WEB_USERNAME and WEB_PASSWORD are required unless WEB_ALLOW_ANONYMOUS=true")
|
||||
}
|
||||
if !c.WebAllowAnonymous && len(c.WebPassword) < 12 {
|
||||
return errors.New("WEB_PASSWORD must contain at least 12 characters")
|
||||
}
|
||||
if !c.WebAllowAnonymous && isPlaceholder(c.WebPassword) {
|
||||
return errors.New("WEB_PASSWORD still contains a CHANGE_ME placeholder")
|
||||
}
|
||||
if c.WebhookSecret != "" && len(c.WebhookSecret) < 24 {
|
||||
return errors.New("WEBHOOK_SECRET must contain at least 24 characters when enabled")
|
||||
}
|
||||
if c.WebhookSecret != "" && isPlaceholder(c.WebhookSecret) {
|
||||
return errors.New("WEBHOOK_SECRET still contains a CHANGE_ME placeholder")
|
||||
}
|
||||
for name, value := range map[string]string{"GLPI_CLIENT_ID": c.GLPIClientID, "GLPI_CLIENT_SECRET": c.GLPIClientSecret, "GLPI_PASSWORD": c.GLPIPassword} {
|
||||
if isPlaceholder(value) {
|
||||
return fmt.Errorf("%s still contains a CHANGE_ME placeholder", name)
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(c.GLPIURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return errors.New("GLPI_URL must be a valid absolute URL")
|
||||
}
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return errors.New("GLPI_URL scheme must be http or https")
|
||||
}
|
||||
if u.Scheme == "http" && !c.GLPIAllowInsecureHTTP {
|
||||
return errors.New("GLPI_URL must use https unless GLPI_ALLOW_INSECURE_HTTP=true")
|
||||
}
|
||||
if len(c.GLPIAllowedStatusIDs) == 0 {
|
||||
return errors.New("GLPI_ALLOWED_STATUS_IDS must contain at least one positive status id")
|
||||
}
|
||||
for _, id := range c.GLPIAllowedStatusIDs {
|
||||
if id <= 0 {
|
||||
return errors.New("GLPI_ALLOWED_STATUS_IDS may contain only positive integers")
|
||||
}
|
||||
}
|
||||
if c.AutoReply && c.GLPIAgentUserID <= 0 {
|
||||
return errors.New("GLPI_AGENT_USER_ID must be set when AUTO_REPLY=true")
|
||||
}
|
||||
if strings.TrimSpace(c.CommunicationLanguage) == "" {
|
||||
return errors.New("COMMUNICATION_LANGUAGE must not be empty")
|
||||
}
|
||||
switch c.CommunicationStyle {
|
||||
case "formal", "neutral", "informal":
|
||||
default:
|
||||
return errors.New("COMMUNICATION_STYLE must be one of: formal, neutral, informal")
|
||||
}
|
||||
if len(c.KnowledgeAllowedSources) == 0 {
|
||||
return errors.New("KNOWLEDGE_ALLOWED_SOURCES must contain at least one source")
|
||||
}
|
||||
allowedSources := make(map[string]struct{}, len(c.KnowledgeAllowedSources))
|
||||
for _, source := range c.KnowledgeAllowedSources {
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return errors.New("KNOWLEDGE_ALLOWED_SOURCES may not contain empty source names")
|
||||
}
|
||||
allowedSources[strings.ToLower(source)] = struct{}{}
|
||||
}
|
||||
for _, source := range c.KnowledgeAutoReplySources {
|
||||
if _, ok := allowedSources[strings.ToLower(source)]; !ok {
|
||||
return fmt.Errorf("KNOWLEDGE_AUTO_REPLY_SOURCES source %q is not present in KNOWLEDGE_ALLOWED_SOURCES", source)
|
||||
}
|
||||
}
|
||||
if c.AutoReply && len(c.KnowledgeAutoReplySources) == 0 {
|
||||
return errors.New("KNOWLEDGE_AUTO_REPLY_SOURCES must contain at least one source when AUTO_REPLY=true")
|
||||
}
|
||||
if c.Workers < 1 || c.QueueSize < 1 {
|
||||
return errors.New("WORKERS and QUEUE_SIZE must be >= 1")
|
||||
}
|
||||
if c.CategoryConfidence < 0 || c.CategoryConfidence > 1 || c.ReplyConfidence < 0 || c.ReplyConfidence > 1 || c.KnowledgeMinScore < 0 || c.KnowledgeMinScore > 1 || c.ContextRelevanceMinScore < 0 || c.ContextRelevanceMinScore > 1 {
|
||||
return errors.New("confidence/score thresholds must be between 0 and 1")
|
||||
}
|
||||
if c.ContextEnabled {
|
||||
if c.ContextTimeout <= 0 {
|
||||
return errors.New("CONTEXT_TIMEOUT must be > 0")
|
||||
}
|
||||
if c.ChangeCalendarEnabled {
|
||||
if !validAPIPath(c.GLPIChangePath) {
|
||||
return errors.New("GLPI_CHANGE_PATH must be an absolute API path such as /Assistance/Change")
|
||||
}
|
||||
if c.GLPIChangeLimit < 1 || c.GLPIChangeLimit > 1000 {
|
||||
return errors.New("GLPI_CHANGE_LIMIT must be between 1 and 1000")
|
||||
}
|
||||
}
|
||||
if c.MajorIncidentsEnabled {
|
||||
if c.GLPIMajorIncidentFilter == "" {
|
||||
return errors.New("GLPI_MAJOR_INCIDENT_FILTER is required when MAJOR_INCIDENTS_ENABLED=true")
|
||||
}
|
||||
if c.GLPIMajorIncidentLimit < 1 || c.GLPIMajorIncidentLimit > 500 {
|
||||
return errors.New("GLPI_MAJOR_INCIDENT_LIMIT must be between 1 and 500")
|
||||
}
|
||||
}
|
||||
if c.UserDeviceContextEnabled {
|
||||
if len(c.GLPIUserDevicePaths) == 0 {
|
||||
return errors.New("GLPI_USER_DEVICE_PATHS must contain at least one API path when USER_DEVICE_CONTEXT_ENABLED=true")
|
||||
}
|
||||
for _, p := range c.GLPIUserDevicePaths {
|
||||
if !validAPIPath(p) {
|
||||
return fmt.Errorf("invalid GLPI user-device API path %q", p)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(c.GLPIUserDeviceFilterTemplate, "{{user_id}}") {
|
||||
return errors.New("GLPI_USER_DEVICE_FILTER_TEMPLATE must contain {{user_id}}")
|
||||
}
|
||||
if c.GLPIUserDeviceLimit < 1 || c.GLPIUserDeviceLimit > 500 {
|
||||
return errors.New("GLPI_USER_DEVICE_LIMIT must be between 1 and 500")
|
||||
}
|
||||
}
|
||||
if c.UptimeKumaEnabled {
|
||||
if c.UptimeKumaURL == "" {
|
||||
return errors.New("UPTIME_KUMA_URL is required when UPTIME_KUMA_ENABLED=true")
|
||||
}
|
||||
u, err := url.Parse(c.UptimeKumaURL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return errors.New("UPTIME_KUMA_URL must be an absolute http/https URL")
|
||||
}
|
||||
switch c.UptimeKumaMode {
|
||||
case "metrics":
|
||||
if strings.TrimSpace(c.UptimeKumaAPIKey) == "" {
|
||||
return errors.New("UPTIME_KUMA_API_KEY is required for UPTIME_KUMA_MODE=metrics")
|
||||
}
|
||||
case "status_page":
|
||||
if len(c.UptimeKumaStatusPages) == 0 {
|
||||
return errors.New("UPTIME_KUMA_STATUS_PAGES is required for UPTIME_KUMA_MODE=status_page")
|
||||
}
|
||||
default:
|
||||
return errors.New("UPTIME_KUMA_MODE must be metrics or status_page")
|
||||
}
|
||||
if c.UptimeKumaMaxIssues < 1 || c.UptimeKumaMaxIssues > 200 {
|
||||
return errors.New("UPTIME_KUMA_MAX_ISSUES must be between 1 and 200")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAPIPath(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
return strings.HasPrefix(v, "/") && !strings.Contains(v, "..") && !strings.ContainsAny(v, "\r\n")
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
func isPlaceholder(v string) bool {
|
||||
return strings.Contains(strings.ToUpper(strings.TrimSpace(v)), "CHANGE_ME")
|
||||
}
|
||||
|
||||
func envInt64List(key, def string) []int64 {
|
||||
raw := os.Getenv(key)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
raw = def
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]int64, 0, len(parts))
|
||||
seen := make(map[int64]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envStringList(key, def string) []string {
|
||||
raw, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
raw = def
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.EqualFold(raw, "none") {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
v := strings.ToLower(strings.TrimSpace(part))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envStringListPreserveCase(key, def string) []string {
|
||||
raw, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
raw = def
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.EqualFold(raw, "none") {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
v := strings.TrimSpace(part)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(v)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envPathList(key, def string) []string {
|
||||
vals := envStringListPreserveCase(key, def)
|
||||
for i := range vals {
|
||||
vals[i] = "/" + strings.TrimLeft(strings.TrimSpace(vals[i]), "/")
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
b, e := strconv.ParseBool(v)
|
||||
if e != nil {
|
||||
return def
|
||||
}
|
||||
return b
|
||||
}
|
||||
func envInt(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, e := strconv.Atoi(v)
|
||||
if e != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
func envInt64(key string, def int64) int64 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, e := strconv.ParseInt(v, 10, 64)
|
||||
if e != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
func envFloat(key string, def float64) float64 {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, e := strconv.ParseFloat(v, 64)
|
||||
if e != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
func envDuration(key string, def time.Duration) time.Duration {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
d, e := time.ParseDuration(v)
|
||||
if e != nil {
|
||||
return def
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
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}}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user