Files
sultan/pkg/policy/policy_test.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

730 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}
}