package policy import ( "errors" "net/url" "reflect" "testing" "time" "sultan/pkg/license" "sultan/pkg/store" "sultan/pkg/traefik" ) var now = time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) const interceptor = "http://127.0.0.1:8081" func engine() *Engine { return &Engine{InterceptorURL: interceptor, now: func() time.Time { return now }} } func lic(tier license.Tier, maxRoutes int, features ...license.Feature) license.License { return license.License{ TenantID: "kunde", Tier: tier, MaxRoutes: maxRoutes, AllowedDomains: []string{"kunde.de", "*.kunde.de"}, Features: features, ValidUntil: now.AddDate(0, 3, 0), } } // cfg baut eine Knoten-Konfiguration aus Labels – wie sie der Server vom // Agenten bekommt. func cfg(t *testing.T, labels map[string]string) *traefik.Configuration { t.Helper() labels["traefik.enable"] = "true" c, err := traefik.FromLabels("100.64.0.42", labels) if err != nil { t.Fatalf("FromLabels: %v", err) } return c } func wpLabels() map[string]string { return map[string]string{ "traefik.http.routers.wp.rule": "Host(`kunde.de`) || Host(`www.kunde.de`)", "traefik.http.routers.wp.entrypoints": "websecure", "traefik.http.routers.wp.tls.certresolver": "dns-resolver", "traefik.http.routers.wp.middlewares": "wp-www", "traefik.http.middlewares.wp-www.redirectregex.regex": `^https?://www\.kunde\.de/(.*)`, "traefik.http.middlewares.wp-www.redirectregex.replacement": "https://kunde.de/${1}", "traefik.http.services.wp.loadbalancer.server.port": "80", } } func input(t *testing.T, labels map[string]string) Input { return Input{ Tenant: Tenant{ID: "kunde", Status: store.TenantActive}, License: lic(license.TierPro, 10), NodeID: "node-1", Config: cfg(t, labels), } } func kinds(vs []Violation) []ViolationKind { var out []ViolationKind for _, v := range vs { out = append(out, v.Kind) } return out } func TestApply_NormalNamespacesEverything(t *testing.T) { in := input(t, wpLabels()) res, err := engine().Apply(in) if err != nil { t.Fatalf("Apply: %v", err) } if len(res.Violations) != 0 || res.Stage != StageNormal { t.Errorf("Violations=%v Stage=%q", res.Violations, res.Stage) } want := &traefik.Configuration{HTTP: &traefik.HTTPConfiguration{ Routers: map[string]*traefik.Router{ "kunde__node-1__wp": { EntryPoints: []string{"websecure"}, Middlewares: []string{"kunde__node-1__wp-www"}, Service: "kunde__node-1__wp", Rule: "Host(`kunde.de`) || Host(`www.kunde.de`)", TLS: &traefik.RouterTLS{CertResolver: "dns-resolver"}, }, }, Services: map[string]*traefik.Service{ "kunde__node-1__wp": {LoadBalancer: &traefik.ServersLoadBalancer{Servers: []traefik.Server{{URL: "http://100.64.0.42:80"}}}}, }, Middlewares: map[string]traefik.Middleware{ "kunde__node-1__wp-www": in.Config.HTTP.Middlewares["wp-www"], }, }} if !reflect.DeepEqual(res.Config, want) { t.Errorf("Konfiguration weicht ab\n got: %+v\nwant: %+v", res.Config.HTTP, want.HTTP) } } func TestApply_DoesNotMutateInput(t *testing.T) { in := input(t, wpLabels()) before := cfg(t, wpLabels()) if _, err := engine().Apply(in); err != nil { t.Fatal(err) } if !reflect.DeepEqual(in.Config, before) { t.Error("Eingabe-Konfiguration wurde verändert") } } func TestApply_TwoTenantsSameNamesMergeWithoutConflict(t *testing.T) { a := input(t, wpLabels()) b := input(t, wpLabels()) b.Tenant.ID, b.License.TenantID, b.NodeID = "andere", "andere", "node-1" b.License.AllowedDomains = []string{"kunde.de", "*.kunde.de"} ra, _ := engine().Apply(a) rb, _ := engine().Apply(b) if _, err := traefik.Merge(ra.Config, rb.Config); err != nil { t.Errorf("Merge nach Namensraum-Präfix: %v", err) } } func TestApply_DomainViolation(t *testing.T) { labels := wpLabels() labels["traefik.http.routers.bank.rule"] = "Host(`bank.de`)" labels["traefik.http.routers.bank.service"] = "wp" res, err := engine().Apply(input(t, labels)) if err != nil { t.Fatal(err) } if _, ok := res.Config.HTTP.Routers["kunde__node-1__bank"]; ok { t.Error("Router mit fremder Domain wurde übernommen") } if _, ok := res.Config.HTTP.Routers["kunde__node-1__wp"]; !ok { t.Error("erlaubter Router fehlt") } if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationDomain}) || res.Violations[0].Router != "bank" { t.Errorf("Violations = %+v", res.Violations) } } func TestApply_RuleViolations(t *testing.T) { tests := map[string]ViolationKind{ "Host(`kunde.de`) || PathPrefix(`/`)": ViolationCatchAll, "PathPrefix(`/`)": ViolationCatchAll, "HostRegexp(`.+`)": ViolationHostRegexp, "Host(`kunde.de`": ViolationRuleSyntax, "Host(`sub.kunde.de`) || Host(`x.de`)": ViolationDomain, } for rule, want := range tests { labels := wpLabels() labels["traefik.http.routers.wp.rule"] = rule res, err := engine().Apply(input(t, labels)) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{want}) { t.Errorf("%s: Violations = %+v, erwartet %s", rule, res.Violations, want) } if res.Config.HTTP != nil { t.Errorf("%s: Router trotz Verstoß übernommen: %+v", rule, res.Config.HTTP) } } } func TestApply_RouteLimit(t *testing.T) { labels := map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"} for _, r := range []string{"c", "a", "b"} { labels["traefik.http.routers."+r+".rule"] = "Host(`" + r + ".kunde.de`)" } in := input(t, labels) in.License.MaxRoutes = 2 res, err := engine().Apply(in) if err != nil { t.Fatal(err) } if len(res.Config.HTTP.Routers) != 2 || res.Config.HTTP.Routers["kunde__node-1__c"] != nil { t.Errorf("erwartet a und b (sortiert), bekommen %v", keys(res.Config.HTTP.Routers)) } if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationRouteLimit}) || res.Violations[0].Router != "c" { t.Errorf("Violations = %+v", res.Violations) } in.License.MaxRoutes = -1 res, _ = engine().Apply(in) if len(res.Config.HTTP.Routers) != 3 || len(res.Violations) != 0 { t.Errorf("unbegrenzt: %d Router, %v", len(res.Config.HTTP.Routers), res.Violations) } } func TestApply_ExternalReferences(t *testing.T) { labels := wpLabels() labels["traefik.http.routers.dash.rule"] = "Host(`dash.kunde.de`)" labels["traefik.http.routers.dash.service"] = "api@internal" labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth@file" res, err := engine().Apply(input(t, labels)) if err != nil { t.Fatal(err) } if res.Config.HTTP != nil { t.Errorf("Router mit externen Referenzen übernommen: %v", keys(res.Config.HTTP.Routers)) } if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationExternalRef, ViolationExternalRef}) { t.Errorf("Violations = %+v", res.Violations) } e := engine() e.AllowedExternalRefs = []string{"auth@file"} res, _ = e.Apply(input(t, labels)) r := res.Config.HTTP.Routers["kunde__node-1__wp"] if r == nil || !reflect.DeepEqual(r.Middlewares, []string{"kunde__node-1__wp-www", "auth@file"}) { t.Errorf("freigegebene Referenz: %+v", r) } } func TestApply_UnknownLocalReferences(t *testing.T) { labels := wpLabels() labels["traefik.http.routers.x.rule"] = "Host(`x.kunde.de`)" labels["traefik.http.routers.x.service"] = "andere__node-9__shop" // fremder Service per vollem Namen labels["traefik.http.routers.y.rule"] = "Host(`y.kunde.de`)" labels["traefik.http.routers.y.service"] = "wp" labels["traefik.http.routers.y.middlewares"] = "gibtsnicht" res, err := engine().Apply(input(t, labels)) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationUnknownRef, ViolationUnknownRef}) { t.Errorf("Violations = %+v", res.Violations) } if len(res.Config.HTTP.Routers) != 1 { t.Errorf("Router: %v", keys(res.Config.HTTP.Routers)) } } func TestApply_MiddlewareFeatureGate(t *testing.T) { labels := wpLabels() labels["traefik.http.middlewares.auth.basicauth.users"] = "u:$apr1$x" labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth" res, _ := engine().Apply(input(t, labels)) if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationFeature}) || res.Config.HTTP != nil { t.Errorf("ohne custom_middleware: Violations=%+v", res.Violations) } in := input(t, labels) in.License = lic(license.TierPro, 10, license.FeatureCustomMiddleware) res, _ = engine().Apply(in) if len(res.Violations) != 0 || res.Config.HTTP.Middlewares["kunde__node-1__auth"] == nil { t.Errorf("mit custom_middleware: Violations=%+v", res.Violations) } } // chain und errors verweisen per Namen auf andere Middlewares/Services und // würden den Namensraum umgehen – nie erlaubt. func TestApply_ForbiddenMiddlewares(t *testing.T) { for _, typ := range []string{"chain.middlewares", "errors.service"} { labels := wpLabels() labels["traefik.http.middlewares.m."+typ] = "andere__node-9__x" labels["traefik.http.routers.wp.middlewares"] = "m" in := input(t, labels) in.License = lic(license.TierEnterprise, -1, license.FeatureCustomMiddleware) res, _ := engine().Apply(in) if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationForbiddenMiddleware}) || res.Config.HTTP != nil { t.Errorf("%s: Violations=%+v", typ, res.Violations) } } } func TestApply_PrunesUnreferenced(t *testing.T) { labels := wpLabels() labels["traefik.http.middlewares.unused.compress"] = "true" labels["traefik.http.services.unused.loadbalancer.server.port"] = "81" labels["traefik.http.routers.wp.service"] = "wp" res, _ := engine().Apply(input(t, labels)) if res.Config.HTTP.Middlewares["kunde__node-1__unused"] != nil || res.Config.HTTP.Services["kunde__node-1__unused"] != nil { t.Error("nicht referenzierte Definitionen wurden übernommen") } } func TestStageFor(t *testing.T) { tests := []struct { name string tier license.Tier status store.TenantStatus level int expire bool want Stage }{ {"Community aktiv", license.TierCommunity, store.TenantActive, 0, false, StageNormal}, {"Community ohne Mahnwesen", license.TierCommunity, store.TenantActive, 2, false, StageNormal}, // Community wird automatisch neu lizenziert (5c): kein Ablauf über valid_until … {"Community mit altem valid_until", license.TierCommunity, store.TenantActive, 0, true, StageNormal}, // … nur der explizite Status expired führt zur Landingpage. {"Community explizit abgelaufen", license.TierCommunity, store.TenantExpired, 0, false, StageLanding}, {"Pro aktiv", license.TierPro, store.TenantActive, 0, false, StageNormal}, {"Mahnstufe 1", license.TierPro, store.TenantActive, 1, false, StageWatermark}, {"Mahnstufe 2", license.TierPro, store.TenantActive, 2, false, StageFiremark}, {"Mahnstufe 3", license.TierEnterprise, store.TenantActive, 3, false, StagePaywall}, {"Mahnstufe 9", license.TierPro, store.TenantActive, 9, false, StagePaywall}, {"Zahlung ausgesetzt", license.TierPro, store.TenantSuspendedPayment, 0, false, StagePaywall}, {"Status abgelaufen", license.TierPro, store.TenantExpired, 0, false, StageLanding}, {"Lizenz abgelaufen", license.TierPro, store.TenantActive, 1, true, StageLanding}, {"Quarantäne", license.TierPro, store.TenantQuarantined, 0, false, StageBlocked}, {"Quarantäne Community", license.TierCommunity, store.TenantQuarantined, 0, false, StageBlocked}, } for _, tt := range tests { l := lic(tt.tier, 5) if tt.expire { l.ValidUntil = now } if got := StageFor(Tenant{ID: "kunde", Status: tt.status, DunningLevel: tt.level}, l, now); got != tt.want { t.Errorf("%s: Stage = %q, erwartet %q", tt.name, got, tt.want) } } } func TestApply_Interception(t *testing.T) { tests := []struct { stage Stage level int status store.TenantStatus ack bool // Durchlass per Cookie nach Lander-Hinweis }{ {StageWatermark, 1, store.TenantActive, true}, {StageFiremark, 2, store.TenantActive, true}, {StagePaywall, 0, store.TenantSuspendedPayment, false}, {StageLanding, 0, store.TenantExpired, false}, } for _, tt := range tests { t.Run(string(tt.stage), func(t *testing.T) { in := input(t, wpLabels()) in.Tenant.Status, in.Tenant.DunningLevel = tt.status, tt.level res, err := engine().Apply(in) if err != nil { t.Fatal(err) } if res.Stage != tt.stage { t.Errorf("Stage = %q", res.Stage) } r := res.Config.HTTP.Routers["kunde__node-1__wp"] if r == nil || r.Service != InterceptorService { t.Fatalf("Router nicht zum Interceptor umgeleitet: %+v", r) } if r.Rule != "Host(`kunde.de`) || Host(`www.kunde.de`)" || r.TLS == nil { t.Error("Regel/TLS dürfen sich nicht ändern") } mw := "sultan__kunde__node-1__wp" if !reflect.DeepEqual(r.Middlewares, []string{"kunde__node-1__wp-www", mw}) { t.Errorf("Middlewares = %v", r.Middlewares) } hdr := res.Config.HTTP.Middlewares[mw]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any) want := map[string]any{HeaderStage: string(tt.stage), HeaderTenant: "kunde", HeaderTier: "pro"} if !reflect.DeepEqual(hdr, want) { t.Errorf("Header = %v, erwartet %v", hdr, want) } svc := res.Config.HTTP.Services[InterceptorService] if svc == nil || svc.LoadBalancer.Servers[0].URL != interceptor { t.Errorf("Interceptor-Service fehlt: %+v", svc) } ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"] if !tt.ack { if ack != nil { t.Errorf("Stufe %s darf keinen Durchlass haben: %+v", tt.stage, ack) } return } if ack == nil { t.Fatal("Durchlass-Router fehlt") } wantRule := "(Host(`kunde.de`) || Host(`www.kunde.de`)) && HeaderRegexp(`Cookie`, `(^|;\\s*)sultan_ack=" + string(tt.stage) + "(;|$)`)" if ack.Rule != wantRule { t.Errorf("Durchlass-Regel = %s\nerwartet %s", ack.Rule, wantRule) } if ack.Service != "kunde__node-1__wp" || !reflect.DeepEqual(ack.Middlewares, []string{"kunde__node-1__wp-www"}) { t.Errorf("Durchlass-Router: %+v", ack) } if ack.TLS == nil || !reflect.DeepEqual(ack.EntryPoints, r.EntryPoints) { t.Error("Durchlass-Router muss TLS/EntryPoints übernehmen") } }) } } // Hat der Mandant eine Priorität gesetzt, muss der Durchlass darüber liegen; // ohne Priorität gewinnt er in Traefik ohnehin über die längere Regel. func TestApply_AckPriority(t *testing.T) { for _, prio := range []int{0, 50} { labels := wpLabels() if prio > 0 { labels["traefik.http.routers.wp.priority"] = "50" } in := input(t, labels) in.Tenant.DunningLevel = 1 res, _ := engine().Apply(in) r := res.Config.HTTP.Routers["kunde__node-1__wp"] ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"] wantAck := 0 if prio > 0 { wantAck = prio + 1 } if r.Priority != prio || ack.Priority != wantAck { t.Errorf("prio=%d: Router %d, Durchlass %d", prio, r.Priority, ack.Priority) } } } func TestDunningLevel(t *testing.T) { due := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) day := 24 * time.Hour tests := []struct { now time.Time want int }{ {due.Add(-day), 0}, {due, 0}, {due.Add(8*day - time.Second), 0}, {due.Add(8 * day), 1}, {due.Add(25*day - time.Second), 1}, {due.Add(25 * day), 2}, {due.Add(32 * day), 3}, {due.Add(365 * day), 3}, } var sched DunningSchedule // Nullwert = Standard 8/25/32 Tage for _, tt := range tests { if got := sched.Level(due, tt.now); got != tt.want { t.Errorf("%v überfällig: Stufe %d, erwartet %d", tt.now.Sub(due), got, tt.want) } } if got := sched.Level(time.Time{}, due); got != 0 { t.Errorf("ohne Fälligkeit: Stufe %d", got) } custom := DunningSchedule{Level1: day, Level2: 2 * day, Level3: 3 * day} if got := custom.Level(due, due.Add(2*day)); got != 2 { t.Errorf("eigener Zeitplan: Stufe %d, erwartet 2", got) } } func TestApply_MaintenanceForDownService(t *testing.T) { labels := wpLabels() labels["traefik.http.routers.shop.rule"] = "Host(`shop.kunde.de`)" labels["traefik.http.routers.shop.service"] = "shop" labels["traefik.http.routers.wp.service"] = "wp" labels["traefik.http.services.shop.loadbalancer.server.port"] = "8080" in := input(t, labels) in.DownServices = map[string]bool{"shop": true} res, err := engine().Apply(in) if err != nil { t.Fatal(err) } if res.Stage != StageNormal { t.Errorf("Mandanten-Stage = %q, Wartung betrifft nur den Router", res.Stage) } if res.Config.HTTP.Routers["kunde__node-1__shop"].Service != InterceptorService { t.Error("ausgefallener Dienst nicht zum Interceptor umgeleitet") } if res.Config.HTTP.Routers["kunde__node-1__wp"].Service != "kunde__node-1__wp" { t.Error("gesunder Dienst wurde umgeleitet") } hdr := res.Config.HTTP.Middlewares["sultan__kunde__node-1__shop"]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any) if hdr[HeaderStage] != string(StageMaintenance) || hdr[HeaderTier] != "pro" { t.Errorf("Header = %v", hdr) } if res.Config.HTTP.Routers["sultan__kunde__node-1__shop"] != nil { t.Error("Wartung darf keinen Cookie-Durchlass haben") } } func TestApply_BlockedAndDrainedProduceNothing(t *testing.T) { blocked := input(t, wpLabels()) blocked.Tenant.Status = store.TenantQuarantined drained := input(t, wpLabels()) drained.NodeDrained = true for name, in := range map[string]Input{"Quarantäne": blocked, "gedraint": drained} { res, err := engine().Apply(in) if err != nil || res.Config == nil || res.Config.HTTP != nil { t.Errorf("%s: res=%+v err=%v", name, res.Config, err) } } } func TestApply_Errors(t *testing.T) { needsInterceptor := input(t, wpLabels()) needsInterceptor.Tenant.DunningLevel = 1 if _, err := (&Engine{now: engine().now}).Apply(needsInterceptor); !errors.Is(err, ErrNoInterceptor) { t.Errorf("ohne InterceptorURL: erwartet ErrNoInterceptor, bekommen %v", err) } tests := map[string]func(*Input){ "Lizenz eines anderen Mandanten": func(in *Input) { in.License.TenantID = "andere" }, "Mandanten-ID mit __": func(in *Input) { in.Tenant.ID, in.License.TenantID = "a__b", "a__b" }, "Mandanten-ID endet mit _": func(in *Input) { in.Tenant.ID, in.License.TenantID = "a_", "a_" }, "reservierte Mandanten-ID": func(in *Input) { in.Tenant.ID, in.License.TenantID = "sultan", "sultan" }, "Knoten-ID leer": func(in *Input) { in.NodeID = "" }, "Knoten-ID mit @": func(in *Input) { in.NodeID = "n@x" }, } for name, mutate := range tests { in := input(t, wpLabels()) mutate(&in) if _, err := engine().Apply(in); !errors.Is(err, ErrInvalidInput) { t.Errorf("%s: erwartet ErrInvalidInput, bekommen %v", name, err) } } } func TestApply_NilConfig(t *testing.T) { in := input(t, wpLabels()) in.Config = nil res, err := engine().Apply(in) if err != nil || res.Config == nil || res.Config.HTTP != nil { t.Errorf("res=%+v err=%v", res.Config, err) } } func keys[V any](m map[string]V) []string { var out []string for k := range m { out = append(out, k) } return out } // Andockstelle für die geplante Middleware-Zielbegrenzung: ohne Prüffunktion // wird alles geduldet; mit Prüffunktion beobachten oder durchsetzen. func TestApply_MiddlewareTargetHook(t *testing.T) { labels := wpLabels() labels["traefik.http.middlewares.auth.forwardauth.address"] = "http://10.0.0.99:9000/auth" labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth" in := input(t, labels) in.License = lic(license.TierPro, 10, license.FeatureCustomMiddleware) // Ohne Hook: dulden. res, _ := engine().Apply(in) if len(res.Violations) != 0 || res.Config.HTTP.Routers["kunde__node-1__wp"] == nil { t.Fatalf("ohne Hook: %+v", res.Violations) } var calls []string hook := func(mwName string, def traefik.Middleware, backend string) error { calls = append(calls, mwName+"→"+backend) if _, ok := def["forwardauth"]; ok { return errors.New("Ziel außerhalb des Backends") } return nil } observe := engine() observe.MiddlewareTargetCheck = hook res, _ = observe.Apply(in) if res.Config.HTTP.Routers["kunde__node-1__wp"] == nil { t.Error("Beobachtungsmodus darf nicht sperren") } if len(res.Violations) != 1 || res.Violations[0].Kind != ViolationMiddlewareTarget || !res.Violations[0].Observed { t.Errorf("Beobachtungsmodus: %+v", res.Violations) } if !reflect.DeepEqual(calls, []string{"auth→http://100.64.0.42:80", "wp-www→http://100.64.0.42:80"}) { t.Errorf("Hook-Aufrufe = %v", calls) } enforce := engine() enforce.MiddlewareTargetCheck = hook enforce.EnforceMiddlewareTargets = true res, _ = enforce.Apply(in) if res.Config.HTTP != nil { t.Error("Durchsetzungsmodus: Router hätte entfernt werden müssen") } if len(res.Violations) != 1 || res.Violations[0].Kind != ViolationMiddlewareTarget || res.Violations[0].Observed { t.Errorf("Durchsetzungsmodus: %+v", res.Violations) } } func TestValidID(t *testing.T) { for id, want := range map[string]bool{ "kunde": true, "kunde_schmidt": true, "node-proxmox-vm-42": true, "A1": true, "": false, "a__b": false, "_a": false, "a_": false, "-a": false, "a.b": false, "a@b": false, "a b": false, } { if got := ValidID(id); got != want { t.Errorf("ValidID(%q) = %v, erwartet %v", id, got, want) } } } func headersOf(t *testing.T, res Result, router string) map[string]any { t.Helper() mw := res.Config.HTTP.Middlewares["sultan__kunde__node-1__"+router] if mw == nil { t.Fatalf("keine Interceptor-Middleware für %s", router) } return mw["headers"].(map[string]any)["customRequestHeaders"].(map[string]any) } func TestApply_NodeMaintenance(t *testing.T) { in := input(t, wpLabels()) until := now.Add(2 * time.Hour) in.Maintenance = &Maintenance{Until: until, PublicReason: "Sicherheitsupdate (CVE-1234) – Sicherheit geht vor."} res, err := engine().Apply(in) if err != nil { t.Fatal(err) } if res.Config.HTTP.Routers["kunde__node-1__wp"].Service != InterceptorService { t.Fatal("Knoten in Wartung nicht zum Interceptor umgeleitet") } h := headersOf(t, res, "wp") if h[HeaderStage] != string(StageMaintenance) || h[HeaderUntil] != until.Format(time.RFC3339) { t.Errorf("Header: %v", h) } // Öffentlicher Grund URL-kodiert (Header sind einzeilig, Traefik reicht Bytes nicht zuverlässig durch). if h[HeaderPublicReason] != url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit geht vor.") { t.Errorf("öffentlicher Grund: %v", h[HeaderPublicReason]) } } func TestApply_NodeMaintenanceUnlimitedAndExpired(t *testing.T) { in := input(t, wpLabels()) in.Maintenance = &Maintenance{} // unbefristet, ohne Grund res, _ := engine().Apply(in) h := headersOf(t, res, "wp") if _, ok := h[HeaderUntil]; ok { t.Error("unbefristete Wartung mit Ende-Header") } if _, ok := h[HeaderPublicReason]; ok { t.Error("Wartung ohne öffentlichen Grund mit Grund-Header") } in.Maintenance = &Maintenance{Until: now} // gerade abgelaufen res, _ = engine().Apply(in) if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService { t.Error("abgelaufene Wartung leitet noch um") } } func TestApply_ServiceMaintenance(t *testing.T) { labels := wpLabels() labels["traefik.http.routers.wp.service"] = "wp" labels["traefik.http.routers.shop.rule"] = "Host(`shop.kunde.de`)" labels["traefik.http.routers.shop.service"] = "shop" labels["traefik.http.services.shop.loadbalancer.server.port"] = "8080" in := input(t, labels) in.ServiceMaintenance = map[string]Maintenance{"shop": {PublicReason: "Inventur"}} res, _ := engine().Apply(in) if res.Config.HTTP.Routers["kunde__node-1__shop"].Service != InterceptorService { t.Error("Dienst in Wartung nicht umgeleitet") } if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService { t.Error("anderer Dienst wurde umgeleitet") } if h := headersOf(t, res, "shop"); h[HeaderPublicReason] != "Inventur" { t.Errorf("Header: %v", h) } } // Vorrang: Sperre/drain > Ablauf/Paywall > Wartung > Mahnhinweis > normal. func TestApply_MaintenancePrecedence(t *testing.T) { tests := []struct { name string mutate func(*Input) stage Stage // Stufe am Router; "" = keine Routen ackSeen bool }{ {"Wartung schlägt Mahnstufe 1", func(in *Input) { in.Tenant.DunningLevel = 1 }, StageMaintenance, false}, {"Paywall schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantSuspendedPayment }, StagePaywall, false}, {"Ablauf schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantExpired }, StageLanding, false}, {"drain schlägt Wartung", func(in *Input) { in.NodeDrained = true }, "", false}, {"Quarantäne schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantQuarantined }, "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { in := input(t, wpLabels()) in.Maintenance = &Maintenance{} tt.mutate(&in) res, err := engine().Apply(in) if err != nil { t.Fatal(err) } if tt.stage == "" { if res.Config.HTTP != nil { t.Errorf("erwartet keine Routen: %v", keys(res.Config.HTTP.Routers)) } return } if h := headersOf(t, res, "wp"); h[HeaderStage] != string(tt.stage) { t.Errorf("Stufe %v, erwartet %s", h[HeaderStage], tt.stage) } if _, ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]; ack != tt.ackSeen { t.Errorf("Durchlass-Router vorhanden=%v", ack) } }) } } // Ungeplant getrennt (Lease noch gültig): Wartungsseite ohne Ende und ohne Grund. func TestApply_DisconnectedNode(t *testing.T) { in := input(t, wpLabels()) in.Disconnected = true res, _ := engine().Apply(in) h := headersOf(t, res, "wp") if h[HeaderStage] != string(StageMaintenance) { t.Errorf("Stufe %v", h[HeaderStage]) } if _, ok := h[HeaderUntil]; ok { t.Error("getrennter Knoten mit Ende-Header") } // Angemeldete Wartung hat Vorrang (deren Grund/Ende wird gezeigt). in.Maintenance = &Maintenance{PublicReason: "Reboot nach Update"} res, _ = engine().Apply(in) if h := headersOf(t, res, "wp"); h[HeaderPublicReason] != url.QueryEscape("Reboot nach Update") { t.Errorf("Grund der Wartung fehlt: %v", h) } // Paywall bleibt Paywall. in.Maintenance = nil in.Tenant.Status = store.TenantSuspendedPayment res, _ = engine().Apply(in) if h := headersOf(t, res, "wp"); h[HeaderStage] != string(StagePaywall) { t.Errorf("Paywall verdrängt: %v", h[HeaderStage]) } }