Files
glpi-ai-agent/internal/agent/escalation_actions_test.go
jbergner f415434111
release-tag / release-image (push) Successful in 1m36s
Eskalation erweitert
2026-08-02 23:00:39 +02:00

357 lines
18 KiB
Go

package agent
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/example/glpi-ai-agent/internal/config"
"github.com/example/glpi-ai-agent/internal/metrics"
"github.com/example/glpi-ai-agent/internal/model"
"github.com/example/glpi-ai-agent/internal/queue"
"github.com/example/glpi-ai-agent/internal/state"
)
func escalationTestConfig() config.Config {
return config.Config{
EscalationEnabled: true,
AutoEscalation: true,
EscalationMinAge: time.Hour,
EscalationMinInactivity: 30 * time.Minute,
EscalationConfidence: .8,
EscalationMaxLevel: 4,
EscalationSLARiskWindow: 2 * time.Hour,
EscalationServiceOwnerMinLevel: 2,
EscalationManagerReviewMinLevel: 3,
EscalationMajorIncidentMinScore: .5,
EscalationAllowedReasonCodes: []string{"no_human_response", "unassigned", "sla_at_risk", "sla_breached", "business_deadline", "no_workaround", "security_incident_suspected", "major_incident_candidate"},
EscalationAllowedActions: []string{"none", "raise_priority", "assign_second_level", "assign_security_team", "notify_service_owner", "link_major_incident", "request_manager_review"},
EscalationSecondLevelGroupID: 42,
EscalationSecurityGroupID: 51,
EscalationServiceOwnerGroupID: 61,
EscalationServiceOwnerUserID: 62,
EscalationManagerReviewGroupID: 71,
EscalationManagerReviewUserID: 72,
EscalationAddPrivateFollowup: true,
EscalationSecondLevelNote: "Second Level {{ticket_id}} {{level}} {{reason_codes}}",
EscalationSecurityNote: "Security {{ticket_id}} {{reason}}",
EscalationServiceOwnerNote: "Owner {{ticket_id}}",
EscalationMajorIncidentNote: "Major {{major_incident_id}} {{major_incident_name}}",
EscalationManagerReviewNote: "Manager {{ticket_id}}",
GLPIEscalationGroupPatchField: "assigned_groups",
GLPIEscalationUserPatchField: "assigned_users",
GLPIEscalationITILLinkPath: "/ITIL/Link",
GLPIEscalationITILLinkBody: `{"source":{{ticket_id}},"target":{{major_incident_id}}}`,
GLPIAgentUserID: 999,
}
}
func TestBuildEscalationEvidenceUsesInactivitySLAAndMajorIncident(t *testing.T) {
cfg := escalationTestConfig()
now := time.Date(2026, 8, 2, 18, 0, 0, 0, time.UTC)
ticket := model.Ticket{
ID: 10,
DateCreation: now.Add(-5 * time.Hour).Format(time.RFC3339),
TimeToResolve: now.Add(90 * time.Minute).Format(time.RFC3339),
}
followups := []model.Followup{
{ID: 1, UserID: cfg.GLPIAgentUserID, Date: now.Add(-10 * time.Minute).Format(time.RFC3339)},
{ID: 2, UserID: 123, Date: now.Add(-2 * time.Hour).Format(time.RFC3339)},
}
contextData := model.ContextSnapshot{MajorIncidents: []model.MajorIncidentContext{
{ID: 80, Name: "weniger relevant", Relevance: .6},
{ID: 81, Name: "Druckausfall", Relevance: .9},
}}
e := buildEscalationEvidence(cfg, ticket, followups, contextData, now)
if !e.NoHumanResponse || e.Unassigned != true || !e.SLAAtRisk || e.SLABreached {
t.Fatalf("unexpected deterministic evidence: %+v", e)
}
if e.MajorIncidentID != 81 || e.MajorIncidentName != "Druckausfall" {
t.Fatalf("wrong major incident selected: %+v", e)
}
followups = append(followups, model.Followup{ID: 3, UserID: 124, Date: now.Add(-10 * time.Minute).Format(time.RFC3339)})
e = buildEscalationEvidence(cfg, ticket, followups, contextData, now)
if e.NoHumanResponse {
t.Fatalf("recent human activity was ignored: %+v", e)
}
}
func TestEvaluateEscalationAcceptsConfiguredActionPlan(t *testing.T) {
cfg := escalationTestConfig()
st, err := state.Open(t.TempDir(), 20)
if err != nil {
t.Fatal(err)
}
now := time.Now()
ticket := model.Ticket{ID: 20, DateCreation: now.Add(-4 * time.Hour).Format(time.RFC3339), Priority: 3}
contextData := model.ContextSnapshot{MajorIncidents: []model.MajorIncidentContext{{ID: 200, Name: "Standortausfall", Relevance: .95}}}
decision := model.EscalationDecision{
Escalate: true, Level: 3,
RecommendedActions: []string{"assign_security_team", "link_major_incident", "request_manager_review"},
ReasonCodes: []string{"no_human_response", "security_incident_suspected", "major_incident_candidate"},
Confidence: .95, Reason: "Mehrere kontrollierte Eskalationssignale liegen vor.",
}
result := evaluateEscalation(cfg, st, ticket, nil, contextData, decision, now)
if !result.Accepted || result.Decision != "escalation_accepted" || len(result.Actions) != 3 {
t.Fatalf("unexpected escalation result: %+v", result)
}
for _, action := range result.Actions {
if !action.Accepted || action.IdempotencyKey == "" {
t.Fatalf("action was not accepted: %+v", action)
}
}
if result.Actions[1].Target != "ticket:200" {
t.Fatalf("major incident target=%q", result.Actions[1].Target)
}
}
func TestEvaluateEscalationBlocksSecurityWithoutSecurityReason(t *testing.T) {
cfg := escalationTestConfig()
now := time.Now()
ticket := model.Ticket{ID: 21, DateCreation: now.Add(-4 * time.Hour).Format(time.RFC3339), Priority: 3}
decision := model.EscalationDecision{
Escalate: true, Level: 2, RecommendedActions: []string{"assign_security_team"},
ReasonCodes: []string{"no_human_response"}, Confidence: .95, Reason: "Keine Reaktion.",
}
result := evaluateEscalation(cfg, nil, ticket, nil, model.ContextSnapshot{}, decision, now)
if result.Accepted || len(result.Actions) != 1 || result.Actions[0].Decision != "escalation_action_prerequisite_missing" {
t.Fatalf("security action was not blocked: %+v", result)
}
}
func TestExecuteEscalationPlanWritesMultipleIndependentActions(t *testing.T) {
cfg := escalationTestConfig()
g := &fakeGLPI{ticket: model.Ticket{ID: 30, Priority: 3, AssignedGroups: []int64{8}}}
svc := &Service{cfg: cfg, glpi: g, metrics: metrics.New()}
decision := model.EscalationDecision{Escalate: true, Level: 2, ReasonCodes: []string{"no_human_response", "unassigned", "security_incident_suspected"}, Reason: "Test", Confidence: .95}
result := model.EscalationResult{Accepted: true, Decision: "escalation_accepted", Actions: []model.EscalationActionResult{
{Action: "raise_priority", Accepted: true, IdempotencyKey: "ticket=30;level=2;action=raise_priority;target=priority:4"},
{Action: "assign_second_level", Target: "group:42", Accepted: true, IdempotencyKey: "ticket=30;level=2;action=assign_second_level;target=group:42"},
{Action: "assign_security_team", Target: "group:51", Accepted: true, IdempotencyKey: "ticket=30;level=2;action=assign_security_team;target=group:51"},
}}
audit, err := svc.executeEscalationPlan(context.Background(), g.ticket, decision, result, model.ContextSnapshot{})
if err != nil {
t.Fatal(err)
}
if !audit.Executed || len(audit.Steps) != 3 || g.priorityValue != 4 {
t.Fatalf("unexpected action audit: %+v priority=%d", audit, g.priorityValue)
}
if len(g.assignedGroups) != 3 || g.assignedGroups[0] != 8 || g.assignedGroups[1] != 42 || g.assignedGroups[2] != 51 {
t.Fatalf("assignments were not merged: %v", g.assignedGroups)
}
if len(g.privateNotes) != 3 {
t.Fatalf("private notes=%d want 3", len(g.privateNotes))
}
for _, step := range audit.Steps {
if !step.Executed || step.Result == "" {
t.Fatalf("incomplete action step: %+v", step)
}
}
}
func TestServiceOwnerActionSendsAuthenticatedIdempotentWebhook(t *testing.T) {
var got map[string]any
var gotAuth, gotKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotKey = r.Header.Get("Idempotency-Key")
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
cfg := escalationTestConfig()
cfg.EscalationWebhookURL = server.URL
cfg.EscalationWebhookBearerToken = "secret"
cfg.EscalationWebhookTimeout = time.Second
g := &fakeGLPI{ticket: model.Ticket{ID: 40, Name: "Service gestört", Priority: 4}}
svc := &Service{cfg: cfg, glpi: g, metrics: metrics.New()}
decision := model.EscalationDecision{Escalate: true, Level: 2, ReasonCodes: []string{"business_deadline"}, Reason: "Frist gefährdet", Confidence: .93}
action := model.EscalationActionResult{Action: "notify_service_owner", Target: "group:61,user:62,webhook", Accepted: true, IdempotencyKey: "ticket=40;level=2;action=notify_service_owner;target=group:61,user:62,webhook"}
warnings, err := svc.executeEscalationAction(context.Background(), &g.ticket, decision, action, model.ContextSnapshot{})
if err != nil || len(warnings) != 0 {
t.Fatalf("action error=%v warnings=%v", err, warnings)
}
if gotAuth != "Bearer secret" || gotKey != action.IdempotencyKey || got["action"] != "notify_service_owner" {
t.Fatalf("unexpected webhook auth=%q key=%q body=%v", gotAuth, gotKey, got)
}
if len(g.assignedGroups) != 1 || g.assignedGroups[0] != 61 || len(g.assignedUsers) != 1 || g.assignedUsers[0] != 62 {
t.Fatalf("service owner targets not assigned: groups=%v users=%v", g.assignedGroups, g.assignedUsers)
}
}
func TestMajorIncidentActionUsesDeterministicTarget(t *testing.T) {
cfg := escalationTestConfig()
g := &fakeGLPI{ticket: model.Ticket{ID: 50, Priority: 4}}
svc := &Service{cfg: cfg, glpi: g, metrics: metrics.New()}
contextData := model.ContextSnapshot{MajorIncidents: []model.MajorIncidentContext{{ID: 500, Name: "A", Relevance: .7}, {ID: 501, Name: "B", Relevance: .9}}}
decision := model.EscalationDecision{Escalate: true, Level: 2, ReasonCodes: []string{"major_incident_candidate"}, Reason: "Passender Major Incident", Confidence: .95}
action := model.EscalationActionResult{Action: "link_major_incident", Target: "ticket:501", Accepted: true, IdempotencyKey: "ticket=50;level=2;action=link_major_incident;target=ticket:501"}
warnings, err := svc.executeEscalationAction(context.Background(), &g.ticket, decision, action, contextData)
if err != nil || len(warnings) != 0 {
t.Fatalf("action error=%v warnings=%v", err, warnings)
}
if len(g.linkedTargets) != 1 || g.linkedTargets[0] != 501 || len(g.privateNotes) != 1 {
t.Fatalf("major incident action incomplete: links=%v notes=%v", g.linkedTargets, g.privateNotes)
}
}
func TestEvaluateEscalationBlocksHallucinatedDeterministicReason(t *testing.T) {
cfg := escalationTestConfig()
now := time.Now()
ticket := model.Ticket{ID: 22, DateCreation: now.Add(-4 * time.Hour).Format(time.RFC3339), Priority: 3}
decision := model.EscalationDecision{
Escalate: true, Level: 2, RecommendedActions: []string{"raise_priority"},
ReasonCodes: []string{"sla_breached"}, Confidence: .95, Reason: "SLA angeblich verletzt.",
}
result := evaluateEscalation(cfg, nil, ticket, nil, model.ContextSnapshot{}, decision, now)
if result.Accepted || result.Decision != "escalation_reason_not_evidenced" {
t.Fatalf("hallucinated SLA reason was not blocked: %+v", result)
}
}
func TestLastHumanFollowupParsesMixedDateFormats(t *testing.T) {
followups := []model.Followup{
{ID: 1, UserID: 1, Date: "2026-08-02 12:00:00"},
{ID: 2, UserID: 2, Date: "2026-08-02T13:00:00Z"},
{ID: 3, UserID: 999, Date: "2026-08-02T14:00:00Z"},
}
got := lastHumanFollowup(followups, 999)
if got == nil || got.ID != 2 {
t.Fatalf("latest human followup=%+v", got)
}
}
func TestPrimaryEscalationWriteRemainsExecutedWhenPrivateNoteFails(t *testing.T) {
cfg := escalationTestConfig()
g := &fakeGLPI{ticket: model.Ticket{ID: 31, Priority: 3}, privateNoteErr: errors.New("followup forbidden")}
svc := &Service{cfg: cfg, glpi: g, metrics: metrics.New()}
decision := model.EscalationDecision{Escalate: true, Level: 1, ReasonCodes: []string{"no_human_response"}, Reason: "Test", Confidence: .95}
result := model.EscalationResult{Accepted: true, Decision: "escalation_accepted", Actions: []model.EscalationActionResult{
{Action: "raise_priority", Accepted: true, IdempotencyKey: "ticket=31;level=1;action=raise_priority;target=priority:4"},
}}
audit, err := svc.executeEscalationPlan(context.Background(), g.ticket, decision, result, model.ContextSnapshot{})
if err != nil {
t.Fatalf("ancillary note error must not fail the primary write: %v", err)
}
if !audit.Executed || len(audit.Steps) != 1 || !audit.Steps[0].Executed || audit.Steps[0].Error == "" || g.priorityValue != 4 {
t.Fatalf("unexpected warning audit: %+v priority=%d", audit, g.priorityValue)
}
}
func TestLiveEscalationRechecksFollowupsBeforeWrite(t *testing.T) {
now := time.Now()
g := &fakeGLPI{
ticket: model.Ticket{ID: 60, Name: "Alt", DateCreation: now.Add(-5 * time.Hour).Format(time.RFC3339), DateMod: "v1", StatusID: 1, Priority: 3},
cats: []model.Category{{ID: 1}}, injectFollowupOnSecondCheck: true,
}
svc := newTestService(t, g, model.Decision{}, false)
svc.cfg.EscalationEnabled = true
svc.cfg.AutoEscalation = true
svc.cfg.DryRun = false
svc.cfg.EscalationMinAge = time.Hour
svc.cfg.EscalationMinInactivity = time.Hour
svc.cfg.EscalationConfidence = .8
svc.cfg.EscalationMaxLevel = 3
svc.cfg.EscalationAllowedReasonCodes = []string{"no_human_response"}
svc.cfg.EscalationAllowedActions = []string{"raise_priority"}
svc.cfg.GLPIAgentUserID = 999
svc.ai = fakeAI{escalation: model.EscalationDecision{Escalate: true, Level: 1, RecommendedActions: []string{"raise_priority"}, ReasonCodes: []string{"no_human_response"}, Confidence: .95, Reason: "Keine Bearbeitung."}}
if err := svc.ProcessWork(context.Background(), queue.WorkItem{TicketID: 60, Trigger: "scheduled_escalation"}); err != nil {
t.Fatal(err)
}
if g.setPriority != 0 {
t.Fatalf("priority was written despite a fresh human followup: %d", g.setPriority)
}
run := svc.state.Recent(1)[0]
if run.PolicyReason != "escalation_recent_human_activity" || len(run.Analyses) != 1 {
t.Fatalf("unexpected prewrite result: %+v", run)
}
}
func TestEscalationFailsClosedForUnparseableHumanFollowupDate(t *testing.T) {
cfg := escalationTestConfig()
now := time.Now()
ticket := model.Ticket{ID: 63, DateCreation: now.Add(-5 * time.Hour).Format(time.RFC3339), Priority: 3}
followups := []model.Followup{{ID: 8, UserID: 123, Date: "unbekannt"}}
decision := model.EscalationDecision{Escalate: true, Level: 1, RecommendedActions: []string{"raise_priority"}, ReasonCodes: []string{"no_human_response"}, Confidence: .95, Reason: "Keine Bearbeitung."}
result := evaluateEscalation(cfg, nil, ticket, followups, model.ContextSnapshot{}, decision, now)
if result.Accepted || result.Decision != "escalation_human_activity_time_unknown" {
t.Fatalf("unparseable human activity did not fail closed: %+v", result)
}
}
func TestRaisePriorityIsIdempotentPerTicketAndLevel(t *testing.T) {
cfg := escalationTestConfig()
st, err := state.Open(t.TempDir(), 20)
if err != nil {
t.Fatal(err)
}
a := model.AnalysisRun{AnalysisID: "old", AnalysisType: "escalation", Action: model.ActionAudit{Type: "escalation_plan", Steps: []model.ActionStepAudit{{Step: "raise_priority", Executed: true, Result: "ticket=70;level=2;action=raise_priority; executed"}}}}
if err := st.Append(model.RunRecord{RunID: "old-run", TicketID: 70, Trigger: "scheduled_escalation", Outcome: "processed", FinishedAt: time.Now(), Analyses: []model.AnalysisRun{a}}); err != nil {
t.Fatal(err)
}
now := time.Now()
ticket := model.Ticket{ID: 70, DateCreation: now.Add(-5 * time.Hour).Format(time.RFC3339), Priority: 4}
decision := model.EscalationDecision{Escalate: true, Level: 2, RecommendedActions: []string{"raise_priority"}, ReasonCodes: []string{"no_human_response"}, Confidence: .95, Reason: "Keine Bearbeitung."}
result := evaluateEscalation(cfg, st, ticket, nil, model.ContextSnapshot{}, decision, now)
if result.Accepted || len(result.Actions) != 1 || result.Actions[0].Decision != "escalation_action_duplicate" {
t.Fatalf("priority action repeated within same escalation level: %+v", result)
}
}
func TestSLAEscalationCanProceedDespiteRecentHumanActivity(t *testing.T) {
cfg := escalationTestConfig()
now := time.Now()
ticket := model.Ticket{ID: 64, DateCreation: now.Add(-5 * time.Hour).Format(time.RFC3339), TimeToResolve: now.Add(-time.Minute).Format(time.RFC3339), Priority: 4}
followups := []model.Followup{{ID: 9, UserID: 123, Date: now.Add(-5 * time.Minute).Format(time.RFC3339)}}
decision := model.EscalationDecision{Escalate: true, Level: 2, RecommendedActions: []string{"notify_service_owner"}, ReasonCodes: []string{"sla_breached"}, Confidence: .95, Reason: "SLA verletzt."}
result := evaluateEscalation(cfg, nil, ticket, followups, model.ContextSnapshot{}, decision, now)
if !result.Accepted || result.Decision != "escalation_accepted" {
t.Fatalf("SLA escalation was incorrectly blocked by recent activity: %+v", result)
}
}
func TestEscalationActionOrderIsDeterministic(t *testing.T) {
got := normalizeEscalationActions(model.EscalationDecision{RecommendedActions: []string{"request_manager_review", "raise_priority", "assign_security_team"}})
want := []string{"assign_security_team", "raise_priority", "request_manager_review"}
if len(got) != len(want) {
t.Fatalf("actions=%v want=%v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("actions=%v want=%v", got, want)
}
}
}
func TestEvaluateEscalationRejectsMissingReasonCode(t *testing.T) {
cfg := escalationTestConfig()
now := time.Now()
ticket := model.Ticket{ID: 90, DateCreation: now.Add(-4 * time.Hour).Format(time.RFC3339), Priority: 3}
decision := model.EscalationDecision{
Escalate: true, Level: 1, RecommendedActions: []string{"raise_priority"},
Confidence: .95, Reason: "Eskalation ohne strukturierten Grund.",
}
result := evaluateEscalation(cfg, nil, ticket, nil, model.ContextSnapshot{}, decision, now)
if result.Accepted || result.Decision != "escalation_reason_missing" {
t.Fatalf("missing reason code was not rejected: %+v", result)
}
}